├── .gitignore ├── BACKLOG.md ├── CHANGELOG.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── flake8.cfg ├── git-alt.svg ├── git-black.svg ├── git-red.svg ├── git-white.svg ├── gitonic ├── __init__.py ├── __main__.py ├── const.py ├── file.py ├── gitutil.py ├── gui.py ├── icons.py ├── main.py ├── pyjsoncfg.py ├── singleinstance.py ├── sysutil.py ├── task.py └── tile │ ├── __init__.py │ ├── core.py │ └── tkcmd.py ├── install_linux.sh ├── patch_version.py ├── pyproject.toml ├── requirements.txt ├── run_main.py ├── run_qs_tool.sh ├── setup.cfg ├── setup.py ├── setupchk.py └── setuputil.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | bin/ 3 | dist/ 4 | build/ 5 | include/ 6 | lib/ 7 | lib64 8 | share/ 9 | **/__pycache__/ 10 | *.egg-info 11 | pyvenv.cfg 12 | .venv/ 13 | .eggs/ 14 | 15 | -------------------------------------------------------------------------------- /BACKLOG.md: -------------------------------------------------------------------------------- 1 | 2 | # BACKLOG 3 | 4 | - documentation 5 | - testcases? 6 | - revert changes / git checkout support 7 | - undo support for unstaged files 8 | - git restore 9 | - ~~git fetch / merge support~~ 10 | - ~~currently gitonic supports only pull~~ 11 | - git branch support 12 | - create 13 | - switch 14 | - git diff rework 15 | - support also git diff --staged. 16 | - support merge-base, refer to 17 | - git difftool master...contrib 18 | - git merge-base contrib master 19 | - git merge-base contrib master --name-only 20 | - diff on base of single file history 21 | - merge tool integration 22 | - git commit 23 | - support also git commit --amend 24 | - git tag support 25 | - git stash support (list, show, push, pop/apply, drop, clear) 26 | - support verbose output in expert mode 27 | - support flags -v and -vv where applicable 28 | - settings tabs 29 | - check for installed git 30 | - ~~git exe configuration in settings~~ 31 | - check for latest version of gitonic 32 | - 33 | - ~~config file for last known config~~ 34 | - diff-tool blocks main screen (see next) 35 | - gui rework 36 | - theme support 37 | - resize behavior -> expand 38 | - ~~icons~~ 39 | - grid layout / less floating 40 | - freezing ui -> see background tasks 41 | - 42 | - background task and event loop -> freezing gui when running git utils 43 | - use TkCmd also for Cmd runners 44 | - status bar with running background tasks overview? 45 | - 46 | - refact for integration in other tools 47 | - filter git on 'changes' tab 48 | - rework logging 49 | - ~~remove print statements in main~~ 50 | - remove print statements in tile.core 51 | - automation / external jobs 52 | - ~~black PEP8 support~~ 53 | - desktop integration 54 | - open shell at repo path 55 | - open file management too at repo path 56 | - ~~history of commit texts~~ 57 | - ~~in combo box~~ 58 | - git log / show integration? 59 | - git error handling 60 | - switch to log tab after pull 61 | - logging, use python logger for expert mode output 62 | - support for .gitignore 63 | - adding single files 64 | - open .gitignore for editing 65 | - execute git operations in parallel where possible 66 | - make expert mode debugging out better (use python logging) 67 | - 68 | 69 | 70 | # OPEN ISSUES 71 | 72 | refer to [issues](https://github.com/kr-g/gitonic/issues) 73 | 74 | 75 | # LIMITATIONS 76 | 77 | - ~~currently the tracked workspace is fix located under `~/repo`~~ 78 | - ~~provided in version v0.0.2~~ 79 | - difftool only works with unstaged files, no diff on already staged or 80 | commited changes (same behavior as cmd-line `git difftool`) 81 | - git credentials basic support, 82 | you need to use https://git-scm.com/docs/git-credential-store. 83 | no separate credit store provided. 84 | - only existing git repo's under the workspace are supported, 85 | as of now no support to create a new git repo. 86 | use `git init`, or `git clone` manually from cmd-line 87 | - `gitonic` interacts with `git` just like starting in bash / commandline. 88 | at the present time there is _no_additional_ error checking. 89 | this must be done by checking the log tab manually where all cmdline output goes. 90 | - at the present time the context menu only works on the underlying file (row) in the table. 91 | there is no support for multiple files (selection) as of now. 92 | 93 | 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | 5 | ## version v0.17.0 - ??? 6 | - added `$NAME` to context-menu variables for `os.path.basename` placeholder 7 | - added official logos from [git-scm](https://git-scm.com/downloads/logos) 8 | - added `basic-ctx` sample in README 9 | - 10 | 11 | 12 | ## version v0.16.0 - 20250219 13 | 14 | - support for multiple repo workspaces 15 | - on `settings` tab the `workspaces` entry field accepts a 16 | list of paths separated by `;` 17 | - use `~` for user-home path 18 | - blanks between `;` separated paths are ignored 19 | - when using double or single quotes around paths make sure the `;` exists to separate paths 20 | - blanks at the start or end of paths are not supported 21 | - added `$PYTHON` to context menu variables 22 | - added clear message after commit select box 23 | - added keyboard interrupt cntrl+c handler 24 | - added `workdir` key for context menu handlers 25 | - changing versioning scheme in pypi 26 | - 27 | 28 | 29 | ## version v0.0.15 - 20240812 30 | 31 | - fix: blank in path will show file as deleted 32 | - fix: rename file with git 33 | - 34 | 35 | 36 | ## version v0.0.14 - 20240518 37 | 38 | - improved formatter 39 | - support for multiple file extension for single config entry 40 | - support for path expansion for formatter config 41 | - added sample for uncrustify (c, c++) 42 | - on "changes" tab 43 | - added context menu to open file system explorer 44 | - added support for custom context menu with `~/.gitonic/context.json` 45 | - calling merge-tool with more then more file selected on change tab will 46 | not block the user-interface anymore since starting independent from main process 47 | - **important** add `--newtab` to `meld` configuration in `.gitconfig` 48 | to re-use an already running instance of `meld` 49 | - context menu handler expands user path (`~`) 50 | - 51 | 52 | 53 | ## version v0.0.13 - 20231125 54 | 55 | - show all untracked files on changes tab (not only the folder) 56 | - 57 | 58 | 59 | ## version v0.0.12 - 20231111 60 | 61 | - support for more code formatters (included in standard installation as extras) 62 | - install with `pip install gitonic[PEP08]` 63 | - pycodestyle 64 | - autopep8 65 | - black 66 | - yapf 67 | - support for meld (included in standard installation as extra) 68 | - install with `pip install gitonic[MELD]` 69 | - installation script (linux) for using within a virtual environment 70 | - added more installation options 71 | - 72 | 73 | 74 | ## version v0.0.11 - 20230710 75 | 76 | - fixed logging 77 | - fix no branch crash when no branch is set up 78 | - added fetch buttons 79 | - double ESC will exit gitonic 80 | - new hotkey Alt-c to go directly to commit tab 81 | - added F5, F6 hotkeys for changing the active tabs 82 | - custom formatter support 83 | - defined under settings `formatter.json` 84 | - 85 | 86 | 87 | ## version v0.0.10 - 20230225 88 | 89 | - sort files in changes tab 90 | - 91 | 92 | 93 | ## version v0.0.9 - 20221008 94 | 95 | - git current branch in changes tab 96 | - support for black 97 | - 98 | 99 | 100 | ## version v0.0.8 - 20220802 101 | 102 | - bug fix, due to corrupted local env 103 | - double click in changes view will stage/ unstage a file 104 | - 105 | 106 | 107 | ## version v0.0.7 - 20220729 108 | 109 | - changes view height 110 | - bug fix, due to corrupted local env 111 | - bug fix, removed control-x hotkey 112 | - 113 | 114 | 115 | ## version v0.0.6 - 20220729 116 | 117 | - fix save settings 118 | - set focus on commit entry after clearing fields 119 | - expert (dev-mode) logging tab 120 | - calling diff-tool will not switch to log tab 121 | - icon support with 122 | [`fontawesome`](https://github.com/FortAwesome/Font-Awesome) 123 | and 124 | [`pytkfaicons`](https://github.com/kr-g/pytkfaicons) 125 | - added tooltips 126 | - added hotkeys 127 | - BUG fix: search only in first level of workspace folder for git folder 128 | - 129 | 130 | 131 | ## version v0.0.5 - 20220515 132 | 133 | - rework ui layout 134 | - use subprocess popen to capture stdout, and stderr 135 | - added "commit + push" button 136 | - 137 | 138 | 139 | ## version v0.0.4 - 20211107 140 | 141 | - fix find git repo in workspace (with similar starting letters) 142 | - hopefully all childhood diseases are gone for now 143 | - 144 | 145 | 146 | ## version v0.0.3 - 20211107 147 | 148 | - thonny-gitonic plugin support 149 | - 150 | 151 | 152 | ## pre version v0.0.2 - 20211106 153 | 154 | - config parameter in config file 155 | - workspace not fixed anymore to ~/repo 156 | - refactored 157 | - commit history 158 | - documentation in readme 159 | - 160 | 161 | 162 | ## pre version v0.0.1 - 20211106 163 | 164 | - first version 165 | - no yet released official 166 | - 167 | - 168 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | Copyright (c) 2022 k. goger - https://github.com/kr-g 4 | 5 | --- 6 | 7 | https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License 8 | 9 | --- 10 | 11 | GNU AFFERO GENERAL PUBLIC LICENSE 12 | Version 3, 19 November 2007 13 | 14 | Copyright (C) 2007 Free Software Foundation, Inc. 15 | Everyone is permitted to copy and distribute verbatim copies 16 | of this license document, but changing it is not allowed. 17 | 18 | Preamble 19 | 20 | The GNU Affero General Public License is a free, copyleft license for 21 | software and other kinds of works, specifically designed to ensure 22 | cooperation with the community in the case of network server software. 23 | 24 | The licenses for most software and other practical works are designed 25 | to take away your freedom to share and change the works. By contrast, 26 | our General Public Licenses are intended to guarantee your freedom to 27 | share and change all versions of a program--to make sure it remains free 28 | software for all its users. 29 | 30 | When we speak of free software, we are referring to freedom, not 31 | price. Our General Public Licenses are designed to make sure that you 32 | have the freedom to distribute copies of free software (and charge for 33 | them if you wish), that you receive source code or can get it if you 34 | want it, that you can change the software or use pieces of it in new 35 | free programs, and that you know you can do these things. 36 | 37 | Developers that use our General Public Licenses protect your rights 38 | with two steps: (1) assert copyright on the software, and (2) offer 39 | you this License which gives you legal permission to copy, distribute 40 | and/or modify the software. 41 | 42 | A secondary benefit of defending all users' freedom is that 43 | improvements made in alternate versions of the program, if they 44 | receive widespread use, become available for other developers to 45 | incorporate. Many developers of free software are heartened and 46 | encouraged by the resulting cooperation. However, in the case of 47 | software used on network servers, this result may fail to come about. 48 | The GNU General Public License permits making a modified version and 49 | letting the public access it on a server without ever releasing its 50 | source code to the public. 51 | 52 | The GNU Affero General Public License is designed specifically to 53 | ensure that, in such cases, the modified source code becomes available 54 | to the community. It requires the operator of a network server to 55 | provide the source code of the modified version running there to the 56 | users of that server. Therefore, public use of a modified version, on 57 | a publicly accessible server, gives the public access to the source 58 | code of the modified version. 59 | 60 | An older license, called the Affero General Public License and 61 | published by Affero, was designed to accomplish similar goals. This is 62 | a different license, not a version of the Affero GPL, but Affero has 63 | released a new version of the Affero GPL which permits relicensing under 64 | this license. 65 | 66 | The precise terms and conditions for copying, distribution and 67 | modification follow. 68 | 69 | TERMS AND CONDITIONS 70 | 71 | 0. Definitions. 72 | 73 | "This License" refers to version 3 of the GNU Affero General Public License. 74 | 75 | "Copyright" also means copyright-like laws that apply to other kinds of 76 | works, such as semiconductor masks. 77 | 78 | "The Program" refers to any copyrightable work licensed under this 79 | License. Each licensee is addressed as "you". "Licensees" and 80 | "recipients" may be individuals or organizations. 81 | 82 | To "modify" a work means to copy from or adapt all or part of the work 83 | in a fashion requiring copyright permission, other than the making of an 84 | exact copy. The resulting work is called a "modified version" of the 85 | earlier work or a work "based on" the earlier work. 86 | 87 | A "covered work" means either the unmodified Program or a work based 88 | on the Program. 89 | 90 | To "propagate" a work means to do anything with it that, without 91 | permission, would make you directly or secondarily liable for 92 | infringement under applicable copyright law, except executing it on a 93 | computer or modifying a private copy. Propagation includes copying, 94 | distribution (with or without modification), making available to the 95 | public, and in some countries other activities as well. 96 | 97 | To "convey" a work means any kind of propagation that enables other 98 | parties to make or receive copies. Mere interaction with a user through 99 | a computer network, with no transfer of a copy, is not conveying. 100 | 101 | An interactive user interface displays "Appropriate Legal Notices" 102 | to the extent that it includes a convenient and prominently visible 103 | feature that (1) displays an appropriate copyright notice, and (2) 104 | tells the user that there is no warranty for the work (except to the 105 | extent that warranties are provided), that licensees may convey the 106 | work under this License, and how to view a copy of this License. If 107 | the interface presents a list of user commands or options, such as a 108 | menu, a prominent item in the list meets this criterion. 109 | 110 | 1. Source Code. 111 | 112 | The "source code" for a work means the preferred form of the work 113 | for making modifications to it. "Object code" means any non-source 114 | form of a work. 115 | 116 | A "Standard Interface" means an interface that either is an official 117 | standard defined by a recognized standards body, or, in the case of 118 | interfaces specified for a particular programming language, one that 119 | is widely used among developers working in that language. 120 | 121 | The "System Libraries" of an executable work include anything, other 122 | than the work as a whole, that (a) is included in the normal form of 123 | packaging a Major Component, but which is not part of that Major 124 | Component, and (b) serves only to enable use of the work with that 125 | Major Component, or to implement a Standard Interface for which an 126 | implementation is available to the public in source code form. A 127 | "Major Component", in this context, means a major essential component 128 | (kernel, window system, and so on) of the specific operating system 129 | (if any) on which the executable work runs, or a compiler used to 130 | produce the work, or an object code interpreter used to run it. 131 | 132 | The "Corresponding Source" for a work in object code form means all 133 | the source code needed to generate, install, and (for an executable 134 | work) run the object code and to modify the work, including scripts to 135 | control those activities. However, it does not include the work's 136 | System Libraries, or general-purpose tools or generally available free 137 | programs which are used unmodified in performing those activities but 138 | which are not part of the work. For example, Corresponding Source 139 | includes interface definition files associated with source files for 140 | the work, and the source code for shared libraries and dynamically 141 | linked subprograms that the work is specifically designed to require, 142 | such as by intimate data communication or control flow between those 143 | subprograms and other parts of the work. 144 | 145 | The Corresponding Source need not include anything that users 146 | can regenerate automatically from other parts of the Corresponding 147 | Source. 148 | 149 | The Corresponding Source for a work in source code form is that 150 | same work. 151 | 152 | 2. Basic Permissions. 153 | 154 | All rights granted under this License are granted for the term of 155 | copyright on the Program, and are irrevocable provided the stated 156 | conditions are met. This License explicitly affirms your unlimited 157 | permission to run the unmodified Program. The output from running a 158 | covered work is covered by this License only if the output, given its 159 | content, constitutes a covered work. This License acknowledges your 160 | rights of fair use or other equivalent, as provided by copyright law. 161 | 162 | You may make, run and propagate covered works that you do not 163 | convey, without conditions so long as your license otherwise remains 164 | in force. You may convey covered works to others for the sole purpose 165 | of having them make modifications exclusively for you, or provide you 166 | with facilities for running those works, provided that you comply with 167 | the terms of this License in conveying all material for which you do 168 | not control copyright. Those thus making or running the covered works 169 | for you must do so exclusively on your behalf, under your direction 170 | and control, on terms that prohibit them from making any copies of 171 | your copyrighted material outside their relationship with you. 172 | 173 | Conveying under any other circumstances is permitted solely under 174 | the conditions stated below. Sublicensing is not allowed; section 10 175 | makes it unnecessary. 176 | 177 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 178 | 179 | No covered work shall be deemed part of an effective technological 180 | measure under any applicable law fulfilling obligations under article 181 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 182 | similar laws prohibiting or restricting circumvention of such 183 | measures. 184 | 185 | When you convey a covered work, you waive any legal power to forbid 186 | circumvention of technological measures to the extent such circumvention 187 | is effected by exercising rights under this License with respect to 188 | the covered work, and you disclaim any intention to limit operation or 189 | modification of the work as a means of enforcing, against the work's 190 | users, your or third parties' legal rights to forbid circumvention of 191 | technological measures. 192 | 193 | 4. Conveying Verbatim Copies. 194 | 195 | You may convey verbatim copies of the Program's source code as you 196 | receive it, in any medium, provided that you conspicuously and 197 | appropriately publish on each copy an appropriate copyright notice; 198 | keep intact all notices stating that this License and any 199 | non-permissive terms added in accord with section 7 apply to the code; 200 | keep intact all notices of the absence of any warranty; and give all 201 | recipients a copy of this License along with the Program. 202 | 203 | You may charge any price or no price for each copy that you convey, 204 | and you may offer support or warranty protection for a fee. 205 | 206 | 5. Conveying Modified Source Versions. 207 | 208 | You may convey a work based on the Program, or the modifications to 209 | produce it from the Program, in the form of source code under the 210 | terms of section 4, provided that you also meet all of these conditions: 211 | 212 | a) The work must carry prominent notices stating that you modified 213 | it, and giving a relevant date. 214 | 215 | b) The work must carry prominent notices stating that it is 216 | released under this License and any conditions added under section 217 | 7. This requirement modifies the requirement in section 4 to 218 | "keep intact all notices". 219 | 220 | c) You must license the entire work, as a whole, under this 221 | License to anyone who comes into possession of a copy. This 222 | License will therefore apply, along with any applicable section 7 223 | additional terms, to the whole of the work, and all its parts, 224 | regardless of how they are packaged. This License gives no 225 | permission to license the work in any other way, but it does not 226 | invalidate such permission if you have separately received it. 227 | 228 | d) If the work has interactive user interfaces, each must display 229 | Appropriate Legal Notices; however, if the Program has interactive 230 | interfaces that do not display Appropriate Legal Notices, your 231 | work need not make them do so. 232 | 233 | A compilation of a covered work with other separate and independent 234 | works, which are not by their nature extensions of the covered work, 235 | and which are not combined with it such as to form a larger program, 236 | in or on a volume of a storage or distribution medium, is called an 237 | "aggregate" if the compilation and its resulting copyright are not 238 | used to limit the access or legal rights of the compilation's users 239 | beyond what the individual works permit. Inclusion of a covered work 240 | in an aggregate does not cause this License to apply to the other 241 | parts of the aggregate. 242 | 243 | 6. Conveying Non-Source Forms. 244 | 245 | You may convey a covered work in object code form under the terms 246 | of sections 4 and 5, provided that you also convey the 247 | machine-readable Corresponding Source under the terms of this License, 248 | in one of these ways: 249 | 250 | a) Convey the object code in, or embodied in, a physical product 251 | (including a physical distribution medium), accompanied by the 252 | Corresponding Source fixed on a durable physical medium 253 | customarily used for software interchange. 254 | 255 | b) Convey the object code in, or embodied in, a physical product 256 | (including a physical distribution medium), accompanied by a 257 | written offer, valid for at least three years and valid for as 258 | long as you offer spare parts or customer support for that product 259 | model, to give anyone who possesses the object code either (1) a 260 | copy of the Corresponding Source for all the software in the 261 | product that is covered by this License, on a durable physical 262 | medium customarily used for software interchange, for a price no 263 | more than your reasonable cost of physically performing this 264 | conveying of source, or (2) access to copy the 265 | Corresponding Source from a network server at no charge. 266 | 267 | c) Convey individual copies of the object code with a copy of the 268 | written offer to provide the Corresponding Source. This 269 | alternative is allowed only occasionally and noncommercially, and 270 | only if you received the object code with such an offer, in accord 271 | with subsection 6b. 272 | 273 | d) Convey the object code by offering access from a designated 274 | place (gratis or for a charge), and offer equivalent access to the 275 | Corresponding Source in the same way through the same place at no 276 | further charge. You need not require recipients to copy the 277 | Corresponding Source along with the object code. If the place to 278 | copy the object code is a network server, the Corresponding Source 279 | may be on a different server (operated by you or a third party) 280 | that supports equivalent copying facilities, provided you maintain 281 | clear directions next to the object code saying where to find the 282 | Corresponding Source. Regardless of what server hosts the 283 | Corresponding Source, you remain obligated to ensure that it is 284 | available for as long as needed to satisfy these requirements. 285 | 286 | e) Convey the object code using peer-to-peer transmission, provided 287 | you inform other peers where the object code and Corresponding 288 | Source of the work are being offered to the general public at no 289 | charge under subsection 6d. 290 | 291 | A separable portion of the object code, whose source code is excluded 292 | from the Corresponding Source as a System Library, need not be 293 | included in conveying the object code work. 294 | 295 | A "User Product" is either (1) a "consumer product", which means any 296 | tangible personal property which is normally used for personal, family, 297 | or household purposes, or (2) anything designed or sold for incorporation 298 | into a dwelling. In determining whether a product is a consumer product, 299 | doubtful cases shall be resolved in favor of coverage. For a particular 300 | product received by a particular user, "normally used" refers to a 301 | typical or common use of that class of product, regardless of the status 302 | of the particular user or of the way in which the particular user 303 | actually uses, or expects or is expected to use, the product. A product 304 | is a consumer product regardless of whether the product has substantial 305 | commercial, industrial or non-consumer uses, unless such uses represent 306 | the only significant mode of use of the product. 307 | 308 | "Installation Information" for a User Product means any methods, 309 | procedures, authorization keys, or other information required to install 310 | and execute modified versions of a covered work in that User Product from 311 | a modified version of its Corresponding Source. The information must 312 | suffice to ensure that the continued functioning of the modified object 313 | code is in no case prevented or interfered with solely because 314 | modification has been made. 315 | 316 | If you convey an object code work under this section in, or with, or 317 | specifically for use in, a User Product, and the conveying occurs as 318 | part of a transaction in which the right of possession and use of the 319 | User Product is transferred to the recipient in perpetuity or for a 320 | fixed term (regardless of how the transaction is characterized), the 321 | Corresponding Source conveyed under this section must be accompanied 322 | by the Installation Information. But this requirement does not apply 323 | if neither you nor any third party retains the ability to install 324 | modified object code on the User Product (for example, the work has 325 | been installed in ROM). 326 | 327 | The requirement to provide Installation Information does not include a 328 | requirement to continue to provide support service, warranty, or updates 329 | for a work that has been modified or installed by the recipient, or for 330 | the User Product in which it has been modified or installed. Access to a 331 | network may be denied when the modification itself materially and 332 | adversely affects the operation of the network or violates the rules and 333 | protocols for communication across the network. 334 | 335 | Corresponding Source conveyed, and Installation Information provided, 336 | in accord with this section must be in a format that is publicly 337 | documented (and with an implementation available to the public in 338 | source code form), and must require no special password or key for 339 | unpacking, reading or copying. 340 | 341 | 7. Additional Terms. 342 | 343 | "Additional permissions" are terms that supplement the terms of this 344 | License by making exceptions from one or more of its conditions. 345 | Additional permissions that are applicable to the entire Program shall 346 | be treated as though they were included in this License, to the extent 347 | that they are valid under applicable law. If additional permissions 348 | apply only to part of the Program, that part may be used separately 349 | under those permissions, but the entire Program remains governed by 350 | this License without regard to the additional permissions. 351 | 352 | When you convey a copy of a covered work, you may at your option 353 | remove any additional permissions from that copy, or from any part of 354 | it. (Additional permissions may be written to require their own 355 | removal in certain cases when you modify the work.) You may place 356 | additional permissions on material, added by you to a covered work, 357 | for which you have or can give appropriate copyright permission. 358 | 359 | Notwithstanding any other provision of this License, for material you 360 | add to a covered work, you may (if authorized by the copyright holders of 361 | that material) supplement the terms of this License with terms: 362 | 363 | a) Disclaiming warranty or limiting liability differently from the 364 | terms of sections 15 and 16 of this License; or 365 | 366 | b) Requiring preservation of specified reasonable legal notices or 367 | author attributions in that material or in the Appropriate Legal 368 | Notices displayed by works containing it; or 369 | 370 | c) Prohibiting misrepresentation of the origin of that material, or 371 | requiring that modified versions of such material be marked in 372 | reasonable ways as different from the original version; or 373 | 374 | d) Limiting the use for publicity purposes of names of licensors or 375 | authors of the material; or 376 | 377 | e) Declining to grant rights under trademark law for use of some 378 | trade names, trademarks, or service marks; or 379 | 380 | f) Requiring indemnification of licensors and authors of that 381 | material by anyone who conveys the material (or modified versions of 382 | it) with contractual assumptions of liability to the recipient, for 383 | any liability that these contractual assumptions directly impose on 384 | those licensors and authors. 385 | 386 | All other non-permissive additional terms are considered "further 387 | restrictions" within the meaning of section 10. If the Program as you 388 | received it, or any part of it, contains a notice stating that it is 389 | governed by this License along with a term that is a further 390 | restriction, you may remove that term. If a license document contains 391 | a further restriction but permits relicensing or conveying under this 392 | License, you may add to a covered work material governed by the terms 393 | of that license document, provided that the further restriction does 394 | not survive such relicensing or conveying. 395 | 396 | If you add terms to a covered work in accord with this section, you 397 | must place, in the relevant source files, a statement of the 398 | additional terms that apply to those files, or a notice indicating 399 | where to find the applicable terms. 400 | 401 | Additional terms, permissive or non-permissive, may be stated in the 402 | form of a separately written license, or stated as exceptions; 403 | the above requirements apply either way. 404 | 405 | 8. Termination. 406 | 407 | You may not propagate or modify a covered work except as expressly 408 | provided under this License. Any attempt otherwise to propagate or 409 | modify it is void, and will automatically terminate your rights under 410 | this License (including any patent licenses granted under the third 411 | paragraph of section 11). 412 | 413 | However, if you cease all violation of this License, then your 414 | license from a particular copyright holder is reinstated (a) 415 | provisionally, unless and until the copyright holder explicitly and 416 | finally terminates your license, and (b) permanently, if the copyright 417 | holder fails to notify you of the violation by some reasonable means 418 | prior to 60 days after the cessation. 419 | 420 | Moreover, your license from a particular copyright holder is 421 | reinstated permanently if the copyright holder notifies you of the 422 | violation by some reasonable means, this is the first time you have 423 | received notice of violation of this License (for any work) from that 424 | copyright holder, and you cure the violation prior to 30 days after 425 | your receipt of the notice. 426 | 427 | Termination of your rights under this section does not terminate the 428 | licenses of parties who have received copies or rights from you under 429 | this License. If your rights have been terminated and not permanently 430 | reinstated, you do not qualify to receive new licenses for the same 431 | material under section 10. 432 | 433 | 9. Acceptance Not Required for Having Copies. 434 | 435 | You are not required to accept this License in order to receive or 436 | run a copy of the Program. Ancillary propagation of a covered work 437 | occurring solely as a consequence of using peer-to-peer transmission 438 | to receive a copy likewise does not require acceptance. However, 439 | nothing other than this License grants you permission to propagate or 440 | modify any covered work. These actions infringe copyright if you do 441 | not accept this License. Therefore, by modifying or propagating a 442 | covered work, you indicate your acceptance of this License to do so. 443 | 444 | 10. Automatic Licensing of Downstream Recipients. 445 | 446 | Each time you convey a covered work, the recipient automatically 447 | receives a license from the original licensors, to run, modify and 448 | propagate that work, subject to this License. You are not responsible 449 | for enforcing compliance by third parties with this License. 450 | 451 | An "entity transaction" is a transaction transferring control of an 452 | organization, or substantially all assets of one, or subdividing an 453 | organization, or merging organizations. If propagation of a covered 454 | work results from an entity transaction, each party to that 455 | transaction who receives a copy of the work also receives whatever 456 | licenses to the work the party's predecessor in interest had or could 457 | give under the previous paragraph, plus a right to possession of the 458 | Corresponding Source of the work from the predecessor in interest, if 459 | the predecessor has it or can get it with reasonable efforts. 460 | 461 | You may not impose any further restrictions on the exercise of the 462 | rights granted or affirmed under this License. For example, you may 463 | not impose a license fee, royalty, or other charge for exercise of 464 | rights granted under this License, and you may not initiate litigation 465 | (including a cross-claim or counterclaim in a lawsuit) alleging that 466 | any patent claim is infringed by making, using, selling, offering for 467 | sale, or importing the Program or any portion of it. 468 | 469 | 11. Patents. 470 | 471 | A "contributor" is a copyright holder who authorizes use under this 472 | License of the Program or a work on which the Program is based. The 473 | work thus licensed is called the contributor's "contributor version". 474 | 475 | A contributor's "essential patent claims" are all patent claims 476 | owned or controlled by the contributor, whether already acquired or 477 | hereafter acquired, that would be infringed by some manner, permitted 478 | by this License, of making, using, or selling its contributor version, 479 | but do not include claims that would be infringed only as a 480 | consequence of further modification of the contributor version. For 481 | purposes of this definition, "control" includes the right to grant 482 | patent sublicenses in a manner consistent with the requirements of 483 | this License. 484 | 485 | Each contributor grants you a non-exclusive, worldwide, royalty-free 486 | patent license under the contributor's essential patent claims, to 487 | make, use, sell, offer for sale, import and otherwise run, modify and 488 | propagate the contents of its contributor version. 489 | 490 | In the following three paragraphs, a "patent license" is any express 491 | agreement or commitment, however denominated, not to enforce a patent 492 | (such as an express permission to practice a patent or covenant not to 493 | sue for patent infringement). To "grant" such a patent license to a 494 | party means to make such an agreement or commitment not to enforce a 495 | patent against the party. 496 | 497 | If you convey a covered work, knowingly relying on a patent license, 498 | and the Corresponding Source of the work is not available for anyone 499 | to copy, free of charge and under the terms of this License, through a 500 | publicly available network server or other readily accessible means, 501 | then you must either (1) cause the Corresponding Source to be so 502 | available, or (2) arrange to deprive yourself of the benefit of the 503 | patent license for this particular work, or (3) arrange, in a manner 504 | consistent with the requirements of this License, to extend the patent 505 | license to downstream recipients. "Knowingly relying" means you have 506 | actual knowledge that, but for the patent license, your conveying the 507 | covered work in a country, or your recipient's use of the covered work 508 | in a country, would infringe one or more identifiable patents in that 509 | country that you have reason to believe are valid. 510 | 511 | If, pursuant to or in connection with a single transaction or 512 | arrangement, you convey, or propagate by procuring conveyance of, a 513 | covered work, and grant a patent license to some of the parties 514 | receiving the covered work authorizing them to use, propagate, modify 515 | or convey a specific copy of the covered work, then the patent license 516 | you grant is automatically extended to all recipients of the covered 517 | work and works based on it. 518 | 519 | A patent license is "discriminatory" if it does not include within 520 | the scope of its coverage, prohibits the exercise of, or is 521 | conditioned on the non-exercise of one or more of the rights that are 522 | specifically granted under this License. You may not convey a covered 523 | work if you are a party to an arrangement with a third party that is 524 | in the business of distributing software, under which you make payment 525 | to the third party based on the extent of your activity of conveying 526 | the work, and under which the third party grants, to any of the 527 | parties who would receive the covered work from you, a discriminatory 528 | patent license (a) in connection with copies of the covered work 529 | conveyed by you (or copies made from those copies), or (b) primarily 530 | for and in connection with specific products or compilations that 531 | contain the covered work, unless you entered into that arrangement, 532 | or that patent license was granted, prior to 28 March 2007. 533 | 534 | Nothing in this License shall be construed as excluding or limiting 535 | any implied license or other defenses to infringement that may 536 | otherwise be available to you under applicable patent law. 537 | 538 | 12. No Surrender of Others' Freedom. 539 | 540 | If conditions are imposed on you (whether by court order, agreement or 541 | otherwise) that contradict the conditions of this License, they do not 542 | excuse you from the conditions of this License. If you cannot convey a 543 | covered work so as to satisfy simultaneously your obligations under this 544 | License and any other pertinent obligations, then as a consequence you may 545 | not convey it at all. For example, if you agree to terms that obligate you 546 | to collect a royalty for further conveying from those to whom you convey 547 | the Program, the only way you could satisfy both those terms and this 548 | License would be to refrain entirely from conveying the Program. 549 | 550 | 13. Remote Network Interaction; Use with the GNU General Public License. 551 | 552 | Notwithstanding any other provision of this License, if you modify the 553 | Program, your modified version must prominently offer all users 554 | interacting with it remotely through a computer network (if your version 555 | supports such interaction) an opportunity to receive the Corresponding 556 | Source of your version by providing access to the Corresponding Source 557 | from a network server at no charge, through some standard or customary 558 | means of facilitating copying of software. This Corresponding Source 559 | shall include the Corresponding Source for any work covered by version 3 560 | of the GNU General Public License that is incorporated pursuant to the 561 | following paragraph. 562 | 563 | Notwithstanding any other provision of this License, you have 564 | permission to link or combine any covered work with a work licensed 565 | under version 3 of the GNU General Public License into a single 566 | combined work, and to convey the resulting work. The terms of this 567 | License will continue to apply to the part which is the covered work, 568 | but the work with which it is combined will remain governed by version 569 | 3 of the GNU General Public License. 570 | 571 | 14. Revised Versions of this License. 572 | 573 | The Free Software Foundation may publish revised and/or new versions of 574 | the GNU Affero General Public License from time to time. Such new versions 575 | will be similar in spirit to the present version, but may differ in detail to 576 | address new problems or concerns. 577 | 578 | Each version is given a distinguishing version number. If the 579 | Program specifies that a certain numbered version of the GNU Affero General 580 | Public License "or any later version" applies to it, you have the 581 | option of following the terms and conditions either of that numbered 582 | version or of any later version published by the Free Software 583 | Foundation. If the Program does not specify a version number of the 584 | GNU Affero General Public License, you may choose any version ever published 585 | by the Free Software Foundation. 586 | 587 | If the Program specifies that a proxy can decide which future 588 | versions of the GNU Affero General Public License can be used, that proxy's 589 | public statement of acceptance of a version permanently authorizes you 590 | to choose that version for the Program. 591 | 592 | Later license versions may give you additional or different 593 | permissions. However, no additional obligations are imposed on any 594 | author or copyright holder as a result of your choosing to follow a 595 | later version. 596 | 597 | 15. Disclaimer of Warranty. 598 | 599 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 600 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 601 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 602 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 603 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 604 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 605 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 606 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 607 | 608 | 16. Limitation of Liability. 609 | 610 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 611 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 612 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 613 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 614 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 615 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 616 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 617 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 618 | SUCH DAMAGES. 619 | 620 | 17. Interpretation of Sections 15 and 16. 621 | 622 | If the disclaimer of warranty and limitation of liability provided 623 | above cannot be given local legal effect according to their terms, 624 | reviewing courts shall apply local law that most closely approximates 625 | an absolute waiver of all civil liability in connection with the 626 | Program, unless a warranty or assumption of liability accompanies a 627 | copy of the Program in return for a fee. 628 | 629 | END OF TERMS AND CONDITIONS 630 | 631 | How to Apply These Terms to Your New Programs 632 | 633 | If you develop a new program, and you want it to be of the greatest 634 | possible use to the public, the best way to achieve this is to make it 635 | free software which everyone can redistribute and change under these terms. 636 | 637 | To do so, attach the following notices to the program. It is safest 638 | to attach them to the start of each source file to most effectively 639 | state the exclusion of warranty; and each file should have at least 640 | the "copyright" line and a pointer to where the full notice is found. 641 | 642 | 643 | Copyright (C) 644 | 645 | This program is free software: you can redistribute it and/or modify 646 | it under the terms of the GNU Affero General Public License as published 647 | by the Free Software Foundation, either version 3 of the License, or 648 | (at your option) any later version. 649 | 650 | This program is distributed in the hope that it will be useful, 651 | but WITHOUT ANY WARRANTY; without even the implied warranty of 652 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 653 | GNU Affero General Public License for more details. 654 | 655 | You should have received a copy of the GNU Affero General Public License 656 | along with this program. If not, see . 657 | 658 | Also add information on how to contact you by electronic and paper mail. 659 | 660 | If your software can interact with users remotely through a computer 661 | network, you should also make sure that it provides a way for users to 662 | get its source. For example, if your program is a web application, its 663 | interface could display a "Source" link that leads users to an archive 664 | of the code. There are many ways you could offer source, and different 665 | solutions will be better for different programs; see section 13 for the 666 | specific requirements. 667 | 668 | You should also get your employer (if you work as a programmer) or school, 669 | if any, to sign a "copyright disclaimer" for the program, if necessary. 670 | For more information on this, and how to apply and follow the GNU AGPL, see 671 | . 672 | 673 | https://www.gnu.org/licenses/agpl-3.0.de.html 674 | 675 | 676 | 677 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.md 2 | include README.md 3 | include CHANGELOG.md 4 | include BACKLOG.md 5 | 6 | include git-*.svg 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PEP-08](https://img.shields.io/badge/code%20style-PEP08-green.svg)](https://www.python.org/dev/peps/pep-0008/) 2 | 3 | 4 | you are reading VERSION = v0.17.0-develop 5 | 6 | 7 | # gitonic 8 | 9 | gitonic simplifies working with multiple git repositories. 10 | 11 | 12 | gitonic comes with an easy to use Tkinter GUI. 13 | 14 | 15 | # background 16 | 17 | where software components/ artefacts are stored in multiple repositories 18 | separately gitonic helps to keep track 19 | 20 | 21 | # what's new ? 22 | 23 | Check 24 | [`CHANGELOG`](https://github.com/kr-g/gitonic/blob/main/CHANGELOG.md) 25 | for latest ongoing, or upcoming news. 26 | 27 | 28 | # limitations 29 | 30 | Check 31 | [`BACKLOG`](https://github.com/kr-g/gitonic/blob/main/BACKLOG.md) 32 | for open development tasks and limitations. 33 | 34 | IMPORTANT: 35 | 36 | `gitonic` interacts with `git` just like starting in bash / commandline. 37 | at the present time there is **no additional** error checking. 38 | this must be done by checking the log tab manually where all cmdline output goes. 39 | 40 | 41 | # recommended readings prior using gitonic 42 | 43 | an introduction on how git works in general can be found in the official git documentation in section 44 | [`Git-Basics`](https://git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository). 45 | 46 | another very good book 47 | [`progit2`](https://github.com/progit/progit2) 48 | can be downloaded from the official github project page. 49 | under [`releases`](https://github.com/progit/progit2/releases) 50 | are different formats such as pdf, epub ... 51 | 52 | 53 | # how to use 54 | 55 | todo - documentation pending 56 | 57 | 58 | ## working with mutiple git repositories 59 | 60 | when adding a git repo to the tracked list, and staging files (from different repositories) 61 | a following commit command is executed on **all** repositories in the tracked list with the same commit message. 62 | this is not a bug; moreover it is intened to be like that. 63 | 64 | 65 | # hotkeys 66 | 67 | following list contains all hotkeys: 68 | 69 | | key | action | 70 | |---|---| 71 | | Esc | minimize the app window | 72 | | Esc Esc | press quickly Esc twice to close the app | 73 | | Control-p | pull all tracked gits | 74 | | Control-Shift-p | fetch all tracked gits | 75 | | F1 | refresh all in changed files view | 76 | | F2 | select all in changed files view | 77 | | F3 | un-select all in changed files view | 78 | | F5 | select previous tab | 79 | | F6 | select next tab | 80 | | Alt-a | stage file(s) in git | 81 | | Alt-q | un-stage file(s) in git | 82 | | Alt-w | diff file(s) | 83 | | Alt-d | difftool file(s) | 84 | | Alt-f | auto format file(s) with `black` PEP08 | 85 | | Alt-c | go to commit tab and clear commit message field(s) | 86 | | Alt-x | commit file(s) | 87 | | Alt-s | push git(s) | 88 | | Alt-e | commit and push git(s), like pressing Alt-x and Alt-s | 89 | 90 | HINT: 91 | 92 | if not responding to a hotkey, make sure that CapsLock is disabled 93 | 94 | 95 | # file status staged / unstaged 96 | 97 | the file status is the same as when calling 98 | [`git status --porcelain`](https://git-scm.com/docs/git-status#_options). 99 | see also under [`git status output`](https://git-scm.com/docs/git-status#_output). 100 | 101 | | status | comment | 102 | |---|---| 103 | | M | modified | 104 | | A | added | 105 | | D | deleted | 106 | | R | renamed | 107 | | C | copied | 108 | | U | updated but unmerged | 109 | | ?? | not in git. untracked | 110 | 111 | NOTE: 112 | `gitonic` use porcelain format version 1. 113 | 114 | 115 | # file formatter 116 | 117 | it is possible to configure external formatter tools depending on the file extension. 118 | 119 | `gitonic` will use the configuration file `~/.gitonic/formatter.json`. 120 | 121 | the general structure is: 122 | 123 | { 124 | ".file-ext": { 125 | "cmd": "full-path-to-command", 126 | "para": [ 127 | "%file" 128 | ] 129 | } 130 | } 131 | 132 | where `.file-ext` is a simple file extension such as `.py`, 133 | or a comma separated list of extensions `.c,.h,.cpp`. 134 | wild-cards are not supported. 135 | 136 | where `para` is an array of cmd-line options passed to the formatter command 137 | where `%file` is a placeholder and replaced by the file name 138 | 139 | for different file extensions `gitonic` will call the formatter accordingly 140 | even if the selected files are of different types (extensions) 141 | 142 | 143 | ## templates for python pep08 formatters 144 | 145 | all of the following tools are part of `gitonic` standard installation 146 | (available as extra, see below under installation). 147 | choose the one what fits best for your needs. 148 | 149 | 150 | ### autopep8 151 | 152 | [autopep8](https://github.com/hhatto/autopep8) 153 | is a python formatter what fixes problems reported by 154 | [pycodestyle](https://github.com/PyCQA/pycodestyle). 155 | pycodestyle is an official tool from python's code quality authority. 156 | 157 | { 158 | ".py": { 159 | "cmd": "autopep8", 160 | "para": [ 161 | "-i", 162 | "%file" 163 | ] 164 | } 165 | } 166 | 167 | to use the same code formatter also for 168 | [`cython's`](https://github.com/cython/cython) 169 | files with extension `.pyx` change the setting to 170 | 171 | { 172 | ".py,.pyx": { 173 | "cmd": "autopep8", 174 | "para": [ 175 | "-i", 176 | "%file" 177 | ] 178 | } 179 | } 180 | 181 | or in case autopep8 is installed in a venv, e.g. 182 | 183 | { 184 | ".py,.pyx": { 185 | "cmd": "~/gitonic/.venv/bin/autopep8", 186 | "para": [ 187 | "-i", 188 | "%file" 189 | ] 190 | } 191 | } 192 | 193 | 194 | 195 | ### black 196 | 197 | black is also an offical python tool, but resolves not fully to issues reported by pycodestyle. 198 | there might be some rework required (from case to case). 199 | result is quite similar to 200 | [autopep8](https://github.com/hhatto/autopep8) 201 | beside the list reported by pycodestyle (after formatting) 202 | is a bit longer comparing to 203 | [autopep8](https://github.com/hhatto/autopep8) 204 | what does a better job here. 205 | 206 | { 207 | ".py": { 208 | "cmd": "black", 209 | "para": [ 210 | "%file" 211 | ] 212 | } 213 | } 214 | 215 | 216 | ### yapf 217 | 218 | yapf (google python code formmater) is slow comparing the former tools, and re-arranges code so 219 | that it is reported as error by pycodestyle after formatting. 220 | 221 | { 222 | ".py": { 223 | "cmd": "yapf", 224 | "para": [ 225 | "-i", 226 | "%file" 227 | ] 228 | } 229 | } 230 | 231 | 232 | ## templates for c, c++ formatters 233 | 234 | all of the following tools are NOT part of `gitonic` standard installation. 235 | 236 | 237 | ### uncrustify 238 | 239 | [`uncrustify`](https://github.com/uncrustify/uncrustify) 240 | is a code formatter for c, c++ (and other languages). 241 | 242 | here in this config sample it is configured for extensions `.c`, `.h`, and `.cpp`. 243 | 244 | IMPORTANT: `uncrustify` requires an additional config file, a sample can be found here 245 | [`uncrustify.cfg`](https://github.com/uncrustify/uncrustify/blob/master/forUncrustifySources.cfg) 246 | 247 | here in the sample the `uncrustify` config file is placed in path `~/.gitonic/uncrustify.cfg`. 248 | make sure that it is there. 249 | 250 | the following needs to be placed inside `~/.gitonic/formatter.json` 251 | 252 | 253 | ".c,.h,.cpp": { 254 | "cmd": "uncrustify", 255 | "para": [ 256 | "-c", 257 | "~/.gitonic/uncrustify.cfg", 258 | "--replace", 259 | "%file", 260 | "--no-backup", 261 | "--if-changed" 262 | ] 263 | } 264 | 265 | 266 | # custom context menu handler 267 | 268 | when clicking on the `changes` tab right a context memu opens offering 269 | to open the system file manager tool (file explorer) 270 | at the base git repo path or at the changed file path. 271 | 272 | in addition it is possible to add own custom context menu entries here. 273 | configuration is done with `~/.gitonic/context.json` file. 274 | 275 | the general structure is: 276 | 277 | { 278 | "a-context-name-ctx": { 279 | "expr": "*", 280 | "workdir": ".", 281 | "menu": [ 282 | [ 283 | "some text $GIT", 284 | [ 285 | "cmd-path", 286 | "whatever_param=$GIT" 287 | ] 288 | ], 289 | [ 290 | "some other text $PATH", 291 | [ 292 | "cmd-path2", 293 | "whatever_param=$PATH" 294 | ] 295 | ] 296 | ] 297 | }, 298 | ... 299 | } 300 | 301 | 302 | here the variables `$GIT`, `$FILE`, `$PATH`, `$NAME`, or `$PYTHON` 303 | are replaced by the corrosponding path before execution. 304 | where `$PYTHON` expands to `sys.executable` from `gitonic` runtime. 305 | and `$NAME` is a placeholder for `os.path.basedir`, 306 | and `$PATH` for `os.path.dirname` 307 | 308 | the `expr` key contains a single file pattern, or a list of 309 | file patterns - when to enable the context menu. 310 | the file pattern is following Unix filename pattern matching. 311 | 312 | the `workdir` key will change the current working directory before running 313 | the command. 314 | 315 | the `menu` array contains the text to display in the menu, 316 | and the command params as embedded array. 317 | 318 | the context name as such can have any value 319 | (as long it is unique in the structure). 320 | 321 | 322 | below a sample `~/.gitonic/context.json` file 323 | for running on linux with xfce. 324 | 325 | { 326 | "term-ctx": { 327 | "expr": "*", 328 | "menu": [ 329 | [ 330 | "Open Terminal at $GIT", 331 | [ 332 | "xfce4-terminal", 333 | "--working-directory=$GIT" 334 | ] 335 | ], 336 | [ 337 | "Open Terminal at $PATH", 338 | [ 339 | "xfce4-terminal", 340 | "--working-directory=$PATH" 341 | ] 342 | ], 343 | [ 344 | "Edit .gitignore at $GIT", 345 | [ 346 | "xed", 347 | "$GIT/.gitignore" 348 | ] 349 | ] 350 | ] 351 | }, 352 | "basic-ctx": { 353 | "expr": "*", 354 | "menu": [ 355 | [ 356 | "less $NAME at $PATH", 357 | [ 358 | "xfce4-terminal", 359 | "-x", 360 | "less", 361 | "$FILE" 362 | ] 363 | ], 364 | [ 365 | "edit $NAME at $PATH", 366 | [ 367 | "xed", 368 | "$FILE" 369 | ] 370 | ], 371 | [ 372 | "vi $NAME at $PATH", 373 | [ 374 | "xfce4-terminal", 375 | "-x", 376 | "vi", 377 | "$FILE" 378 | ] 379 | ] 380 | ] 381 | }, 382 | "spyder-ctx": { 383 | "expr": "*.py", 384 | "menu": [ 385 | [ 386 | "spyder python $FILE", 387 | [ 388 | "~/spyder/.venv/bin/spyder", 389 | "$FILE" 390 | ] 391 | ] 392 | ] 393 | }, 394 | "autopep8-ctx": { 395 | "expr": "*.py", 396 | "menu": [ 397 | [ 398 | "autopep8 python $FILE", 399 | [ 400 | "autopep8", 401 | "-i", 402 | "$FILE" 403 | ] 404 | ] 405 | ] 406 | }, 407 | "geany-path": { 408 | "expr": [ 409 | "*.c", 410 | "*.cpp", 411 | "*.h" 412 | ], 413 | "menu": [ 414 | [ 415 | "geany c $FILE", 416 | [ 417 | "geany", 418 | "$FILE" 419 | ] 420 | ] 421 | ] 422 | }, 423 | "uncrustify-path": { 424 | "expr": [ 425 | "*.c", 426 | "*.cpp", 427 | "*.h" 428 | ], 429 | "menu": [ 430 | [ 431 | "uncrustify c $FILE", 432 | [ 433 | "uncrustify", 434 | "-c", 435 | "~/.gitonic/uncrustify.cfg", 436 | "--replace", 437 | "$FILE", 438 | "--no-backup", 439 | "--if-changed" 440 | ] 441 | ] 442 | ] 443 | }, 444 | "git-base-tools": { 445 | "expr": [ 446 | "*" 447 | ], 448 | "workdir" : "$GIT", 449 | "menu": [ 450 | [ 451 | "gitk $GIT", 452 | [ 453 | "gitk" 454 | ] 455 | ], 456 | [ 457 | "git gui $GIT", 458 | [ 459 | "git", 460 | "gui" 461 | ] 462 | ] 463 | ] 464 | } 465 | } 466 | 467 | 468 | remark: 469 | the sample config file provides support for opening 470 | - terminal, in this case `xfce4-terminal`, can be replaced by e.g. `xterm` - depending on your distribution 471 | - open file with `less`, `xed`, or `vi` - might be different with your distribution (see line above) 472 | - `.gitignore` file for selected repo with [`xed`](https://en.wikipedia.org/wiki/Xed) 473 | - [`spyder-ide.org`](https://spyder-ide.org/), for files matching `*.py` 474 | - [`geany`](https://www.geany.org/), for files matching `*.c`, `*.cpp`, `*.h` 475 | - `gitk` and `git gui` the base git tools which are automatically installed with `git` 476 | - 477 | 478 | 479 | **limitation:** 480 | 481 | at the present time the context menu only works on the underlying file (row) in the table. 482 | there is **no** support for multiple files (selection) as of now. 483 | 484 | 485 | 486 | # platform 487 | 488 | tested on python3, and linux 489 | 490 | 491 | # development status 492 | 493 | alpha state, use on your own risk!!! 494 | 495 | 496 | # installation 497 | 498 | it is recommented to install gitonic into a 499 | [virtual environment](https://docs.python.org/3/library/venv.html). 500 | the following script will install gitonic in your home directory (linux). 501 | 502 | # create virtual environment 503 | cd ~ 504 | python3 -m venv gitonic 505 | 506 | # install gitonic with recommended extras 507 | ~/gitonic/bin/pip install gitonic[PEP08,MELD] 508 | or 509 | ~/gitonic/bin/pip install gitonic[DEFAULT] 510 | 511 | 512 | this script can be found here 513 | [`install_linux.sh`](install_linux.sh) 514 | 515 | to install just `gitonic` without the extras replace with 516 | 517 | # install gitonic 518 | ~/gitonic/bin/pip install gitonic 519 | 520 | 521 | 522 | to run gitonic use the script from the virtual environment directly 523 | (no prior venv activation required) 524 | 525 | ~/gitonic/bin/gitonic 526 | 527 | 528 | it is recommented to create an alias in `.bash_aliases`. 529 | add the following line at the end of `.bash_aliases` 530 | 531 | alias gitonic=~/gitonic/bin/gitonic 532 | 533 | 534 | ## other installation dependencies 535 | 536 | to use git difftool, and mergetool, download and install a 3rd party tool like 537 | [`meld merge`](https://meldmerge.org/) 538 | and configure like described below. 539 | 540 | note: 541 | 542 | with gitonic >= v0.12.0 meld is already included in standard installation 543 | (as extra, see also under installation) and download is obsolete when 544 | installed as part of gitonic. you just need to configure git then. 545 | 546 | in case meld installation fails install into the virtual environment 547 | 548 | cd ~ 549 | ~/gitonic/bin/pip install PyGObject 550 | 551 | 552 | ### all installation options 553 | 554 | options can be combined by `|`. 555 | use 556 | 557 | ~/gitonic/bin/pip install gitonic[*options*] 558 | 559 | 560 | |option|included packages| 561 | |---|---| 562 | | PEP08 | pycodestyle, flake8, autopep8 | 563 | | PEP08_BLACK | pycodestyle, flake8, black | 564 | | PEP08_FULL | pycodestyle, flake8, autopep8, black, yapf | 565 | | MELD | meld | 566 | | DEFAULT | pycodestyle, flake8, autopep8, meld | 567 | 568 | 569 | ## installation on raspberry pi, or fedora 570 | 571 | when during startup an error is thrown, refer to 572 | [installation on raspberry, fedorra](https://github.com/kr-g/gitonic/issues/6) 573 | 574 | 575 | # git configuration 576 | 577 | add a `.git-credentials` file as described here 578 | [`git-credentials`](https://git-scm.com/docs/git-credential-store) 579 | 580 | 581 | add a `.gitconfig` file as described here 582 | [`git-config`](https://git-scm.com/docs/git-config) 583 | and configure for diff and merge tools. 584 | NOTE: you need to install the diff-tool e.g. 585 | [`meld merge`](https://meldmerge.org/) manually, 586 | if meld is not installed pressing the button will have no effect. 587 | 588 | 589 | [user] 590 | name = your name 591 | email = you@email.tld 592 | 593 | [credential] 594 | helper = store 595 | 596 | [diff] 597 | tool = meld 598 | [difftool] 599 | prompt = false 600 | [difftool "meld"] 601 | cmd = meld --newtab "$LOCAL" "$REMOTE" 602 | 603 | [merge] 604 | tool = meld 605 | [mergetool "meld"] 606 | # Choose one of these 2 lines (not both!) 607 | # cmd = meld "$LOCAL" "$MERGED" "$REMOTE" --output "$MERGED" 608 | cmd = meld --newtab "$LOCAL" "$BASE" "$REMOTE" --output "$MERGED" 609 | 610 | 611 | # additional notes 612 | 613 | the following tools are part of the standard git distribution 614 | 615 | - [`gitk`](https://git-scm.com/docs/gitk) 616 | git history browser 617 | - [`git-gui`](https://git-scm.com/docs/git-gui/) 618 | a git front end 619 | 620 | other gui-clients are listed on [`git-scm`](https://git-scm.com/downloads/guis) 621 | 622 | 623 | # internals 624 | 625 | following files are used: 626 | 627 | |file|description| 628 | |---|---| 629 | |~/.gitonic/commit.json|the last commit messages show in the combo box| 630 | |~/.gitonic/config.json|configuration settings| 631 | |~/.gitonic/context.json|configuration for context menu on changes tab| 632 | |~/.gitonic/formatter.json|configuration for external formatter tools| 633 | |~/.gitonic/tracked.json|tracked git repositories| 634 | |~/.gitonic/socket|internal use| 635 | 636 | 637 | HINT: 638 | crash after configuration change can be resolved by changing the setting manually 639 | in `config.json`, or delete the config file to fall back to the defaults 640 | 641 | 642 | # license 643 | 644 | gitonic is released under the following 645 | [`LICENSE`](https://github.com/kr-g/gitonic/blob/main/LICENSE.md) 646 | 647 | git logos from [git-scm](https://git-scm.com/downloads/logos) 648 | 649 | 650 | -------------------------------------------------------------------------------- /flake8.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | ignore = 4 | E203, # E203 whitespace before ':' 5 | E501, # E501 line too long (86 > 79 characters) 6 | E711, # E711 comparison to None should be 'if cond is not None:' 7 | E712, # E712 comparison to True should be 'if cond is True:' or 'if cond:' 8 | W503 # W503 line break before binary operator 9 | 10 | exclude = 11 | # 12 | # 13 | .git, 14 | __pycache__, 15 | docs, 16 | old, 17 | build, 18 | dist, 19 | tests, 20 | *.egg-info, 21 | setup.py 22 | .venv/ 23 | 24 | max-complexity = 13 25 | -------------------------------------------------------------------------------- /git-alt.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /git-black.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /git-red.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /git-white.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /gitonic/__init__.py: -------------------------------------------------------------------------------- 1 | _tk_root = None 2 | 3 | 4 | def set_tk_root(tk_root): 5 | global _tk_root 6 | _tk_root = tk_root 7 | return tk_root 8 | 9 | 10 | def get_tk_root(): 11 | return _tk_root 12 | -------------------------------------------------------------------------------- /gitonic/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from gitonic.main import main_func 3 | 4 | # from .main import main_func as gui_func 5 | 6 | if __name__ == "__main__": 7 | rc = main_func() 8 | sys.exit(rc) 9 | -------------------------------------------------------------------------------- /gitonic/const.py: -------------------------------------------------------------------------------- 1 | VERSION = "v0.17.0-develop" 2 | -------------------------------------------------------------------------------- /gitonic/file.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c)2021 K. Goger - https://github.com/kr-g 3 | legal: https://github.com/kr-g/gitonic/blob/main/LICENSE.md 4 | """ 5 | 6 | 7 | import sys 8 | import os 9 | import stat 10 | import time 11 | 12 | import tempfile 13 | 14 | import warnings 15 | 16 | from collections import namedtuple 17 | import glob 18 | 19 | 20 | class PushDir(object): 21 | @staticmethod 22 | def basedir(path=None, create=False): 23 | def _func(func): 24 | def decor(*args, **kwargs): 25 | with PushDir(path, create): 26 | return func(*args, **kwargs) 27 | 28 | return decor 29 | 30 | return _func 31 | 32 | def __init__(self, path=None, create=False): 33 | self._path = path 34 | self._create = create 35 | self._cwd = os.path.abspath(os.getcwd()) 36 | 37 | def __enter__(self): 38 | if self._path: 39 | self._path = os.path.expandvars(self._path) 40 | self._path = os.path.expanduser(self._path) 41 | self._path = os.path.abspath(self._path) 42 | if self._create: 43 | os.makedirs(self._path, exist_ok=True) 44 | 45 | os.chdir(self._path) 46 | return self 47 | 48 | def __exit__(self, extype, exval, traceb): 49 | os.chdir(self._cwd) 50 | return self 51 | 52 | def __repr__(self): 53 | return self.__class__.__name__ + '( "' + self._cwd + '" )' 54 | 55 | 56 | class FileStat(object): 57 | Time = namedtuple("Time", ["ctime", "atime", "mtime"]) 58 | 59 | def __init__(self, name=".", expand=True, prefetch=False): 60 | self.name = name 61 | self._stat = None 62 | if expand == True and name != None: 63 | self.expandall() 64 | if prefetch: 65 | self.stat() 66 | 67 | def clone(self): 68 | return FileStat(self.name) 69 | 70 | def __repr__(self): 71 | return ( 72 | self.__class__.__name__ 73 | + "('" 74 | + str(self.name) 75 | + "', " 76 | + str(self._stat) 77 | + ")" 78 | ) 79 | 80 | def stat(self): 81 | """most class methods expect stat() called first. NoneType exception when not loaded""" 82 | try: 83 | self._stat = os.stat(self.name) 84 | return self._stat 85 | except Exception as ex: 86 | print(ex, self.name) 87 | 88 | def clr_stat(self): 89 | self._stat = None 90 | return self 91 | 92 | # base 93 | 94 | def exists(self): 95 | return self.stat() != None 96 | 97 | def mode(self): 98 | return self._stat.st_mode 99 | 100 | def is_file(self): 101 | return stat.S_ISREG(self.mode()) 102 | 103 | def is_dir(self): 104 | return stat.S_ISDIR(self.mode()) 105 | 106 | # 107 | 108 | def __len__(self): 109 | return self.size() 110 | 111 | def size(self): 112 | l = self.stat() 113 | if l == None: 114 | return 115 | return self._stat.st_size 116 | 117 | # path file ext 118 | 119 | def dirname(self): 120 | return os.path.dirname(self.name) 121 | 122 | def basename(self): 123 | return os.path.basename(self.name) 124 | 125 | def splitext(self): 126 | return os.path.splitext(self.basename()) 127 | 128 | # split into dirname basename ext 129 | 130 | def split(self): 131 | return self.dirname(), *self.splitext() 132 | 133 | # removes common path 134 | 135 | def stripbase(self, base, clobber=True, with_dot=True): 136 | base = self.expandpath(base) + os.sep 137 | name = self.name 138 | if name.startswith(base): 139 | name = name[len(base) :] 140 | if with_dot: 141 | name = "." + os.sep + name 142 | if clobber: 143 | self.name = name 144 | return name 145 | 146 | # path 147 | 148 | def makedirs(self, is_file=True, mode=0o777): 149 | """set is_file to False to create folder""" 150 | path = self.name 151 | if is_file == True: 152 | path = os.path.dirname(path) 153 | os.makedirs(path, mode=mode, exist_ok=True) 154 | return path 155 | 156 | def join(self, path, expand_clobber=True, append=True): 157 | if append: 158 | self.name = FileStat.joinpath([self.name, *path], expand_clobber) 159 | else: 160 | self.name = FileStat.joinpath(path, expand_clobber) 161 | return self 162 | 163 | # os and user var 164 | 165 | def expandall(self, clobber=True): 166 | fpath = self.expandpath(self.name) 167 | if clobber: 168 | self.name = fpath 169 | return fpath 170 | 171 | @staticmethod 172 | def expandpath(fpath): 173 | fpath = os.path.expandvars(fpath) 174 | fpath = os.path.expanduser(fpath) 175 | fpath = os.path.abspath(fpath) 176 | return fpath 177 | 178 | @staticmethod 179 | def joinpath(path, expand=True): 180 | path = os.path.join(*path) 181 | if expand: 182 | path = FileStat.expandpath(path) 183 | return path 184 | 185 | # temp helpers 186 | 187 | @staticmethod 188 | def get_tempdir(): 189 | return tempfile.gettempdir() 190 | 191 | # def get_tempfile(suffix=None, prefix=None,): 192 | # """this file is not deleted automatically""" 193 | # return tempfile.mkstemp(suffix=suffix, prefix=prefix,) 194 | 195 | # os env helpers 196 | 197 | @staticmethod 198 | def setenv(k, v): 199 | os.environ[k] = v 200 | 201 | @staticmethod 202 | def getenv(k): 203 | return os.environ[k] 204 | 205 | # first and last access time 206 | # looking on _all_ time stamps 207 | 208 | def ftime(self, conv_tm=True): 209 | """first access time, base utc like time.time()""" 210 | return self._cmptime( 211 | cmp=min, cmpval=sys.maxsize, conv=time.gmtime if conv_tm else None 212 | ) 213 | 214 | def ltime(self, conv_tm=True): 215 | """last access time, base utc like time.time()""" 216 | return self._cmptime(cmp=max, cmpval=0, conv=time.gmtime if conv_tm else None) 217 | 218 | def _cmptime(self, cmp, cmpval, conv): 219 | for t in self.st_time(): 220 | cmpval = cmp(cmpval, t) 221 | return cmpval if conv == None else conv(cmpval) 222 | 223 | # time helper 224 | 225 | P_CREATE = 0 226 | P_ACCESS = 1 227 | P_MODIFY = 2 228 | 229 | T_MODIFY = "m" 230 | T_ACCESS = "a" 231 | T_CREATE = "c" 232 | 233 | def st_time(self, wrap=False, when=None): 234 | t = None 235 | if when == None: 236 | # oder: create access modify 237 | t = self._stat.st_ctime, self._stat.st_atime, self._stat.st_mtime 238 | return FileStat.Time(*t) if wrap else t 239 | elif when == "m": 240 | t = self._stat.st_mtime 241 | elif when == "a": 242 | t = self._stat.st_atime 243 | elif when == "c": 244 | t = self._stat.st_ctime 245 | else: 246 | raise Exception("invalid", when) 247 | return t 248 | 249 | # 250 | 251 | def time(self, wrap=True): 252 | t = self.st_time() 253 | return FileStat.Time(*t) if wrap else t 254 | 255 | def amtime(self): 256 | return self.st_time()[P_CREATE + 1 :] # todo: order seq 257 | 258 | def utctime(self, wrap=True): 259 | return self._convtime(time.gmtime, wrap=wrap) 260 | 261 | def gmtime(self, wrap=True): 262 | """deprecated, use utctime()""" 263 | # warnings.warn("use utctime()", DeprecationWarning, ) 264 | return self.utctime(wrap=wrap) 265 | 266 | def localtime(self, wrap=True): 267 | # use always utc for computation 268 | # local time for display 269 | return self._convtime(time.localtime, wrap=wrap) 270 | 271 | def _convtime(self, conv, wrap=True): 272 | tm = [conv(t) for t in self.st_time()] 273 | return FileStat.Time(*tm) if wrap else tm 274 | 275 | # touch 276 | 277 | def touch_ux(self, amtime=None): 278 | return os.utime(self.name, times=amtime) 279 | 280 | @staticmethod 281 | def _default(v, d): 282 | return v if v != None else d 283 | 284 | def touch_am(self, atime=None, mtime=None): 285 | n = time.time() 286 | atime = self._default(atime, n) 287 | mtime = self._default(mtime, n) 288 | return atime, mtime 289 | 290 | def touch_a(self, atime=None): 291 | atime = self._default(atime, time.time()) 292 | return atime, self._stat.st_mtime 293 | 294 | def touch_m(self, mtime=None): 295 | mtime = self._default(mtime, time.time()) 296 | return self._stat.st_atime, mtime 297 | 298 | # explore 299 | 300 | def scandir(self, expand=True): 301 | self.stat() 302 | if not self.is_dir: 303 | raise Exception("not a folder") 304 | mf = map(lambda x: FileStat(x.path, expand=expand), os.scandir(self.name)) 305 | return mf 306 | 307 | def iglob(self, pattern=None, recursive=False): 308 | self.stat() 309 | if not self.is_dir: 310 | raise Exception("not a folder") 311 | 312 | if pattern == None: 313 | pattern = "**" if recursive else "*" 314 | 315 | fit = FileStat(self.name) if pattern == None else self.clone().join([pattern]) 316 | 317 | it = glob.iglob(fit.name, recursive=recursive) 318 | 319 | mf = map(lambda x: FileStat(x), it) 320 | return mf 321 | 322 | def files(self, expand=True): 323 | it = self.scandir(expand) 324 | f = filter(lambda x: x.stat() and x.is_file(), it) 325 | return f 326 | 327 | def folder(self, expand=True): 328 | it = self.scandir(expand) 329 | f = filter(lambda x: x.stat() and x.is_dir(), it) 330 | return f 331 | 332 | # manipulation 333 | 334 | def remove(self, dryrun=False): 335 | if self.exists() and dryrun == False: 336 | if self.is_file(): 337 | os.remove(self.name) 338 | elif self.is_dir(): 339 | os.rmdir(self.name) 340 | else: 341 | raise Exception("unsupported") 342 | 343 | def rename(self, name, dryrun=False): 344 | """renames a file in the same folder""" 345 | 346 | if name.find(os.sep) >= 0: 347 | raise Exception("wrong operation. use move instead") 348 | 349 | src = FileStat(self.name) 350 | if src.exists() == False: 351 | raise Exception("file dont exists", src.name) 352 | 353 | dest = FileStat().join([src.dirname(), name]) 354 | 355 | if dest.name == src.name: 356 | raise Exception("same file", src.name) 357 | 358 | if dest.exists(): 359 | raise Exception("file exists", dest.name) 360 | 361 | if dryrun == False: 362 | os.rename(src.name, dest.name) 363 | self.name = dest.name 364 | self.stat() 365 | 366 | return src.name, dest.name 367 | 368 | def move(self, dirname, dryrun=False): 369 | """moves the file to a different folder, keeping the name""" 370 | 371 | src = FileStat(self.name) 372 | if src.exists() == False: 373 | raise Exception("file dont exists", src.name) 374 | 375 | if src.is_file(): 376 | dest = FileStat(dirname) 377 | elif src.is_dir(): 378 | raise Exception("untested", src.name) 379 | # dest = FileStat().join([dirname,src.basename()]) 380 | else: 381 | raise Exception("file type not supported") 382 | 383 | if dest.name == src.name: 384 | raise Exception("same file", src.name) 385 | 386 | if dest.exists(): 387 | raise Exception("file exists", dest.name) 388 | 389 | if dryrun == False: 390 | dest.makedirs() 391 | os.rename(src.name, dest.name) 392 | self.name = dest.name 393 | self.stat() 394 | 395 | return src.name, dest.name 396 | -------------------------------------------------------------------------------- /gitonic/gitutil.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c)2021 K. Goger (k.r.goger@gmail.com) 3 | legal: https://github.com/kr-g/gitonic/blob/main/LICENSE.md 4 | """ 5 | 6 | 7 | import os 8 | import glob 9 | 10 | from .file import FileStat, PushDir 11 | from .task import Cmd, CmdTask 12 | from .sysutil import platform_windows 13 | 14 | GIT = "git.exe" if platform_windows() else "git" 15 | BLACK = "black" 16 | 17 | 18 | join_wait = True 19 | 20 | 21 | def set_wait_mode(mode=True): 22 | global join_wait 23 | join_wait = mode 24 | 25 | 26 | def set_git_exe(git_exe): 27 | global GIT 28 | GIT = git_exe 29 | 30 | 31 | def run_cmd(cmdline, callb=None): 32 | cmd = CmdTask().set_command(f"{cmdline}").set_callb(callb) 33 | cmd.start() 34 | if join_wait: 35 | cmd.join() 36 | assert not cmd.running() 37 | return cmd.popall() 38 | return cmd 39 | 40 | 41 | def git_cmd(cmdline, callb=None): 42 | return run_cmd(f"{GIT} {cmdline}", callb=callb) 43 | 44 | 45 | def with_git_cmd(repo, cmd, callb=None): 46 | with PushDir(repo) as pd: 47 | return git_cmd(cmd, callb=callb) 48 | 49 | 50 | def with_cmd(repo, cmd, callb=None): 51 | with PushDir(repo) as pd: 52 | return run_cmd(cmd, callb=callb) 53 | 54 | 55 | def join_files(files, sep=" "): 56 | return sep.join(map(lambda x: "'" + x + "'", files)) 57 | 58 | 59 | def run_black(repo, files, callb=None): return with_cmd( 60 | repo, f"{BLACK} {join_files(files)}", callb=callb 61 | ) 62 | 63 | 64 | def git_version(callb=None): return git_cmd( 65 | f"--version", callb=callb)[0].split()[2] 66 | 67 | 68 | def git_fetch(repo, callb=None): return with_git_cmd( 69 | repo, f"fetch", callb=callb) 70 | 71 | 72 | def git_pull(repo, callb=None): return with_git_cmd(repo, f"pull", callb=callb) 73 | 74 | 75 | def git_stat(repo, callb=None): return with_git_cmd( 76 | repo, f"status -u --porcelain", callb=callb 77 | ) 78 | 79 | 80 | def git_diff(repo, file, callb=None): return with_git_cmd( 81 | repo, f"diff {file}", callb=callb 82 | ) 83 | 84 | 85 | def git_difftool(repo, file, callb=None): return with_git_cmd( 86 | repo, f"difftool {file}", callb=callb 87 | ) 88 | 89 | 90 | def git_add(repo, files, callb=None): return with_git_cmd( 91 | repo, f"add {join_files(files)}", callb=callb 92 | ) 93 | 94 | 95 | def git_commit(repo, comment, callb=None): return with_git_cmd( 96 | repo, f"commit -m '{comment}'", callb=callb 97 | ) 98 | 99 | 100 | def git_commit_porcelain(repo, comment, callb=None): return with_git_cmd( 101 | repo, f"commit --porcelain -m '{comment}'", callb=callb 102 | ) 103 | 104 | 105 | def git_push(repo, callb=None): return with_git_cmd( 106 | repo, f"push --porcelain", callb=callb) 107 | 108 | 109 | def git_push_tags(repo, callb=None): return with_git_cmd( 110 | repo, f"push --porcelain --tags", callb=callb 111 | ) 112 | 113 | 114 | git_push_all = ( 115 | lambda repo, callb=None: git_push(repo, callb=callb) 116 | + ["---"] 117 | + git_push_tags(repo, callb=callb) 118 | ) 119 | 120 | 121 | def git_add_undo(repo, files, callb=None): return with_git_cmd( 122 | repo, f"restore --staged {join_files(files)}", callb=callb 123 | ) 124 | 125 | 126 | def git_checkout(repo, files, callb=None): return with_git_cmd( 127 | repo, f"checkout {join_files(files)}", callb=callb 128 | ) 129 | def git_checkout_ref(repo, ref, callb=None): return git_checkout( 130 | repo, [ref], callb=callb) 131 | 132 | 133 | def git_tags(repo, callb=None): return with_git_cmd(repo, "tag", callb=callb) 134 | 135 | 136 | def git_branch(repo, callb=None): return with_git_cmd( 137 | repo, "branch", callb=callb) 138 | 139 | 140 | def git_branch_all(repo, callb=None): return with_git_cmd( 141 | repo, "branch --all", callb=callb 142 | ) 143 | 144 | 145 | def git_curbranch(repo, callb=None): return with_git_cmd( 146 | repo, "branch --show-current", callb=callb 147 | ) 148 | 149 | 150 | def git_make_tag(repo, tag, callb=None): return with_git_cmd( 151 | repo, f"tag {tag}", callb=callb 152 | ) 153 | 154 | 155 | def git_make_branch(repo, branch, callb=None): return with_git_cmd( 156 | repo, f"branch {branch}", callb=callb 157 | ) 158 | 159 | 160 | class GitBranch(object): 161 | def __init__(self, current=None, bnam=None): 162 | self.set(current, bnam) 163 | 164 | def set(self, current, bnam): 165 | self.current = current 166 | self.bnam = bnam 167 | 168 | return self 169 | 170 | def from_str(self, s): 171 | current = s[0] == "*" 172 | bnam = s[2:].strip() 173 | self.set(current, bnam) 174 | return self 175 | 176 | def __repr__(self): 177 | return f"{self.__class__.__name__}('{ self.current }', '{ str(self.bnam) }' )" 178 | 179 | 180 | class GitStatus(object): 181 | def __init__(self, mode=None, staged=None, file=None): 182 | self.set(mode, staged, file) 183 | 184 | def set(self, mode, staged, file): 185 | self.mode = mode.upper() if mode else "" 186 | self.staged = staged.upper() if staged else "" 187 | 188 | if self.staged == "R": 189 | oldname, newname = file.split("->", maxsplit=1) 190 | file = newname # .strip() 191 | 192 | if file and file.startswith("\""): 193 | assert file.endswith("\""), "quoted string expected. " + str(file) 194 | file = file[1:-1] 195 | 196 | self.file = file 197 | self.state = {} 198 | 199 | for s, fc in [ 200 | ("M", "modified"), 201 | ("A", "added"), 202 | ("D", "deleted"), 203 | ("R", "renamed"), 204 | ("C", "copied"), 205 | ("U", "updated_but_unmerged"), 206 | ("??", "not_in_git"), 207 | ]: 208 | comb = {f"is_{fc}": self.mode.find(s) >= 0} 209 | self.state.update(comb) 210 | self.__dict__.update(comb) 211 | 212 | comb = {f"is_staged_{fc}": self.staged.find(s) >= 0} 213 | self.state.update(comb) 214 | self.__dict__.update(comb) 215 | 216 | return self 217 | 218 | def has_staged(self): 219 | return len(self.staged) > 0 220 | 221 | def from_str(self, s): 222 | if s[:2] == "??": 223 | mode = "??" 224 | staged = "" 225 | else: 226 | staged = s[0].strip() 227 | mode = s[1].strip() 228 | file = s[3:].strip() 229 | self.set(mode, staged, file) 230 | return self 231 | 232 | def __repr__(self): 233 | return f"{self.__class__.__name__}('{ self.file }', '{ str(self.mode) }', '{ str(self.staged) }' )" 234 | 235 | 236 | class GitRepo(object): 237 | def __init__(self, repo): 238 | self.path = FileStat(repo).name 239 | self.status = [] 240 | self.branch = [] 241 | self.current_branch = None 242 | 243 | def __repr__(self): 244 | return f"{self.__class__.__name__}('{ self.path }')" 245 | 246 | def refresh_status(self): 247 | file_status = git_stat(self.path) 248 | self.status.clear() 249 | 250 | for stat in file_status: 251 | gfs = GitStatus().from_str(stat) 252 | self.status.append(gfs) 253 | self.status.sort(key=lambda x: x.file) 254 | 255 | self.current_branch = None 256 | branches = git_branch(self.path) 257 | self.branch.clear() 258 | for branch in branches: 259 | gb = GitBranch().from_str(branch) 260 | self.branch.append(gb) 261 | if gb.current: 262 | self.current_branch = gb 263 | 264 | return self 265 | 266 | def stat(self, status): 267 | fs = FileStat(self.path).join([status.file]) 268 | fs.stat() 269 | return fs 270 | 271 | def has_staged(self): 272 | return any(map(lambda x: x.has_staged(), self.status)) 273 | 274 | 275 | class GitWorkspace(object): 276 | def __init__(self, base_repo_dir="~/repo"): 277 | base_repo_dir = base_repo_dir.split(";") 278 | base_repo_dir = map(lambda x: x.strip(), base_repo_dir) 279 | base_repo_dir = map(lambda x: self._strip_quotes(x), base_repo_dir) 280 | base_repo_dir = map(lambda x: x.strip(), base_repo_dir) 281 | base_repo_dir = filter(lambda x: len(x) > 0, base_repo_dir) 282 | base_repo_dir = filter(lambda x: FileStat(x).exists(), base_repo_dir) 283 | 284 | self.base_repo_dir = [FileStat(x) for x in base_repo_dir] 285 | self.gits = {} 286 | 287 | def __repr__(self): 288 | return f"{self.__class__.__name__}( { ', '.join(self.gits) } )" 289 | 290 | def _strip_quotes(self, s): 291 | for quo in ["\"", "\'"]: 292 | if s.startswith(quo) and s.endswith(quo): 293 | return s[1:-1] 294 | return s 295 | 296 | def refresh(self): 297 | self.gits.clear() 298 | for repodir in self.base_repo_dir: 299 | gits = repodir.iglob("*/.git", True) 300 | for g in gits: 301 | path = g.dirname() 302 | git = GitRepo(path) 303 | self.gits[path] = git 304 | 305 | def refresh_status(self): 306 | for _, git in self.gits.items(): 307 | git.refresh_status() 308 | 309 | def find(self, search_str): 310 | return list( 311 | map( 312 | lambda x: self.gits[x], 313 | filter(lambda x: x == search_str, self.gits), 314 | ) 315 | ) 316 | -------------------------------------------------------------------------------- /gitonic/gui.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from .const import VERSION 4 | 5 | import os 6 | import sys 7 | import time 8 | import json 9 | import webbrowser 10 | import fnmatch 11 | 12 | from .pyjsoncfg import Config 13 | 14 | import tkinter 15 | from tkinter import Tk 16 | 17 | from .icons import get_icon 18 | 19 | from .tile import * 20 | from .file import FileStat, PushDir 21 | from .sysutil import open_file_explorer 22 | 23 | from .gitutil import set_git_exe, GIT, GitWorkspace, git_diff, git_difftool 24 | from .gitutil import run_black, git_add, git_add_undo, git_commit 25 | from .gitutil import git_fetch, git_pull, git_push, git_push_tags, git_push_all 26 | 27 | from .gitutil import with_cmd 28 | from .task import run_proc 29 | 30 | 31 | # global 32 | 33 | url_homepage = "https://github.com/kr-g/gitonic" 34 | # url_sponsor = "https://github.com/sponsors/kr-g" 35 | 36 | # configuration 37 | 38 | fconfigdir = FileStat("~").join([".gitonic"]) 39 | frepo = FileStat("~/repo") 40 | max_history = 1000 41 | max_commit = 15 42 | git_exe = GIT 43 | follow = True 44 | auto_switch = True 45 | push_tags = False 46 | show_changes = False 47 | clear_after_commit = False 48 | min_commit_length = 5 49 | dev_mode = False 50 | dev_follow = True 51 | dev_follow_max = 10000 52 | 53 | # 54 | 55 | ICO_TRASH = "trash" 56 | ICO_FOLDER_OPEN = "folder-open" 57 | ICO_REFRESH = "rotate" 58 | ICO_SEL_ALL = "check-double" 59 | ICO_CLR_ALL = "xmark" 60 | ICO_CLR = "xmark" 61 | ICO_PULL = "angle-down" 62 | ICO_PULL_ALL = "angles-down" 63 | ICO_FETCH = "angle-down" 64 | ICO_FETCH_ALL = "angles-down" 65 | ICO_FILE_ADD = "file-circle-plus" 66 | ICO_FILE_SUB = "file-circle-minus" 67 | ICO_FILE_DIFF = "file-waveform" 68 | ICO_FILE_DIFFTOOL = "code-compare" 69 | ICO_FILE_FORMATSOURCE = "indent" 70 | 71 | # 72 | 73 | 74 | def dgb_pr(*s): 75 | if not True: 76 | print(s) 77 | if dev_mode: 78 | gt("expert_log").append("\t".join([str(x) for x in s])) 79 | on_follow_expert() 80 | 81 | 82 | def do_expert_max_history(): 83 | log = gt("expert_log") 84 | cnt = log.get_line_count() 85 | if cnt >= dev_follow_max: 86 | to_del = float(cnt - dev_follow_max) 87 | log.remove_lines(last=to_del) 88 | 89 | 90 | def on_follow_expert(): 91 | if dev_follow: 92 | gt("expert_log").gotoline() 93 | do_expert_max_history() 94 | 95 | 96 | def on_expert_clr(): 97 | el = gt("expert_log") 98 | if el: 99 | el.clr() 100 | dgb_pr("on_expert_clr") 101 | 102 | 103 | def set_config(): 104 | global config 105 | 106 | dgb_pr("set-config", config.__dict__) 107 | 108 | global frepo 109 | frepo = config().workspace 110 | global max_history 111 | max_history = config().max_history 112 | global max_commit 113 | max_commit = config().max_commit 114 | 115 | global git_exe 116 | git_exe = config().git_exe 117 | set_git_exe(git_exe) 118 | 119 | global follow 120 | follow = bool(config().follow) 121 | global auto_switch 122 | auto_switch = bool(config().auto_switch) 123 | global push_tags 124 | push_tags = bool(config().push_tags) 125 | global show_changes 126 | show_changes = bool(config().show_changes) 127 | 128 | global min_commit_length 129 | min_commit_length = config().min_commit_length 130 | 131 | global clear_after_commit 132 | clear_after_commit = bool(config().clear_after_commit) 133 | 134 | global dev_mode 135 | dev_mode = config().dev_mode 136 | global dev_follow 137 | dev_follow = config().dev_follow 138 | global dev_follow_max 139 | dev_follow_max = config().dev_follow_max 140 | 141 | 142 | def read_config(autocfg=True): 143 | global config 144 | fconfig = FileStat(fconfigdir.name).join(["config.json"]).name 145 | config = Config(filename=fconfig) 146 | config().setdefault("workspace", frepo.name) 147 | config().setdefault("max_history", max_history) 148 | config().setdefault("max_commit", max_commit) 149 | config().setdefault("git_exe", GIT) 150 | config().setdefault("follow", follow) 151 | config().setdefault("auto_switch", auto_switch) 152 | config().setdefault("push_tags", push_tags) 153 | config().setdefault("show_changes", show_changes) 154 | config().setdefault("min_commit_length", min_commit_length) 155 | config().setdefault("clear_after_commit", clear_after_commit) 156 | config().setdefault("dev_mode", dev_mode) 157 | config().setdefault("dev_follow", dev_follow) 158 | config().setdefault("dev_follow_max", dev_follow_max) 159 | 160 | if autocfg: 161 | set_config() 162 | 163 | 164 | def write_config(): 165 | dgb_pr("write_config") 166 | global config 167 | config().workspace = gt("workspace").get_val() 168 | config().max_history = int(gt("max_history").get_val()) 169 | config().max_commit = int(gt("max_commit").get_val()) 170 | config().git_exe = gt("git_exe").get_val() 171 | config().follow = bool(int(gt("follow").get_val())) 172 | config().auto_switch = bool(int(gt("auto_switch").get_val())) 173 | config().push_tags = bool(int(gt("push_tags").get_val())) 174 | config().show_changes = bool(int(gt("show_changes").get_val())) 175 | config().clear_after_commit = bool(int(gt("clear_after_commit").get_val())) 176 | config().min_commit_length = int(gt("min_commit_length").get_val()) 177 | config().dev_mode = bool(int(gt("dev_mode").get_val())) 178 | if dev_mode: 179 | config().dev_follow = bool(int(gt("dev_follow").get_val())) 180 | config().dev_follow_max = int(gt("dev_follow_max").get_val()) 181 | 182 | dgb_pr("write-config", config.__dict__) 183 | 184 | config.save() 185 | set_config() 186 | 187 | 188 | # 189 | 190 | PREFS_CAP_W = 27 191 | PREFS_ENTRY_W = 7 192 | 193 | 194 | def trackgit_name(nam): 195 | userhome = os.path.expanduser("~") 196 | if nam.startswith(userhome + os.sep): 197 | nam = nam.replace(userhome, "~") 198 | return nam 199 | 200 | 201 | def update_workspace(): 202 | write_config() 203 | set_workspace() 204 | 205 | 206 | def get_main(): 207 | read_config() 208 | 209 | main = TileRows( 210 | source=[ 211 | TileTab( 212 | idn="maintabs", 213 | source=[ 214 | ( 215 | "settings", 216 | TileRows( 217 | source=[ 218 | TileLabel(caption=""), 219 | TileEntry( 220 | caption="workspace folders", 221 | # commandtext="...", 222 | # icon=get_icon(ICO_FOLDER_OPEN), 223 | width=60, 224 | idn="workspace", 225 | value=frepo, 226 | on_change=lambda o, n: update_workspace(), 227 | ), 228 | TileLabel( 229 | caption="use ';' as separator for multiple workspaces" 230 | ), 231 | TileLabel(caption=""), 232 | TileEntryInt( 233 | caption="minimum commit text length", 234 | caption_width=PREFS_CAP_W, 235 | width=PREFS_ENTRY_W, 236 | idn="min_commit_length", 237 | value=min_commit_length, 238 | on_change=lambda o, n: write_config(), 239 | ), 240 | TileEntryInt( 241 | caption="max records in log history", 242 | caption_width=PREFS_CAP_W, 243 | width=PREFS_ENTRY_W, 244 | idn="max_history", 245 | value=max_history, 246 | on_change=lambda o, n: write_config(), 247 | ), 248 | TileEntryInt( 249 | caption="max records in commit history", 250 | caption_width=PREFS_CAP_W, 251 | width=PREFS_ENTRY_W, 252 | idn="max_commit", 253 | value=max_commit, 254 | on_change=lambda o, n: write_config(), 255 | ), 256 | TileEntry( 257 | caption="git executable", 258 | caption_width=PREFS_CAP_W, 259 | idn="git_exe", 260 | value=GIT, 261 | on_change=lambda o, n: write_config(), 262 | ), 263 | TileCheckbutton( 264 | caption="always show changes tab on startup", 265 | idn="show_changes", 266 | on_click=lambda x: write_config(), 267 | ), 268 | TileCheckbutton( 269 | caption="expert mode. shows debug logging in extra tab (requires restart of gitonic)", 270 | idn="dev_mode", 271 | on_click=lambda x: write_config(), 272 | ), 273 | TileEntryInt( 274 | caption="max records in expert log history", 275 | caption_width=PREFS_CAP_W, 276 | width=PREFS_ENTRY_W, 277 | idn="dev_follow_max", 278 | value=dev_follow_max, 279 | on_change=lambda o, n: write_config(), 280 | ), 281 | # 282 | TileLabelButton( 283 | caption="open gitonic config folder", 284 | icon=get_icon(ICO_FOLDER_OPEN), 285 | on_click=lambda x: open_file_explorer( 286 | fconfigdir.name 287 | ), 288 | ), 289 | ] 290 | ), 291 | ), 292 | ( 293 | "tracked git's", 294 | TileRows( 295 | source=[ 296 | TileLabel(caption=""), 297 | TileEntryListbox( 298 | caption="", 299 | idn="gits", 300 | max_show=15, 301 | width=40, 302 | select_many=True, 303 | map_value=lambda x: trackgit_name( 304 | x), # os.path.basename(x), 305 | on_sel=lambda x: set_tracked_gits(), 306 | ), 307 | TileCols( 308 | source=[ 309 | TileLabelButton( 310 | caption="workspace", 311 | commandtext="refresh", 312 | icon=get_icon(ICO_REFRESH), 313 | command=lambda: set_workspace(), 314 | ), 315 | TileLabelButton( 316 | caption="all", 317 | commandtext="select", 318 | icon=get_icon(ICO_SEL_ALL), 319 | command=lambda: on_sel_all_gits(), 320 | ), 321 | TileLabelButton( 322 | caption="", 323 | commandtext="un-select", 324 | icon=get_icon(ICO_CLR_ALL), 325 | command=lambda: on_unsel_all_gits(), 326 | ), 327 | ] 328 | ), 329 | TileCols( 330 | source=[ 331 | TileLabelButton( 332 | caption="pull", 333 | commandtext="selected", 334 | icon=get_icon(ICO_PULL), 335 | command=lambda: on_pull_tracked(), 336 | hotkey="", 337 | ), 338 | TileLabelButton( 339 | caption="", 340 | commandtext="all", 341 | icon=get_icon(ICO_PULL_ALL), 342 | command=lambda: pull_all_workspace(), 343 | ), 344 | TileLabelButton( 345 | caption="fetch", 346 | commandtext="selected", 347 | icon=get_icon(ICO_FETCH), 348 | command=lambda: on_fetch_tracked(), 349 | hotkey="", 350 | ), 351 | TileLabelButton( 352 | caption="", 353 | commandtext="all", 354 | icon=get_icon(ICO_FETCH_ALL), 355 | command=lambda: fetch_all_workspace(), 356 | ), 357 | ] 358 | ), 359 | ] 360 | ), 361 | ), 362 | ( 363 | "changes", 364 | TileRows( 365 | source=[ 366 | TileLabel(caption=""), 367 | TileTreeView( 368 | caption="", 369 | idn="changes", 370 | header=[ 371 | ("git", None), 372 | ("branch", None), 373 | ("file", None), 374 | ("unstaged", None), 375 | ("staged", None), 376 | ("type", None), 377 | ], 378 | header_width=( 379 | 150, 100, 250, 100, 100, 100), 380 | height=13, 381 | on_double_click=lambda x: on_add_or_undo( 382 | x), 383 | on_right_click=lambda cntrl, ctx: on_changed_context( 384 | cntrl, ctx), 385 | ), 386 | TileCols( 387 | source=[ 388 | TileLabelButton( 389 | caption="changes", 390 | commandtext="refresh", 391 | icon=get_icon(ICO_REFRESH), 392 | command=lambda: set_changes(), 393 | hotkey="", 394 | ), 395 | TileLabelButton( 396 | caption="all", 397 | commandtext="select", 398 | icon=get_icon(ICO_SEL_ALL), 399 | command=lambda: gt("changes").set_selection( 400 | -1 401 | ), 402 | hotkey="", 403 | ), 404 | TileLabelButton( 405 | caption="", 406 | commandtext="unselect", 407 | icon=get_icon(ICO_CLR_ALL), 408 | command=lambda: gt( 409 | "changes" 410 | ).clr_selection(), 411 | hotkey="", 412 | ), 413 | ] 414 | ), 415 | TileCols( 416 | source=[ 417 | TileLabelButton( 418 | caption="selected", 419 | commandtext="add", 420 | icon=get_icon(ICO_FILE_ADD), 421 | command=lambda: on_add(), 422 | hotkey="", 423 | ), 424 | TileLabelButton( 425 | caption="", 426 | commandtext="unstage", 427 | icon=get_icon(ICO_FILE_SUB), 428 | command=lambda: on_add_undo(), 429 | hotkey="", 430 | ), 431 | TileLabelButton( 432 | caption="", 433 | commandtext="diff", 434 | icon=get_icon(ICO_FILE_DIFF), 435 | command=lambda: on_diff(), 436 | hotkey="", 437 | ), 438 | TileLabelButton( 439 | idn="black", 440 | caption="", 441 | commandtext="autoformat source", 442 | icon=get_icon( 443 | ICO_FILE_FORMATSOURCE), 444 | command=lambda: on_formatter(), 445 | hotkey="", 446 | ), 447 | TileLabelButton( 448 | idn="difftool", 449 | caption="", 450 | commandtext="difftool", 451 | icon=get_icon(ICO_FILE_DIFFTOOL), 452 | command=lambda: on_difftool(), 453 | hotkey="", 454 | ), 455 | ] 456 | ), 457 | ] 458 | ), 459 | ), 460 | ( 461 | "commit", 462 | TileRows( 463 | source=[ 464 | TileLabel(caption=""), 465 | TileCols( 466 | source=[ 467 | TileEntryCombo( 468 | caption="message:", 469 | idn="commit_short", 470 | width=50, 471 | on_select=lambda x, v: on_sel_commit( 472 | x), 473 | ), 474 | TileLabelButton( 475 | caption="", 476 | commandtext="clear", 477 | icon=get_icon(ICO_CLR), 478 | command=lambda: on_clr_commit(), 479 | hotkey="", 480 | ), 481 | ] 482 | ), 483 | TileEntryText( 484 | caption="", idn="commit_long", width=80, height=10 485 | ), 486 | TileCols( 487 | source=[ 488 | TileLabelButton( 489 | caption="tracked git's", 490 | commandtext="commit", 491 | icon=get_icon("file-signature"), 492 | command=lambda: on_commit(), 493 | hotkey="", 494 | ), 495 | TileLabelButton( 496 | caption="", 497 | commandtext="push", 498 | icon=get_icon("upload"), 499 | command=lambda: on_push_tracked(), 500 | hotkey="", 501 | ), 502 | TileLabelButton( 503 | caption="", 504 | commandtext="commit + push", 505 | icon=get_icon( 506 | "wand-magic-sparkles"), 507 | command=lambda: on_commit_push_tracked(), 508 | hotkey="", 509 | ), 510 | TileCheckbutton( 511 | caption="push tags", 512 | idn="push_tags", 513 | on_click=lambda x: write_config(), 514 | ), 515 | TileCheckbutton( 516 | caption="clear message after commit", 517 | idn="clear_after_commit", 518 | on_click=lambda x: write_config(), 519 | ), 520 | ] 521 | ), 522 | ] 523 | ), 524 | ), 525 | # ( 526 | # "tag", 527 | # TileRows( 528 | # source=[ 529 | # TileLabel(caption=""), 530 | # TileEntryButton( 531 | # caption="new tag name", 532 | # commandtext="tag", 533 | # idn="tag_workspace", 534 | # ), 535 | # ] 536 | # ), 537 | # ), 538 | ( 539 | "log", 540 | TileRows( 541 | source=[ 542 | TileLabel(caption=""), 543 | TileEntryText( 544 | caption="", 545 | idn="log", 546 | height=20, 547 | width=80, 548 | readonly=True, 549 | ), 550 | TileCols( 551 | source=[ 552 | TileLabelButton( 553 | caption="", 554 | commandtext="clear", 555 | icon=get_icon(ICO_TRASH), 556 | command=lambda: on_log_clr(), 557 | ), 558 | TileCheckbutton( 559 | caption="follow log", 560 | idn="follow", 561 | on_click=lambda x: write_config(), 562 | ), 563 | TileCheckbutton( 564 | caption="auto switch log", 565 | idn="auto_switch", 566 | on_click=lambda x: write_config(), 567 | ), 568 | ] 569 | ), 570 | ] 571 | ), 572 | ), 573 | ( 574 | "expert", 575 | TileRows( 576 | source=[ 577 | TileLabel(caption=""), 578 | TileEntryText( 579 | caption="", 580 | idn="expert_log", 581 | height=20, 582 | width=80, 583 | readonly=True, 584 | ), 585 | TileCols( 586 | source=[ 587 | TileLabelButton( 588 | caption="", 589 | commandtext="clear", 590 | icon=get_icon(ICO_TRASH), 591 | command=lambda: on_expert_clr(), 592 | ), 593 | TileCheckbutton( 594 | caption="follow log", 595 | idn="dev_follow", 596 | on_click=lambda x: write_config(), 597 | ), 598 | ] 599 | ), 600 | ] 601 | ), 602 | dev_mode, 603 | ), 604 | # ("about", Tile()), 605 | ], 606 | ), 607 | TileCols( 608 | source=[ 609 | TileLabelClick( 610 | caption=f"gitonic - {url_homepage}", 611 | on_click=lambda x: open_homepage(), 612 | ), 613 | TileLabel( 614 | caption=f"version: {VERSION}", 615 | ), 616 | # TileLabelClick( 617 | # caption=f"DONATE to gitonic - {url_sponsor}", 618 | # on_click=lambda x: open_sponsor_page(), 619 | # ), 620 | ] 621 | ), 622 | ] 623 | ) 624 | return main 625 | 626 | 627 | # gui handling 628 | 629 | 630 | def open_homepage(): 631 | webbrowser.get().open(url_homepage, new=0) 632 | 633 | 634 | def open_sponsor_page(): 635 | webbrowser.get().open(url_sponsor, new=0) 636 | 637 | 638 | def on_sel_all_gits(): 639 | dgb_pr("on_sel_all_gits") 640 | gt("gits").set_selection(-1) 641 | set_tracked_gits() 642 | 643 | 644 | def on_unsel_all_gits(): 645 | dgb_pr("on_sel_all_gits") 646 | gt("gits").clr_selection() 647 | set_tracked_gits() 648 | 649 | 650 | def _run_diff_tool(path, file, callb=None): 651 | # todo rework with gitutil -> git_difftool 652 | dgb_pr("_run_diff_tool", path, file) 653 | 654 | from .gitutil import GIT 655 | 656 | cwd = os.getcwd() 657 | 658 | try: 659 | os.chdir(path) 660 | if os.getcwd() != path: 661 | raise Exception("path not exist", path) 662 | args = [GIT, "difftool", file] 663 | rc = os.spawnvpe(os.P_NOWAIT, args[0], args, os.environ) 664 | except Exception as ex: 665 | dgb_pr(ex) 666 | 667 | os.chdir(cwd) 668 | 669 | return ["difftool", file] 670 | 671 | 672 | def on_cmd_diff(info, diff_, ignore_switch=False): 673 | dgb_pr(info) 674 | sel = gt("changes").get_selection_values() 675 | run_first = True 676 | for rec in sel: 677 | if rec["type"] == "file": 678 | 679 | gitnam = rec["git"] 680 | do_log(f"--- {gitnam}") 681 | 682 | pg = FileStat(gitnam).name 683 | git = gws.find(pg)[0] 684 | 685 | logs = [] 686 | rc = diff_(git.path, rec["file"], callb=logs.append) 687 | 688 | # if run_first and len(sel) > 1: 689 | # run_first = False 690 | # # todo rework -> freezing screen 691 | # # todo add input field on settings tab 692 | # time.sleep(0.5) 693 | 694 | dgb_pr(f"--- {git}") 695 | [dgb_pr(x) for x in rc] 696 | do_log_time(info, ignore_switch=ignore_switch) 697 | do_logs(rc) 698 | do_logs_opt(logs) 699 | 700 | 701 | def on_diff(): 702 | on_cmd_diff("on_diff", git_diff) 703 | 704 | 705 | def on_difftool(): 706 | # todo rework with gitutil -> git_difftool 707 | on_cmd_diff("on_difftool", _run_diff_tool, True) 708 | 709 | 710 | def strip_non_ascii(s): 711 | import string 712 | 713 | rc = "" 714 | for c in s: 715 | if c in string.printable: 716 | rc += c 717 | return rc 718 | 719 | 720 | def ext_iter(k): 721 | if "," not in k: 722 | dgb_pr("found formatter ex", k) 723 | yield k 724 | return 725 | 726 | for ex in k.split(","): 727 | dgb_pr("found formatter ex", ex) 728 | yield ex.strip() 729 | 730 | 731 | def elements_iter(adict, keypath=[]): 732 | """deep elements iterator""" 733 | def _iter(x): return x.items() 734 | if type(adict) == list: 735 | _iter = enumerate 736 | 737 | for k, v in _iter(adict): 738 | keypath = [*keypath, k] 739 | 740 | if type(v) in [list, dict]: 741 | yield from elements_iter(v, keypath) 742 | continue 743 | 744 | def setr(nv): 745 | adict[k] = nv 746 | 747 | yield keypath, v, setr 748 | 749 | 750 | def expand_all_vars(v): 751 | 752 | for keypath, val, setr in elements_iter(v): 753 | org_val = str(val) 754 | val = os.path.expanduser(val) 755 | # val = os.path.expandvars(val) 756 | setr(val) 757 | 758 | # todo logging 759 | if org_val != val: 760 | dgb_pr("expanded", val) 761 | 762 | return v 763 | 764 | 765 | def read_formatter_settings(): 766 | frmt_cfg = FileStat(fconfigdir.name).join(["formatter.json"]) 767 | if frmt_cfg.exists() is False: 768 | dgb_pr("no formatter config file") 769 | return None 770 | dgb_pr("found formatter config file") 771 | with open(frmt_cfg.name) as f: 772 | c = f.read() 773 | cfg = json.loads(c) 774 | normdict = {} 775 | for ki, v in cfg.items(): 776 | for k in ext_iter(ki): 777 | lower_k = k.lower() 778 | # todo check for double entries 779 | normdict[k.lower()] = expand_all_vars(v) 780 | return normdict 781 | 782 | 783 | def on_formatter(): 784 | dgb_pr("on_formatter") 785 | sel = gt("changes").get_selection_values() 786 | 787 | s = [] 788 | 789 | def adder(st): 790 | # todo tkinter cant handle all utf-8 chars 791 | s.append(strip_non_ascii(st)) 792 | 793 | cfg = read_formatter_settings() 794 | if cfg is None: 795 | cfg = {".py": {"cmd": "black", "para": ["%file"]}} 796 | 797 | for rec in sel: 798 | gitnam = rec["git"] 799 | repo = FileStat(gitnam).name 800 | fnam = rec["file"] 801 | p = FileStat(repo).join([fnam]) 802 | 803 | fext = p.splitext()[1].lower() 804 | if fext not in cfg.keys(): 805 | s.append("---skipping file. no formatter found---") 806 | s.append(p.name) 807 | continue 808 | 809 | p = p.name 810 | 811 | s.append("---run formatter---") 812 | s.append(p) 813 | frmt_cmd = cfg[fext]["cmd"] 814 | frmt_para = cfg[fext]["para"] 815 | 816 | if type(frmt_para) != list: 817 | frmt_para = [frmt_para] 818 | 819 | frmt_para = map(lambda x: x.replace("%file", p), frmt_para) 820 | 821 | cmd = [frmt_cmd, *frmt_para] 822 | s.append(str(cmd)) 823 | 824 | rc = run_proc(cmd, callb=adder) 825 | 826 | do_log_time("running formatter", ignore_switch=False) 827 | do_logs(s) 828 | 829 | 830 | def on_log_clr(): 831 | gt("log").clr() 832 | dgb_pr("on_log_clr") 833 | 834 | 835 | def do_log_max_history(): 836 | dgb_pr("do_log_max_history") 837 | log = gt("log") 838 | cnt = log.get_line_count() 839 | if cnt >= max_history: 840 | to_del = float(cnt - max_history) 841 | log.remove_lines(last=to_del) 842 | 843 | 844 | def on_follow_log(): 845 | if follow: 846 | gt("log").gotoline() 847 | do_log_max_history() 848 | 849 | 850 | def do_log_show(ignore_switch=False): 851 | if not ignore_switch and auto_switch: 852 | gt("maintabs").select("tab_log") 853 | 854 | 855 | def do_log_time(x, ignore_switch=False): 856 | dgb_pr("do_log_time", x) 857 | do_log_show(ignore_switch) 858 | log = gt("log") 859 | ts = time.asctime(time.localtime(time.time())) 860 | log.append(f"\n\n\n--- {x} --- {ts}") 861 | on_follow_log() 862 | 863 | 864 | def do_log(x=""): 865 | dgb_pr("do_log", x) 866 | gt("log").append(x) 867 | on_follow_log() 868 | 869 | 870 | def do_logs(x): 871 | dgb_pr("do_logs", x) 872 | gt("log").extend(x) 873 | on_follow_log() 874 | 875 | 876 | def do_logs_opt(x): 877 | if x and len(x) > 0: 878 | dgb_pr("do_logs", x) 879 | gt("log").extend(x) 880 | on_follow_log() 881 | do_log_show() 882 | 883 | 884 | tracked = FileStat(fconfigdir.name).join(["tracked.json"]) 885 | tracked.makedirs() 886 | 887 | 888 | def tracked_write(): 889 | with open(tracked.name, "w") as f: 890 | f.write(json.dumps(tracked_gits, indent=4)) 891 | 892 | 893 | def tracked_read(): 894 | try: 895 | with open( 896 | tracked.name, 897 | ) as f: 898 | cont = f.read() 899 | global tracked_gits 900 | tracked_gits = json.loads(cont) 901 | dgb_pr(tracked, "->", tracked_gits) 902 | sel_tracked() 903 | except Exception as ex: 904 | dgb_pr(ex) 905 | 906 | 907 | def sel_tracked(): 908 | gt("gits").set_selection(tracked_gits) 909 | 910 | 911 | def on_gits_cmd(info, selcmd_, gits, ignore_switch=False, update_change=False): 912 | do_log_time(info, ignore_switch) 913 | for rec in gits: 914 | gitnam = rec["git"] 915 | pg = FileStat(gitnam).name 916 | git = gws.find(pg)[0] 917 | logs = [] 918 | rc = selcmd_(git.path, [rec["file"]], callb=logs.append) 919 | dgb_pr(f"--- {git}") 920 | [dgb_pr(x) for x in rc] 921 | do_logs(rc) 922 | do_logs_opt(logs) 923 | if update_change: 924 | set_changes() 925 | 926 | 927 | def call_mult_gits(gits, gitfunc, msgtxt): 928 | dgb_pr("on_", msgtxt) 929 | for gnam in gits: 930 | try: 931 | git = gws.find(gnam)[0] 932 | logs = [] 933 | rc = gitfunc(git.path, callb=logs.append) 934 | dgb_pr(f"--- {git}") 935 | [dgb_pr(x) for x in rc] 936 | do_log_time(f"{msgtxt}: {git.path}") 937 | do_logs(rc) 938 | do_logs_opt(logs) 939 | except Exception as ex: 940 | dgb_pr(ex) 941 | set_changes() 942 | 943 | 944 | def fetch_gits(gits): 945 | call_mult_gits(gits, git_fetch, "fetch") 946 | 947 | 948 | def pull_gits(gits): 949 | call_mult_gits(gits, git_pull, "pull") 950 | 951 | 952 | def on_fetch_tracked(): 953 | fetch_gits(tracked_gits) 954 | 955 | 956 | def fetch_all_workspace(): 957 | fetch_gits(sorted(gws.gits.keys())) 958 | 959 | 960 | def on_pull_tracked(): 961 | pull_gits(tracked_gits) 962 | 963 | 964 | def pull_all_workspace(): 965 | pull_gits(sorted(gws.gits.keys())) 966 | 967 | 968 | def on_sel_cmd(info, selcmd_, ignore_switch=False, update_change=False): 969 | dgb_pr(info) 970 | gits = gt("changes").get_selection_values() 971 | on_gits_cmd( 972 | info, selcmd_, gits, ignore_switch=ignore_switch, update_change=update_change 973 | ) 974 | 975 | 976 | def on_add(): 977 | on_sel_cmd("on_add", git_add, True, True) 978 | 979 | 980 | def on_add_undo(): 981 | on_sel_cmd("on_add_undo", git_add_undo, True, True) 982 | 983 | 984 | def on_add_or_undo(cntrl): 985 | vals = cntrl.get_selection_values() 986 | file = vals[0] 987 | if file["unstaged"] != "": 988 | on_add() 989 | else: 990 | on_add_undo() 991 | 992 | 993 | def on_changed_context(cntrl, ctx): 994 | # dgb_pr 995 | print("on_changed_context", ctx) 996 | global changes 997 | row_no = ctx.row[1] 998 | col_no = ctx.column[1] 999 | 1000 | print(changes) 1001 | gnam_base = changes[row_no]['git'] 1002 | fnam_base = changes[row_no]['file'] 1003 | 1004 | gnam = FileStat(gnam_base) 1005 | gnam_dir = gnam.name 1006 | 1007 | fnam = FileStat(gnam_base).join([fnam_base]) 1008 | fnam_dir = fnam.dirname() 1009 | fnam_dirnam = fnam.dirname()[len(gnam.name) + 1:] 1010 | 1011 | dgb_pr(gnam_base, gnam) 1012 | dgb_pr(fnam_base, fnam) 1013 | 1014 | git = gws.find(gnam) 1015 | 1016 | ctxmenu = ContextMenu(cntrl._treeview, ctx) 1017 | 1018 | def open_explorer(fnam): 1019 | def _call(x): 1020 | open_file_explorer(fnam) 1021 | return _call 1022 | 1023 | ctxmenu.add_command( 1024 | f"open project folder {gnam_base}", open_explorer(gnam_dir)) 1025 | if gnam_base != fnam_dirnam: 1026 | ctxmenu.add_command( 1027 | f"open file folder {fnam_dirnam}", open_explorer(fnam_dir)) 1028 | 1029 | load_and_set_context_settings(ctxmenu, gnam_dir, fnam_dir, fnam.name) 1030 | ctxmenu.show() 1031 | 1032 | 1033 | def replace_all_vars(d, env): 1034 | 1035 | for keypath, val, setr in elements_iter(d): 1036 | org_val = str(val) 1037 | for k, v in env.items(): 1038 | if type(val) == str: 1039 | val = val.replace(k, v) 1040 | val = os.path.expanduser(val) 1041 | setr(val) 1042 | 1043 | return d 1044 | 1045 | 1046 | def load_and_set_context_settings(ctxmenu, gnam_dir, fnam_dir, fnam): 1047 | ctx_cfg = FileStat(fconfigdir.name).join(["context.json"]) 1048 | if ctx_cfg.exists() is False: 1049 | dgb_pr("no context config file") 1050 | return None 1051 | 1052 | try: 1053 | with open(ctx_cfg.name) as f: 1054 | c = f.read() 1055 | cfg = json.loads(c) 1056 | except: 1057 | dgb_pr("json parsing error") 1058 | return 1059 | 1060 | file_short = FileStat(fnam).basename() 1061 | 1062 | env = {"$GIT": gnam_dir, "$PATH": fnam_dir, "$NAME" : file_short, 1063 | "$FILE": fnam, "$PYTHON": sys.executable} 1064 | dgb_pr("env: " + str(env)) 1065 | 1066 | cfg = replace_all_vars(cfg, env) 1067 | dgb_pr(cfg) 1068 | 1069 | for _, ctxset in cfg.items(): 1070 | 1071 | workdir = ctxset.setdefault("workdir", ".") 1072 | dgb_pr("workdir for command", workdir) 1073 | 1074 | patnli = ctxset["expr"] 1075 | if patnli: 1076 | patnli = patnli if type(patnli) == list else [patnli] 1077 | found = False 1078 | for patn in patnli: 1079 | found = fnmatch.fnmatch(fnam, patn) 1080 | if found: 1081 | break 1082 | if found is False: 1083 | continue 1084 | 1085 | ctxmenu.add_separator() 1086 | for mi in ctxset["menu"]: 1087 | def _run(args): 1088 | def _runner(x): 1089 | if args is None or len(args) == 0: 1090 | return 1091 | dgb_pr("run command", *args) 1092 | 1093 | with PushDir(workdir) as pd: 1094 | rc = os.spawnvpe( 1095 | os.P_NOWAIT, args[0], args, os.environ) 1096 | dgb_pr("call result", rc) 1097 | 1098 | return _runner 1099 | ctxmenu.add_command( 1100 | mi[0], _run(mi[1])) 1101 | 1102 | 1103 | fcommit = FileStat(fconfigdir.name).join(["commit.json"]) 1104 | commit_history = [] 1105 | 1106 | 1107 | def read_commit(): 1108 | global commit_history 1109 | try: 1110 | with open(fcommit.name) as f: 1111 | cont = f.read() 1112 | commit_history = json.loads(cont) 1113 | except Exception as ex: 1114 | dgb_pr(ex) 1115 | set_commits() 1116 | 1117 | 1118 | def write_commit(): 1119 | try: 1120 | with open(fcommit.name, "w") as f: 1121 | cont = json.dumps(commit_history, indent=4) 1122 | f.write(cont) 1123 | except Exception as ex: 1124 | dgb_pr(ex) 1125 | 1126 | 1127 | def add_commit_history(short, long): 1128 | global commit_history 1129 | commit = {"short": short, "long": long} 1130 | try: 1131 | commit_history.remove(commit) 1132 | except: 1133 | pass 1134 | commit_history.insert(0, commit) 1135 | if len(commit_history) > max_commit: 1136 | commit_history = commit_history[0:max_commit] 1137 | write_commit() 1138 | set_commits() 1139 | 1140 | 1141 | def set_commits(): 1142 | global commit_history 1143 | commits = gt("commit_short") 1144 | vals = list(map(lambda x: x["short"], commit_history)) 1145 | dgb_pr("*********vals", vals) 1146 | commits.set_values(vals) 1147 | if len(vals) > 0: 1148 | commits.set_index(0) 1149 | gt("commit_long").set_val(commit_history[0]["long"]) 1150 | 1151 | apply_clear_after_commit() 1152 | 1153 | 1154 | def on_sel_commit(idx): 1155 | dgb_pr(idx) 1156 | # gt("commit_short").set_val(commit_history[idx]["short"]) 1157 | gt("commit_long").set_val(commit_history[idx]["long"]) 1158 | 1159 | 1160 | def on_clr_commit(): 1161 | # todo bring tab top front 1162 | gt("maintabs").select("tab_commit") 1163 | gt("commit_short").clr().focus() 1164 | gt("commit_long").clr() 1165 | 1166 | 1167 | def on_commit(): 1168 | dgb_pr("on_commit") 1169 | head = gt("commit_short").get_val().strip() 1170 | body = gt("commit_long").get_val().strip() 1171 | 1172 | if len(head) < min_commit_length or len(head) > 50: 1173 | 1174 | gt("maintabs").select("tab_commit") 1175 | 1176 | tkinter.messagebox.showerror( 1177 | "error", 1178 | f"length: {min_commit_length} >= message < 50\ncurrent: {len(head)}", 1179 | ) 1180 | 1181 | gt("commit_short").focus() 1182 | 1183 | return False 1184 | 1185 | apply_clear_after_commit() 1186 | 1187 | message = head 1188 | if len(body) > 0: 1189 | message += "\n" * 2 + body 1190 | 1191 | add_commit_history(head, body) 1192 | 1193 | for gnam in tracked_gits: 1194 | try: 1195 | dgb_pr("use", gnam) 1196 | git = gws.find(gnam)[0] 1197 | do_log_time(f"commit: {git.path} '{message}'") 1198 | if git.has_staged(): 1199 | logs = [] 1200 | rc = git_commit(git.path, message, callb=logs.append) 1201 | dgb_pr(f"--- {git}") 1202 | [dgb_pr(x) for x in rc] 1203 | do_logs(rc) 1204 | do_logs_opt(logs) 1205 | do_log("commited staged files") 1206 | else: 1207 | do_log("nothing staged") 1208 | 1209 | except Exception as ex: 1210 | dgb_pr(ex) 1211 | 1212 | set_changes() 1213 | 1214 | return True 1215 | 1216 | 1217 | def on_cmd_push(info, push_, gits): 1218 | dgb_pr(info) 1219 | sel = gt("changes").get_selection_values() 1220 | do_log_time(info) 1221 | for pg in gits: 1222 | git = gws.find(pg)[0] 1223 | logs = [] 1224 | rc = push_(git.path, callb=logs.append) 1225 | [dgb_pr(x) for x in rc] 1226 | do_log() 1227 | do_log(f"--- push: {git}") 1228 | do_logs(rc) 1229 | do_logs_opt(logs) 1230 | if push_tags: 1231 | logs = [] 1232 | rc = git_push_tags(git.path, callb=logs.append) 1233 | [dgb_pr(x) for x in rc] 1234 | do_log() 1235 | do_log(f"--- push tags: {git}") 1236 | do_logs(rc) 1237 | do_logs_opt(logs) 1238 | 1239 | 1240 | def on_push_tracked(): 1241 | dgb_pr("on_push_tracked") 1242 | cmd_ = git_push 1243 | on_cmd_push( 1244 | "on_push_tracked", 1245 | cmd_, 1246 | tracked_gits, 1247 | ) 1248 | 1249 | 1250 | def on_commit_push_tracked(): 1251 | if on_commit(): 1252 | on_push_tracked() 1253 | 1254 | 1255 | def on_push_all_workspace(): 1256 | dgb_pr("on_push_all_workspace") 1257 | cmd_ = git_push 1258 | on_cmd_push( 1259 | "on_push_all_workspace", 1260 | cmd_, 1261 | sorted(gws.gits.keys()), 1262 | ) 1263 | 1264 | 1265 | # init 1266 | 1267 | 1268 | def set_workspace(update=True): 1269 | dgb_pr("refresh_workspace") 1270 | global gws 1271 | gws = GitWorkspace(frepo) 1272 | gws.refresh() 1273 | gt("gits").set_values(sorted(gws.gits.keys())) 1274 | dgb_pr("gws", gws) 1275 | # gws.refresh_status() 1276 | if update: 1277 | tracked_read() 1278 | set_tracked_gits() 1279 | 1280 | 1281 | def set_tracked_gits(update=True): 1282 | dgb_pr("set_tracked_gits") 1283 | global tracked_gits 1284 | tracked_gits = gt("gits").get_selection_values() 1285 | tracked_gits = list(map(lambda x: x[1], tracked_gits)) 1286 | dgb_pr("tracked_gits", tracked_gits) 1287 | tracked_write() 1288 | if update: 1289 | set_changes() 1290 | 1291 | 1292 | def set_changes(): 1293 | dgb_pr("set_changes") 1294 | global changes 1295 | changes = [] 1296 | 1297 | gits = list(map(lambda x: (x, gws.find(x)), tracked_gits)) 1298 | dgb_pr(gits) 1299 | 1300 | for path_git in gits: 1301 | path, git = path_git 1302 | 1303 | dgb_pr(path, git) 1304 | 1305 | fs = FileStat(path, prefetch=True) 1306 | if not fs.exists(): 1307 | continue 1308 | 1309 | userhome = os.path.expanduser("~") 1310 | 1311 | gitnam = fs.name # fs.basename() 1312 | if gitnam.startswith(userhome + os.sep): 1313 | gitnam = gitnam.replace(userhome, "~") 1314 | 1315 | git = git[0] 1316 | git.refresh_status() 1317 | 1318 | if len(git.status) > 0: 1319 | for stat in git.status: 1320 | fs = git.stat(stat) 1321 | fs_ex = fs.exists() 1322 | 1323 | bnam = git.current_branch.bnam if git.current_branch else "" 1324 | 1325 | gst = { 1326 | "git": gitnam, 1327 | "branch": bnam, 1328 | "file": stat.file, 1329 | "unstaged": stat.mode, 1330 | "staged": stat.staged, 1331 | "type": ("file" if fs.is_file() else "dir") 1332 | if fs_ex 1333 | else "---deleted---", 1334 | } 1335 | changes.append(gst) 1336 | gt("changes").set_values(changes) 1337 | 1338 | 1339 | def apply_clear_after_commit(): 1340 | if clear_after_commit: 1341 | gt("commit_short").set_val("") 1342 | gt("commit_long").set_val("") 1343 | 1344 | 1345 | def startup_gui(): 1346 | # read_config() 1347 | read_commit() 1348 | set_workspace(True) 1349 | 1350 | # gt("difftool").set_enabled(False) 1351 | # gt("black").set_enabled(False) 1352 | 1353 | gt("follow").set_val(follow) 1354 | gt("auto_switch").set_val(auto_switch) 1355 | gt("push_tags").set_val(push_tags) 1356 | gt("show_changes").set_val(show_changes) 1357 | gt("clear_after_commit").set_val(clear_after_commit) 1358 | gt("dev_mode").set_val(dev_mode) 1359 | if dev_mode: 1360 | gt("dev_follow").set_val(dev_follow) 1361 | gt("dev_follow_max").set_val(dev_follow_max) 1362 | 1363 | if show_changes or len(changes) > 0: 1364 | gt("maintabs").select("tab_changes") 1365 | -------------------------------------------------------------------------------- /gitonic/icons.py: -------------------------------------------------------------------------------- 1 | from pytkfaicons.fonts import get_font_icon 2 | 3 | 4 | def get_icon(ico): 5 | return get_font_icon( 6 | ico, style="solid", height=19, image_size=(23, 23), bg="lightgrey" 7 | ) 8 | -------------------------------------------------------------------------------- /gitonic/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import tkinter 4 | from tkinter import Tk 5 | 6 | from .gui import TkCmd, Tile, TileRows, TileCols, TileLabelButton 7 | from .gui import startup_gui, get_main, fconfigdir 8 | from .gui import gt 9 | 10 | from .icons import get_icon 11 | 12 | from .file import FileStat 13 | from .singleinstance import ( 14 | check_instance, 15 | check_and_bring_to_front, 16 | create_client_socket, 17 | ) 18 | 19 | from gitonic import set_tk_root, get_tk_root 20 | 21 | 22 | # main 23 | 24 | 25 | debug = False 26 | 27 | _last_esc_hit = None 28 | _cancel_task = None 29 | 30 | 31 | def double_esc_check_and_quit_all(frame): 32 | print("double esc check installed") 33 | 34 | def _check(): 35 | print("got esc") 36 | 37 | global _last_esc_hit, _cancel_task 38 | 39 | now = time.time_ns() 40 | if _last_esc_hit: 41 | delta = now - _last_esc_hit 42 | delta /= 1000 * 1000 43 | delta = int(delta) 44 | print("delta", delta) 45 | if delta < 450: 46 | print("good bye.") 47 | frame.quit() 48 | sys.exit() 49 | 50 | _last_esc_hit = now 51 | _cancel_task = get_tk_root().after(500, minimize) 52 | print("wait for next esc, or minimize") 53 | 54 | return _check 55 | 56 | 57 | def quit_all(frame): 58 | def quit_(): 59 | print("quit_all") 60 | # removes all, including threads 61 | # sys.exit() 62 | # soft, state remains 63 | # download_stop() 64 | frame.quit() 65 | if not debug: 66 | sys.exit() 67 | 68 | return quit_ 69 | 70 | 71 | def minimize(): 72 | print("minimize") 73 | get_tk_root().iconify() 74 | 75 | 76 | def _move_tab_focus(direction=1): 77 | mt = gt("maintabs") 78 | l = len(mt.keys()) 79 | pos = mt.get_index() 80 | pos += direction 81 | pos %= l 82 | mt.set_index(pos) 83 | mt.focus_first_active_tab() 84 | 85 | 86 | def f5_handler(ev): 87 | print("f5", ev) 88 | _move_tab_focus(-1) 89 | 90 | 91 | def f6_handler(ev): 92 | print("f5", ev) 93 | _move_tab_focus() 94 | 95 | 96 | # single instance handling 97 | 98 | pnam = FileStat(fconfigdir.name).join(["socket"]) 99 | sock = None 100 | 101 | 102 | def try_switch_app(): 103 | global sock 104 | # set up server if possible 105 | sock, port = check_instance(pnam.name) 106 | if sock is None: 107 | # open connection to server, and quit 108 | sock = create_client_socket(port) 109 | return sock 110 | 111 | 112 | def do_serv_socket(self): 113 | rc = check_and_bring_to_front(sock, get_tk_root()) 114 | if rc is not None: 115 | print("bring to front client request") 116 | 117 | 118 | tkcmd = None 119 | 120 | # end-of single instance 121 | 122 | 123 | def main_func(debug_=False): 124 | global debug 125 | debug = debug_ 126 | 127 | global tk_root 128 | tk_root = set_tk_root(Tk()) 129 | 130 | if try_switch_app(): 131 | print("switched to server instance") 132 | return 133 | 134 | global tkcmd 135 | tkcmd = TkCmd().start(tk_root, command=do_serv_socket) 136 | 137 | mainframe = Tile(tk_root=tk_root, idn="mainframe") 138 | 139 | main = get_main() 140 | 141 | main_content = TileRows( 142 | source=[ 143 | TileCols( 144 | source=[ 145 | TileLabelButton( 146 | idn="closeapp", 147 | caption="close app", 148 | commandtext="good bye - click button or 2x ESC (quickly)", 149 | icon=get_icon("right-from-bracket"), 150 | command=quit_all(mainframe), 151 | ), 152 | TileLabelButton( 153 | caption="minimize", 154 | commandtext="minimize me", 155 | icon=get_icon("minimize"), 156 | hotkey="", 157 | command=double_esc_check_and_quit_all(mainframe), 158 | ), 159 | ] 160 | ), 161 | main, 162 | ] 163 | ) 164 | 165 | mainframe.tk.protocol("WM_DELETE_WINDOW", quit_all(mainframe)) 166 | 167 | mainframe.title("gitonic") 168 | mainframe.resize_grip() 169 | 170 | mainframe.add(main_content) 171 | mainframe.layout() 172 | 173 | tk_root.bind("", f5_handler) 174 | tk_root.bind("", f6_handler) 175 | 176 | startup_gui() 177 | 178 | gt("closeapp").focus() 179 | 180 | try: 181 | mainframe.mainloop() 182 | except KeyboardInterrupt: 183 | print("cntrl+c") 184 | quit_all(mainframe) 185 | -------------------------------------------------------------------------------- /gitonic/pyjsoncfg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import re 5 | import json 6 | 7 | 8 | VERSION = "v0.0.6" 9 | 10 | 11 | class _cfg_namespace: 12 | def __init__(self, cfg): 13 | 14 | self.__dict__.update(cfg) 15 | 16 | for k, v in self.__dict__.items(): 17 | if isinstance(v, dict): 18 | self.__dict__.update({k: _cfg_namespace(v)}) 19 | if isinstance(v, list): 20 | a = [] 21 | for vv in v: 22 | if isinstance(vv, list) or isinstance(vv, dict): 23 | a.append(_cfg_namespace(vv)) 24 | else: 25 | a.append(vv) 26 | self.__dict__.update({k: a}) 27 | if isinstance(v, _cfg_namespace): 28 | self.__dict__.update({k: _cfg_namespace(v.__dict__)}) 29 | 30 | def __iter__(self): 31 | for k, v in self.items(): 32 | yield k 33 | 34 | def __getitem__(self, key): 35 | return self.__dict__[key] 36 | 37 | def __setitem__(self, key, v): 38 | if isinstance(v, dict): 39 | v = _cfg_namespace(v) 40 | self.__dict__[key] = v 41 | 42 | def __delitem__(self, key): 43 | del self.__dict__[key] 44 | 45 | def update(self, dict_): 46 | self.__dict__.update(dict_) 47 | return self 48 | 49 | def items(self): 50 | return self.__dict__.items() 51 | 52 | def __repr__(self): 53 | d = self._dismantle(self.__dict__) 54 | return json.dumps(d) 55 | 56 | def _dismantle(self, dic): 57 | d = {} 58 | for k, v in dic.items(): 59 | if isinstance(v, _cfg_namespace): 60 | d[k] = self._dismantle(v.__dict__) 61 | elif type(v) == dict: 62 | d[k] = self._dismantle(v) 63 | elif type(v) == list: 64 | a = [] 65 | for o in v: 66 | if isinstance(o, _cfg_namespace): 67 | a.append(self._dismantle(o.__dict__)) 68 | elif type(o) == dict: 69 | a.append(self._dismantle(o)) 70 | else: 71 | a.append(o) 72 | d[k] = a 73 | else: 74 | d[k] = v 75 | return d 76 | 77 | def setdefault(self, key, default_val=None): 78 | if not key in self.__dict__: 79 | if type(default_val) == dict: 80 | self.__dict__[key] = self._dismantle(default_val) 81 | elif type(default_val) == list: 82 | a = [] 83 | for o in default_val: 84 | if type(o) == dict: 85 | a.append(_cfg_namespace(self._dismantle(o))) 86 | else: 87 | a.append(o) 88 | self.__dict__[key] = a 89 | else: 90 | self.__dict__[key] = default_val 91 | return self.__dict__[key] 92 | 93 | def default(self, o): 94 | try: 95 | return self._dismantle(o.__dict__) 96 | except: 97 | return json.JSONEncoder.default(self, o) 98 | 99 | 100 | class Config: 101 | 102 | DEFAULT_CONFIG_FILE = "cfg.json" 103 | 104 | # environment variables 105 | PYJSONCONFIG_BASE = "PYJSONCONFIG_BASE" 106 | 107 | def __init__( 108 | self, 109 | filename=DEFAULT_CONFIG_FILE, 110 | basepath=None, 111 | not_exist_ok=True, 112 | auto_conv=True, 113 | ): 114 | if basepath is None: 115 | basepath = os.environ.setdefault(Config.PYJSONCONFIG_BASE, ".") 116 | self.basepath = basepath 117 | self.filename = filename 118 | self.clear() 119 | if not not_exist_ok and not self.exists(): 120 | raise Exception("file not exist", self._fullpath()) 121 | self.load() 122 | if auto_conv: 123 | self.conv() 124 | 125 | def __repr__(self): 126 | return f"" 127 | 128 | def _fullpath(self): 129 | fullpath = os.path.join(self.basepath, self.filename) 130 | userpath = os.path.expanduser(fullpath) 131 | normpath = os.path.normpath(userpath) 132 | abspath = os.path.abspath(normpath) 133 | return abspath 134 | 135 | def clear(self): 136 | self.data = {} 137 | 138 | def conv(self): 139 | self.data = self._namespace() 140 | 141 | def isconv(self): 142 | return isinstance(self.data, _cfg_namespace) 143 | 144 | def setdefault(self, key, default_val=None): 145 | return self.data.setdefault(key, default_val) 146 | 147 | def default(self, o): 148 | # call the inner default here too ... 149 | # since it handles both (python and custom class) 150 | return o.data.default(o) 151 | 152 | def _namespace(self): 153 | 154 | """return a shallow copy of the namespace for this object""" 155 | 156 | if self.isconv(): 157 | return _cfg_namespace(self.data.__dict__) 158 | return _cfg_namespace(self.data) 159 | 160 | def exists(self): 161 | 162 | """check if file exists and size > 0""" 163 | 164 | fp = self._fullpath() 165 | return os.path.exists(fp) and os.path.getsize(fp) > 0 166 | 167 | def load(self): 168 | if self.exists(): 169 | with open(self._fullpath()) as f: 170 | self.data = json.load(f) 171 | return self.data 172 | 173 | def savefd(self, fd, indent=4, sort_keys=True): 174 | conv = None if isinstance(self.data, dict) else self.data.default 175 | json.dump(self.data, fd, default=conv, indent=indent, sort_keys=sort_keys) 176 | 177 | def save(self, indent=4, sort_keys=True): 178 | with open(self._fullpath(), "w") as f: 179 | self.savefd(f, indent, sort_keys) 180 | 181 | def val(self, arr, defval=None, conv=None): 182 | return self._getconfigval(arr, defval, conv) 183 | 184 | def str(self, arr, defval=""): 185 | return self._getconfigval(arr, defval, str) 186 | 187 | def bool(self, arr, defval=True): 188 | conf_bool = lambda t: str(t).lower() == "true" 189 | return self._getconfigval(arr, defval, conf_bool) 190 | 191 | def int(self, arr, defval=0): 192 | conf_int = lambda t: int(t) 193 | return self._getconfigval(arr, defval, conf_int) 194 | 195 | def float(self, arr, defval=0.0): 196 | conf_float = lambda t: float(t) 197 | return self._getconfigval(arr, defval, conf_float) 198 | 199 | def __call__(self, evalstr=None): 200 | if evalstr is None: 201 | return self.data 202 | path = evalstr.split(".") 203 | return path 204 | 205 | def _getconfigval(self, ar, defval=None, conf=None): 206 | 207 | if not isinstance(ar, list): 208 | raise Exception("arr must be list type") 209 | 210 | arr = [] 211 | arr.extend(ar) 212 | last = arr.pop() 213 | e = self.data 214 | 215 | if self.isconv(): 216 | for se in arr: 217 | if se in e.__dict__: 218 | e = e.__dict__[se] 219 | else: 220 | e = e.__dict__.update({se, _cfg_namespace()}) 221 | 222 | if last in e.__dict__: 223 | val = e.__dict__[last] 224 | else: 225 | val = defval 226 | 227 | if conf: 228 | val = conf(val) 229 | e.__dict__[last] = val 230 | return val 231 | 232 | else: 233 | for se in arr: 234 | e = e.setdefault(se, {}) 235 | val = e.setdefault(last, defval) 236 | 237 | if conf: 238 | val = conf(val) 239 | e[last] = val 240 | return val 241 | 242 | # higher level access and var substitution 243 | 244 | def getexpandvars(self, eval_str): 245 | """extract vars such as ${user} or ${host.remote_ip} in the eval_str from the config""" 246 | regex = r"\$\{([a-zA-Z\._]+)\}" 247 | matches = re.finditer(regex, eval_str, re.MULTILINE) 248 | vars = [] 249 | 250 | for matchNum, match in enumerate(matches, start=1): 251 | fullmatch = match.group(0) 252 | selector = match.group(1) 253 | vars.append((selector, fullmatch)) 254 | 255 | return vars 256 | 257 | def expandvars(self, expandvars): 258 | """expands expandvars from a former call to getexpandvars""" 259 | vars = [] 260 | for v, s in expandvars: 261 | val = self.val(self(v)) 262 | vars.append((v, s, val)) 263 | return vars 264 | 265 | def expand(self, eval_str, expandvars=None, recursion_level=3): 266 | """replace vars in config by config values""" 267 | while recursion_level > 0: 268 | recursion_level -= 1 269 | vars = self.getexpandvars(eval_str) 270 | if len(vars) == 0: 271 | break 272 | exvars = self.expandvars(vars) if expandvars is None else expandvars 273 | for selector, fullmatch, val in exvars: 274 | eval_str = eval_str.replace(fullmatch, val) 275 | return eval_str 276 | 277 | # 278 | 279 | def sanitize(self, addkeywords=[], _dict=None): 280 | 281 | """remove sensitive data from config""" 282 | 283 | keywords = ["user", "pass", "url", "host", "remote", "port"] 284 | keywords.extend(addkeywords) 285 | 286 | if _dict is None: 287 | _dict = self.__dict__ 288 | 289 | for k, v in _dict.items(): 290 | if isinstance(v, dict): 291 | self.sanitize(addkeywords=addkeywords, _dict=v) 292 | continue 293 | if isinstance(v, _cfg_namespace): 294 | self.sanitize(addkeywords=addkeywords, _dict=v.__dict__) 295 | continue 296 | for kw in keywords: 297 | if k.lower().find(kw) >= 0: 298 | if k.lower().find("default") >= 0: 299 | # dont process default settings even when keyword is found 300 | continue 301 | _dict[k] = f"*** {kw} ***" 302 | -------------------------------------------------------------------------------- /gitonic/singleinstance.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import socket 4 | import select 5 | 6 | from .tile.core import log, print_t, print_e 7 | from .tile import TkCmd 8 | 9 | 10 | def create_socket(port=0): 11 | serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 12 | 13 | try: 14 | serversocket.bind(("", port)) 15 | except socket.error as err: 16 | print_e(err) 17 | return 18 | 19 | port = serversocket.getsockname() 20 | 21 | # become a server socket 22 | serversocket.listen() 23 | 24 | return serversocket 25 | 26 | 27 | def create_client_socket(port=0): 28 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 29 | sock.connect(("", port)) 30 | try: 31 | rc = sock.send("helo\n".encode()) 32 | print_t("send helo", rc) 33 | except Exception as ex: 34 | print_t("create_client_socket", ex) 35 | 36 | return sock 37 | 38 | 39 | def bring_to_front(wndw): 40 | wndw.deiconify() 41 | wndw.lift() 42 | 43 | 44 | def check_and_bring_to_front(sock, wndw, accept_and_close=True): 45 | active, _, _ = select.select([sock], [], [], 0) 46 | if len(active) == 0: 47 | return 48 | 49 | (clientsocket, address) = sock.accept() 50 | 51 | if accept_and_close: 52 | bring_to_front(wndw) 53 | clientsocket.close() 54 | return 0 55 | 56 | return sok 57 | 58 | 59 | def check_instance(pnam): 60 | port = 0 61 | try: 62 | print_t("reading last known socket", pnam) 63 | with open(pnam) as f: 64 | port = f.read() 65 | port = port.strip() 66 | port = int(port) 67 | print_t("last known port", port) 68 | except Exception as ex: 69 | print_e("app-socket read", ex) 70 | 71 | print_t("try port", port) 72 | sock = create_socket(port) 73 | 74 | if sock == None: 75 | print_t("port in use !!!") 76 | return None, port 77 | else: 78 | print_t("port not in use") 79 | 80 | sock.close() 81 | 82 | sock = create_socket() 83 | 84 | _, port = sock.getsockname() 85 | print_t("new free port", port) 86 | 87 | try: 88 | print_t("writing last known socket", pnam) 89 | with open(pnam, "w") as f: 90 | f.write(str(port)) 91 | except Exception as ex: 92 | print_e("app-socket write", ex) 93 | 94 | return sock, port 95 | -------------------------------------------------------------------------------- /gitonic/sysutil.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | 5 | def open_file_explorer(d): 6 | if sys.platform == "win32": 7 | subprocess.Popen(["start", d], shell=True) 8 | elif sys.platform == "darwin": 9 | subprocess.Popen(["open", d]) 10 | else: 11 | subprocess.Popen(["xdg-open", d]) 12 | 13 | 14 | def platform_windows(): 15 | return sys.platform == "win32" 16 | 17 | 18 | def platform_mac_os(): 19 | return sys.platform == "darwin" 20 | 21 | 22 | def platform_linux(): 23 | return sys.platform == "linux" 24 | -------------------------------------------------------------------------------- /gitonic/task.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c)2021 K. Goger - https://github.com/kr-g 3 | legal: https://github.com/kr-g/gitonic/blob/main/LICENSE.md 4 | """ 5 | 6 | import os 7 | import subprocess 8 | import threading 9 | from queue import Queue 10 | 11 | 12 | def run_proc( 13 | cmd, 14 | stdin=None, 15 | readline=True, 16 | read_blk=5, 17 | decode=True, 18 | combine_stderr=True, 19 | callb=None, 20 | loopcallb=None, 21 | shell=False, 22 | env=-1, 23 | ): 24 | try: 25 | rc = None 26 | proc = subprocess.Popen( 27 | cmd, 28 | stdin=stdin, 29 | stdout=subprocess.PIPE, 30 | stderr=subprocess.STDOUT if combine_stderr else None, 31 | shell=shell, 32 | # todo 33 | env=os.environ if env == -1 else env, 34 | ) 35 | 36 | if proc: 37 | while True: 38 | if loopcallb: 39 | if loopcallb(proc): 40 | break 41 | 42 | recv = None 43 | 44 | if readline: 45 | recv = proc.stdout.readline() 46 | else: 47 | recv = proc.stdout.read(read_blk) 48 | if len(recv) == 0: 49 | break 50 | if decode: 51 | recv = recv.decode() 52 | if callb: 53 | callb(recv) 54 | 55 | proc.wait() 56 | 57 | if proc.returncode == 0: 58 | return 59 | return proc.returncode 60 | 61 | else: 62 | print("no proc", file=sys.stderr) 63 | rc = 1 64 | 65 | except Exception as ex: 66 | rc = ex 67 | 68 | return rc 69 | 70 | 71 | class Task(threading.Thread): 72 | def start(self): 73 | self.qu = Queue() 74 | self.rc = None 75 | self._stop_req = None 76 | super().start() 77 | 78 | def _append_rc(self, rc): 79 | self.qu.put(rc) 80 | 81 | def pop(self): 82 | try: 83 | return self.qu.get(block=False) 84 | except: 85 | pass 86 | 87 | def popall(self): 88 | lines = [] 89 | while True: 90 | l = self.pop() 91 | if l is None: 92 | break 93 | lines.append(l) 94 | return lines 95 | 96 | def running(self): 97 | return self.is_alive() 98 | 99 | def failed(self): 100 | assert not self.running() 101 | return isinstance(self.rc, Exception) 102 | 103 | 104 | class Cmd(object): 105 | def set_command(self, cmd): 106 | self._cmd = cmd 107 | self._stop_req = None 108 | self.set_callb() # todo 109 | return self 110 | 111 | def set_callb(self, callb=None): 112 | self._callb = callb 113 | return self 114 | 115 | def start(self): 116 | pass 117 | 118 | def _loopcb(self, p): 119 | if self._stop_req: 120 | return self._stop_req 121 | 122 | def run(self): 123 | rc = run_proc(self._cmd, callb=self._callb, 124 | loopcallb=self._loopcb, shell=True) 125 | return rc 126 | 127 | 128 | class CmdTask(Cmd, Task): 129 | def start(self): 130 | if self._callb is None: 131 | self.set_callb(self._append_rc) 132 | Task.start(self) 133 | Cmd.start(self) 134 | 135 | def run(self): 136 | Task.run(self) 137 | self.rc = Cmd.run(self) 138 | -------------------------------------------------------------------------------- /gitonic/tile/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import gt, TkMixin, Tile 2 | from .core import TileEntry, TileEntryPassword, TileEntryInt, TileEntryText 3 | from .core import TileLabel, TileLabelClick, TileButton, TileCheckbutton 4 | from .core import TileLabelButton, TileEntryButton 5 | from .core import TileEntryCombo, TileEntrySpinbox, TileEntryListbox 6 | from .core import TileFileSelect, TileDirectorySelect 7 | from .core import TileTab, TileRows, TileCols 8 | from .core import TileTreeView 9 | from .core import ContextMenu 10 | 11 | from .tkcmd import TkCmd 12 | -------------------------------------------------------------------------------- /gitonic/tile/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c)2021 K. Goger - https://github.com/kr-g 3 | legal: https://github.com/kr-g/gitonic/blob/main/LICENSE.md 4 | """ 5 | 6 | 7 | from tkinter import Tk, ttk, filedialog, scrolledtext 8 | from tktooltip import ToolTip 9 | from tkinter import * 10 | import os 11 | 12 | # loggin support 13 | 14 | import logging 15 | 16 | enableLogging = logging.DEBUG 17 | 18 | _logger = logging.getLogger("ruckzuck") 19 | 20 | if enableLogging: 21 | logging.basicConfig() 22 | _logger.setLevel(enableLogging) 23 | else: 24 | _logger.addHandler(logging.NullHandler()) 25 | 26 | 27 | def _flat(args): 28 | fs = map(lambda x: str(x), args) 29 | return " ".join(fs) 30 | 31 | 32 | def _log(level, args): 33 | if _logger.isEnabledFor(level): 34 | _logger.log(level, _flat(args)) 35 | 36 | 37 | def log(*args): 38 | _log(logging.INFO, args) 39 | 40 | 41 | def print_t(*args): 42 | _log(logging.DEBUG, args) 43 | 44 | 45 | def print_e(*args): 46 | _log(logging.CRITICAL, args) 47 | 48 | 49 | # 50 | 51 | 52 | CAPTION = "caption" 53 | 54 | VALUE = "value" 55 | VALUES = "values" 56 | MAP_VALUE = "map_value" 57 | SOURCE = "source" 58 | 59 | ON_CLICK = "on_click" 60 | ON_RIGHT_CLICK = "on_right_click" 61 | ON_DOUBLE_CLICK = "on_double_click" 62 | 63 | ON_SELECT = "on_select" 64 | ON_UNSELECT = "on_unselect" 65 | 66 | ON_CHANGE = "on_change" 67 | 68 | WIDTH = "width" 69 | CAPTION_WIDTH = "caption_width" 70 | 71 | 72 | class TkMixin(object): 73 | def init(self, root=None): 74 | self.tk = root 75 | 76 | def title(self, *args, **kwargs): 77 | self.tk.title(*args, *kwargs) 78 | 79 | def geometry(self, *args, **kwargs): 80 | self.tk.geometry(*args, *kwargs) 81 | 82 | def mainloop(self, *args, **kwargs): 83 | self.tk.mainloop(*args, *kwargs) 84 | 85 | def quit(self): 86 | self.tk.destroy() 87 | 88 | def resize_grip(self): 89 | self.tk.resizable(True, True) 90 | sizegrip = ttk.Sizegrip(self.tk) 91 | sizegrip.pack(side=RIGHT, anchor=SE) 92 | 93 | 94 | _global_reg = {} 95 | _global_hotkey = set() 96 | 97 | 98 | def _add_hotkey(key): 99 | global _global_hotkey 100 | if key in _global_hotkey: 101 | raise Exception("already registered", key) 102 | _global_hotkey.add(key) 103 | 104 | 105 | def gt(name): 106 | return _global_reg.get(name, None) 107 | 108 | 109 | class Tile(TkMixin): 110 | def __init__(self, *a, **kw): 111 | self.__init__tile__(*a, **kw) 112 | 113 | def __init__tile__(self, parent=None, idn=None, tk_root=None, **kwargs): 114 | self._kwargs = kwargs 115 | self.init(tk_root) 116 | self.set_parent(parent) 117 | self._set_idn(idn) 118 | 119 | self.__init__core__() 120 | self.__init__internal__() 121 | 122 | def __init__core__(self): 123 | self.frame = None 124 | self._elems = [] 125 | self._frame = [] 126 | 127 | self._visible = self.pref("visible", True) 128 | self._enabled = self.pref("enabled", True) 129 | 130 | self._container = None 131 | 132 | def _set_idn(self, idn): 133 | self.idn = idn 134 | if idn != None: 135 | if gt(idn) != None: 136 | print_t("already defined", idn) 137 | _global_reg[idn] = self 138 | 139 | def __repr__(self): 140 | return self.__class__.__name__ + " " + (self.idn if self.idn else hex(id(self))) 141 | 142 | def __init__internal__(self): 143 | pass 144 | 145 | def set_parent(self, parent): 146 | self._parent = parent 147 | 148 | def parent_frame(self): 149 | return self._parent.frame if type(self._parent) == Tile else self._parent 150 | 151 | def get_root(self): 152 | root = self 153 | while True: 154 | if isinstance(root, Tile): 155 | root = self._parent 156 | else: 157 | if root.master is None: 158 | break 159 | root = root.master 160 | return root 161 | 162 | def unregister_idn(self): 163 | for el in [self, *self._elems, *self._frame]: 164 | if isinstance(el, Tile): 165 | try: 166 | if el.idn: 167 | del _global_reg[el.idn] 168 | except: 169 | pass 170 | if el != self: 171 | el.unregister_idn() 172 | 173 | def destroy(self): 174 | for f in [self.frame, *self._frame]: 175 | if f: 176 | f.destroy() 177 | self._frame = [] 178 | self.frame = None 179 | 180 | def add(self, elem): 181 | elem.set_parent(self) 182 | self._elems.append(elem) 183 | return self 184 | 185 | def _init_frame(self): 186 | if self.frame != None: 187 | raise Exception("frame alive") 188 | p = self.parent_frame() 189 | 190 | # opts = { "borderwidth":2, "relief":"ridge" } ##todo debug 191 | 192 | self.frame = ttk.Frame(p) # **opts 193 | 194 | def _end_frame(self): 195 | # todo rename this ... 196 | self.destroy() 197 | 198 | def layout(self): 199 | self._end_frame() 200 | 201 | if self._visible: 202 | self._init_frame() 203 | 204 | self._build() 205 | 206 | self._pack_elems() 207 | self._pack_frame() 208 | 209 | self._render_state() 210 | 211 | return self 212 | 213 | def _build(self): 214 | el = self.create_element() 215 | self._add_frames(el) 216 | 217 | for el in self._elems: 218 | if isinstance(el, Tile): 219 | print_t("h...ups", self) 220 | el.layout() 221 | self._add_frames(el.frame) 222 | else: 223 | print_t("...ups") 224 | self._add_frames(el) 225 | 226 | return self 227 | 228 | def _add_frames(self, el): 229 | if el == None: 230 | return 231 | if type(el) in [list, tuple]: 232 | self._frame.extend(el) 233 | else: 234 | self._frame.append(el) 235 | 236 | def create_element(self): 237 | pass 238 | 239 | def _pack_elems(self): 240 | opts = self.layout_options() 241 | for el in self._frame: 242 | # print("_pack_elems", el, self) 243 | if isinstance(el, Tile): 244 | print_t("pack child tile") 245 | el._pack_frame() 246 | else: 247 | el.pack(opts) 248 | 249 | def _pack_frame(self): 250 | # print("_pack_frame", self) 251 | opts = self.layout_frame_options() 252 | 253 | if self._visible != True: 254 | return 255 | 256 | self.frame.pack(opts) 257 | 258 | def layout_options(self): 259 | opts = self.pref( 260 | "pack", {"anchor": CENTER, "side": "left", "pady": 5, "padx": 5} 261 | ) 262 | return opts 263 | 264 | def layout_frame_options(self): 265 | opts = self.pref("pack_frame", {"anchor": W}) 266 | return opts 267 | 268 | # 269 | 270 | def focus(self): 271 | if self.frame: 272 | self.frame.focus_set() 273 | return self 274 | 275 | def can_receive_focus(self): 276 | return True 277 | 278 | # 279 | 280 | def set_visible(self, en=True): 281 | self._visible = en 282 | self.layout() 283 | 284 | def set_enabled(self, en=True): 285 | self._enabled = en 286 | self._render_state() 287 | return self 288 | 289 | def _render_state(self): 290 | pass 291 | 292 | def _set_state(self, w): 293 | w["state"] = "normal" if self._enabled else "disabled" 294 | 295 | # 296 | 297 | def pref(self, name, defval=None): 298 | v = self._kwargs.setdefault(name, defval) 299 | return v 300 | 301 | def set_pref(self, name, val): 302 | self._kwargs[name] = val 303 | 304 | def pref_int(self, name, defval=None, none_ok=False): 305 | val = self.pref(name, defval) 306 | if val is None: 307 | if not none_ok: 308 | raise Exception("None val") 309 | return None 310 | return int(val) 311 | 312 | def pref_float(self, name, defval=None, none_ok=False): 313 | val = self.pref(name, defval) 314 | if val is None: 315 | if not none_ok: 316 | raise Exception("None val") 317 | return None 318 | return float(val) 319 | 320 | def get_caption(self): 321 | return self.pref(CAPTION, "... caption not-set" + str(self)) 322 | 323 | 324 | class TileLabel(Tile): 325 | def create_element(self): 326 | width = self.pref_int(CAPTION_WIDTH, none_ok=True) 327 | self._var = StringVar() 328 | self._lbl = ttk.Label(self.frame, textvariable=self._var, width=width) 329 | self.text(self.get_caption()) 330 | return self._lbl 331 | 332 | def text(self, t): 333 | self._var.set(t) 334 | 335 | def can_receive_focus(self): 336 | return False 337 | 338 | 339 | class ClickHandlerMixIn(object): 340 | def _click_handler(self): 341 | self.pref(ON_CLICK, self.on_click)(self) 342 | 343 | def on_click(self, ref_self): 344 | print_t(self.__class__.__name__, ON_CLICK) 345 | 346 | 347 | class TileLabelClick(TileLabel, ClickHandlerMixIn): 348 | def create_element(self): 349 | lbl = super().create_element() 350 | lbl.config(foreground="blue") 351 | lbl.config(cursor="hand1") 352 | lbl.bind("", self._click_event_handler) 353 | return lbl 354 | 355 | def _click_event_handler(self, event): 356 | self._click_handler() 357 | 358 | 359 | class TileButton(Tile, ClickHandlerMixIn): 360 | def create_element(self): 361 | text = self.pref("commandtext", "...") 362 | image = self.pref("icon", None) 363 | hotkey = self.pref("hotkey", None) 364 | command = self.pref("command", self._click_handler) 365 | 366 | self._button = ttk.Button( 367 | self.frame, 368 | text=text, 369 | image=image, 370 | command=command, 371 | ) 372 | 373 | if image: 374 | if hotkey: 375 | text = text + " / " + hotkey 376 | ToolTip(self._button, msg=text) 377 | 378 | if hotkey: 379 | _add_hotkey(hotkey) 380 | root = self.get_root() 381 | root.bind(hotkey, lambda x: command()) 382 | 383 | return self._button 384 | 385 | def _render_state(self): 386 | self._set_state(self._button) 387 | 388 | def focus(self): 389 | self._button.focus_set() 390 | return self 391 | 392 | 393 | class TileCheckbutton(Tile): 394 | def create_element(self): 395 | self._var = StringVar() 396 | self.set_val(self.pref(VALUE, False)) 397 | 398 | self._var_str = StringVar() 399 | self._var_str.set( 400 | self.pref(CAPTION, ""), 401 | ) 402 | 403 | self._checkbutton = ttk.Checkbutton( 404 | self.frame, 405 | variable=self._var, 406 | textvariable=self._var_str, 407 | onvalue=self.pref("on_val", True), 408 | offvalue=self.pref("off_val", False), 409 | command=self._click_handler, 410 | ) 411 | return self._checkbutton 412 | 413 | def text(self, val): 414 | self._var_str.set(val) 415 | 416 | def get_val(self): 417 | return self._var.get() 418 | 419 | def set_val(self, val): 420 | return self._var.set(val) 421 | 422 | def _click_handler(self): 423 | self.pref(ON_CLICK, self.on_click)(self) 424 | 425 | def on_click(self, ref_self): 426 | print_t(ON_CLICK, self.get_val()) 427 | 428 | def focus(self): 429 | self._checkbutton.focus_set() 430 | return self 431 | 432 | 433 | class TileLabelButton(TileLabel, TileButton): 434 | def create_element( 435 | self, 436 | ): 437 | TileLabel.create_element(self) 438 | TileButton.create_element(self) 439 | return [self._lbl, self._button] 440 | 441 | def _render_state(self): 442 | TileButton._render_state(self) 443 | 444 | def focus(self): 445 | self._button.focus_set() 446 | return self 447 | 448 | 449 | class TileEntry(Tile): 450 | def create_element(self): 451 | width = self.pref_int(CAPTION_WIDTH, none_ok=True) 452 | self._lbl = ttk.Label(self.frame, text=self.get_caption(), width=width) 453 | self._var = self._create_var() 454 | self._entry = self._create_entry() 455 | 456 | try: 457 | val = self._val 458 | except: 459 | val = self.pref(VALUE, None) 460 | 461 | if val: 462 | self.set_val(val) 463 | 464 | return [self._lbl, self._entry] 465 | 466 | def _create_var(self): 467 | return StringVar() 468 | 469 | def _create_entry(self): 470 | width = self.pref_int("width", 20) 471 | te = ttk.Entry(self.frame, textvariable=self._var, width=width) 472 | self._bind_focus(te) 473 | return te 474 | 475 | def _bind_focus(self, te): 476 | te.bind("", self.on_focus_enter) 477 | te.bind("", self.on_focus_leave) 478 | self._old_val = None 479 | 480 | def focus(self): 481 | if self._entry: 482 | self._entry.focus_set() 483 | return self 484 | 485 | def get_val(self): 486 | return self._var.get() 487 | 488 | def clr(self): 489 | self.set_val("") 490 | return self 491 | 492 | def set_val(self, val=None): 493 | self._val = val 494 | self._var.set(val) 495 | return self 496 | 497 | def on_focus_enter(self, ev): 498 | self._old_val = self.get_val() 499 | 500 | def on_focus_leave(self, ev): 501 | try: 502 | nval = self.get_val() 503 | self._val = nval 504 | except: 505 | # validation error 506 | self.set_val(self._old_val) 507 | # todo error handling 508 | return 509 | if self._old_val != nval: 510 | self.on_change(self._old_val, nval) 511 | 512 | def on_change(self, oval, nval): 513 | self.pref(ON_CHANGE, self._on_change)(oval, nval) 514 | 515 | def _on_change(self, oval, nval): 516 | print_t(self, ON_CHANGE, "'" + str(oval) + "'", "'" + str(nval) + "'") 517 | 518 | 519 | class TileEntryPassword(TileEntry): 520 | def _create_entry(self): 521 | te = ttk.Entry(self.frame, textvariable=self._var, show="*") 522 | return te 523 | 524 | def show(self, enable=True): 525 | show = "" if int(enable) == True else "*" 526 | self._entry.configure(show=show) 527 | 528 | 529 | class TileEntryInt(TileEntry): 530 | def _create_var(self): 531 | return IntVar() 532 | 533 | def on_change(self, oval, nval): 534 | only_positiv = self.pref("only_positiv", None) 535 | if only_positiv == True and nval < 0: 536 | # todo error handling 537 | print_t("catched only_positiv") 538 | self.set_val(oval) 539 | return 540 | min_val = self.pref("min_val", None) 541 | if min_val != None: 542 | if nval < min_val: 543 | # todo error handling 544 | print_t("catched min_val") 545 | self.set_val(oval) 546 | return 547 | max_val = self.pref("max_val", None) 548 | if max_val != None: 549 | if nval > max_val: 550 | # todo error handling 551 | print_t("catched max_val") 552 | self.set_val(oval) 553 | return 554 | super().on_change(oval, nval) 555 | 556 | 557 | class TileEntryText(TileEntry): 558 | def _create_entry(self): 559 | width = self.pref_int("width", 40) 560 | height = self.pref_int("height", 15) 561 | 562 | entry = scrolledtext.ScrolledText( 563 | self.frame, 564 | width=width, 565 | height=height, 566 | ) 567 | if self.readonly: 568 | entry.config(state="disabled") 569 | 570 | return entry 571 | 572 | def __init__internal__(self): 573 | self.readonly = self.pref("readonly", False) 574 | 575 | def _preserve_state(self, begin=True): 576 | if self.readonly == False: 577 | return 578 | if begin: 579 | self._entry.config(state="normal") 580 | else: 581 | self._entry.config(state="disabled") 582 | 583 | def get_val(self): 584 | return self._entry.get("1.0", "end") 585 | 586 | def set_val(self, val): 587 | self.clr() 588 | self.append(val) 589 | 590 | def clr(self): 591 | self._preserve_state() 592 | self._entry.delete("1.0", "end") 593 | self._preserve_state(False) 594 | 595 | def append(self, val, nl=True): 596 | self._preserve_state() 597 | self._entry.insert("end", str(val)) 598 | if nl: 599 | self._entry.insert("end", "\n") 600 | self._preserve_state(False) 601 | 602 | def extend(self, vals, nl=True): 603 | self._preserve_state() 604 | for val in vals: 605 | self._entry.insert("end", str(val)) 606 | if nl: 607 | self._entry.insert("end", "\n") 608 | self._preserve_state(False) 609 | 610 | def gotoline(self, lineno=-1): 611 | if lineno < 0: 612 | lineno = "end" 613 | else: 614 | lineno = float(lineno) 615 | self._entry.yview_pickplace(lineno) 616 | 617 | def get_line_count(self): 618 | return int(float(self._entry.index("end-1c"))) 619 | 620 | def remove_lines(self, first="1.0", last="end"): 621 | self._preserve_state() 622 | self._entry.delete(first, last) 623 | self._preserve_state(False) 624 | 625 | 626 | class TileEntryButton(TileEntry, TileButton): 627 | def create_element(self): 628 | vars = TileEntry.create_element(self) 629 | TileButton.create_element(self) 630 | vars.append(self._button) 631 | return vars 632 | 633 | def focus(self): 634 | self._entry.focus_set() 635 | return self 636 | 637 | 638 | class TileEntryCombo(TileEntry): 639 | def create_element(self): 640 | vars = super().create_element() 641 | 642 | values = list(self.pref(VALUES, [])) 643 | self.set_values(values) 644 | 645 | sel_idx = self.pref("sel_idx", None) 646 | if sel_idx != None: 647 | self.set_index(sel_idx) 648 | 649 | return vars 650 | 651 | def _create_entry(self): 652 | width = self.pref_int("width", 20) 653 | self._combo = ttk.Combobox( 654 | self.frame, textvariable=self._var, width=width) 655 | self._combo.bind("<>", self._handler) 656 | 657 | self._bind_focus(self._combo) 658 | 659 | return self._combo 660 | 661 | def _handler(self, event): 662 | idx = self.get_index() 663 | val = self.get_val() 664 | self.pref(ON_SELECT, self.on_select)(idx, val) 665 | 666 | def on_select(self, ref_self, idx, val): 667 | print(self.__class__.__name__, ON_SELECT, self.get_select()) 668 | 669 | def get_index(self): 670 | return self._combo.current() 671 | 672 | def set_index(self, pos): 673 | self._combo.current(pos) 674 | 675 | def set_values(self, values): 676 | self._values = values 677 | mf = self.pref(MAP_VALUE, lambda x: x) 678 | self._map_values = list(map(mf, self._values)) 679 | self._combo[VALUES] = self._map_values 680 | 681 | def get_values(self): 682 | return self._combo[VALUES] 683 | 684 | def get_select(self): 685 | # todo refactor ? 686 | idx = self.get_index() 687 | if idx < 0: 688 | return 689 | return self._values[idx] 690 | 691 | def focus(self): 692 | self._combo.focus_set() 693 | return self 694 | 695 | 696 | class TileEntryListbox(TileEntry): 697 | def __init__internal__(self): 698 | super().__init__internal__() 699 | self.scrollbar() 700 | 701 | self._lastsel = None 702 | 703 | self._show_scrollb = self.pref("show_scroll", False) 704 | self._auto_scrollb = int(self.pref("max_show", 5)) 705 | vals = self.pref(VALUES, []) 706 | self._values = list(vals if vals else []) 707 | 708 | self._width = int(self.pref("width", 20)) 709 | 710 | self._sel_mode = int(self.pref("select_many", False)) 711 | # todo self._height = int(self.pref("height", 5)) 712 | 713 | def destroy(self): 714 | super().destroy() 715 | # init tile (move?) 716 | self._lastsel = None 717 | 718 | def scrollbar(self, enable=True): 719 | self._scrollable = enable 720 | return self 721 | 722 | def set_values(self, values): 723 | self._do_map(values) 724 | self._scroll_height() 725 | self.clr_selection() 726 | 727 | if not self._scrollable: 728 | self._scrollb_y.forget() 729 | else: 730 | self._scrollb_y_pack() 731 | 732 | def _do_map(self, values): 733 | mf = self.pref(MAP_VALUE, lambda x: f"{x} > {x}") 734 | self._values = list(values) 735 | self._map_values = list(map(mf, values)) 736 | self._var.set(self._map_values) 737 | 738 | def create_element(self): 739 | self._listb_wg_parent = ttk.Frame(self.frame) 740 | self._listb_wg = ttk.Frame(self._listb_wg_parent) 741 | 742 | vars = super().create_element() 743 | # print("list", self, vars) 744 | 745 | self._do_map(self._values) 746 | 747 | if len(self._values) > 0: 748 | self.set_val(self._values[0]) 749 | 750 | return vars 751 | 752 | def _pack_elems(self): 753 | super()._pack_elems() 754 | 755 | self._listb.pack({"side": "left"}) 756 | 757 | self._scrollb_x = ttk.Scrollbar( 758 | self._listb_wg_parent, 759 | orient=HORIZONTAL, 760 | command=self._listb.xview, 761 | ) 762 | 763 | self._listb.configure(xscrollcommand=self._scrollb_x.set) 764 | self._scrollb_x.pack(side="bottom", fill="both", padx=0) 765 | 766 | self._scrollb_y = ttk.Scrollbar( 767 | self._listb_wg, 768 | orient=VERTICAL, 769 | command=self._listb.yview, 770 | ) 771 | self._listb.configure(yscrollcommand=self._scrollb_y.set) 772 | 773 | self._scrollb_y_pack() 774 | 775 | if not self._show_scrollb: 776 | if not self._scrollable: 777 | self._scrollb_y.forget() 778 | 779 | self._listb_wg.pack(anchor=CENTER, side="left", padx=0, pady=0) 780 | self._listb_wg_parent.pack(anchor=CENTER, side="left", padx=0, pady=0) 781 | 782 | self._listb.bind("<>", self._select_handler) 783 | self._listb.bind("", self._right_click_handler) 784 | self._listb.bind("", self._double_click_handler) 785 | 786 | def _scrollb_y_pack(self): 787 | self._scrollb_y.pack(side="right", fill="both", padx=0) 788 | 789 | def _scroll_height(self): 790 | self.scrollbar() 791 | h = len(self._values) 792 | if h > self._auto_scrollb: 793 | self.scrollbar(True) 794 | else: 795 | self.scrollbar(False) 796 | h = self._auto_scrollb 797 | return h 798 | 799 | def _create_entry(self): 800 | h = self._scroll_height() 801 | 802 | self._listb = Listbox( 803 | self._listb_wg, 804 | listvariable=self._var, 805 | exportselection=False, 806 | height=h, 807 | width=self._width, 808 | selectmode="multiple" if self._sel_mode else None, 809 | ) 810 | 811 | return self._listb_wg_parent 812 | 813 | def _right_click_handler(self, event): 814 | self.pref(ON_RIGHT_CLICK, self.on_right_click)(self) 815 | 816 | def on_right_click(self, ref_self): 817 | print_t(ON_RIGHT_CLICK, ref_self) 818 | 819 | def _double_click_handler(self, event): 820 | # sel = self._listb.curselection() 821 | self.pref(ON_DOUBLE_CLICK, self.on_double_click)(self) 822 | 823 | def on_double_click(self, ref_self): 824 | print_t(ON_DOUBLE_CLICK, ref_self) 825 | 826 | def _select_handler(self, event): 827 | if self._sel_mode: 828 | # todo select and unselect 829 | self.pref("on_sel", self.on_sel)(self) 830 | return 831 | sel = self._listb.curselection() 832 | nullable = self.pref("nullable", True) 833 | if sel == self._lastsel and nullable: 834 | sel = None 835 | self.clr_selection() 836 | self.pref(ON_UNSELECT, self.on_unselect)(self) 837 | else: 838 | self.pref(ON_SELECT, self.on_select)(self) 839 | self._lastsel = sel 840 | 841 | def get_selection(self): 842 | sel = self._listb.curselection() 843 | return sel 844 | 845 | def get_selection_values(self): 846 | sel = self._listb.curselection() 847 | vals = list(map(lambda x: (x, self._values[x]), sel)) 848 | return vals 849 | 850 | def set_selection(self, vals): 851 | self.clr_selection() 852 | if vals == -1: 853 | # select all 854 | vals = self._values 855 | for v in vals: 856 | try: 857 | idx = self._values.index(v) 858 | self._listb.selection_set(idx) 859 | self._listb.see(idx) 860 | except: 861 | print_t("not found", v) 862 | 863 | def on_sel(self, ref_self): 864 | print_t( 865 | self.__class__.__name__, "on_sel", ref_self, ref_self.get_selection_values() 866 | ) 867 | 868 | def on_select(self, ref_self): 869 | print_t(self.__class__.__name__, ON_SELECT, ref_self.get_val()) 870 | 871 | def on_unselect(self, ref_self): 872 | print_t(self.__class__.__name__, ON_UNSELECT, ref_self.get_val()) 873 | 874 | def get_val(self): 875 | idx = self.get_index() 876 | if idx is not None: 877 | return (idx, self._values[idx]) 878 | 879 | def get_index(self): 880 | idx = self._listb.curselection() # no range 881 | if len(idx) == 0: 882 | return None 883 | else: 884 | idx = idx[0] 885 | return idx 886 | 887 | def set_index(self, idx=0): 888 | if idx < 0: 889 | idx = len(self._values) + idx if self._values else 0 890 | self._listb.selection_set(idx) 891 | self._listb.see(idx) 892 | 893 | def get_mapval(self): 894 | val = self.get_val() 895 | if val: 896 | return val[1] 897 | 898 | def clr_selection(self): 899 | self._listb.select_clear(0, END) 900 | self._lastsel = None 901 | 902 | def set_val(self, val): 903 | self.clr_selection() 904 | if val: 905 | idx = self._values.index(val) 906 | self.set_index(idx) 907 | self._lastsel = self.get_index() 908 | 909 | def focus(self): 910 | self._listb.focus_set() 911 | return self 912 | 913 | 914 | class TileEntrySpinbox(TileEntry): 915 | def create_element(self): 916 | vars = super().create_element() 917 | 918 | self._values = list(self.pref(VALUES, [])) 919 | mf = self.pref(MAP_VALUE, lambda x: f"{x} > {x}") 920 | self._map_values = list(map(mf, self._values)) 921 | 922 | _spin_opts = self.pref("spin_opts", {}) 923 | _spin_opts[VALUES] = self._map_values 924 | 925 | self._spinb.config(**_spin_opts) 926 | 927 | return vars 928 | 929 | def _create_entry(self): 930 | self._spinb = ttk.Spinbox(self.frame, textvariable=self._var) 931 | self._spinb.bind("<>", self._change_handler) 932 | self._spinb.bind("<>", self._change_handler) 933 | return self._spinb 934 | 935 | def _change_handler(self, event): 936 | self.pref(ON_CHANGE, self.on_change)() 937 | 938 | def on_change(self): 939 | print_t(self.__class__.__name__, ON_CHANGE, 940 | self.get_index(), self.get_val()) 941 | 942 | def get_index(self): 943 | return self._map_values.index(self.get_val()) 944 | 945 | def get_val(self): 946 | return self._spinb.get() 947 | 948 | def set_val(self, val): 949 | self._spinb.set(val) 950 | 951 | def focus(self): 952 | self._spinb.focus_set() 953 | return self 954 | 955 | 956 | class TileFileSelect(TileEntryButton): 957 | PATH = "path" 958 | """path: always add os.sep at the end""" 959 | 960 | def create_element(self): 961 | vars = super().create_element() 962 | 963 | self._filetypes = self.pref("filetypes", self.file_types()) 964 | 965 | path = self.pref(self.PATH, self.get_base()) 966 | self.path = self.fullpath(path) 967 | self.set_val(self.path) 968 | 969 | self._bind_focus(self._entry) 970 | 971 | return vars 972 | 973 | def _bind_focus(self, te): 974 | te.bind("", self.on_focus_enter) 975 | te.bind("", self.on_focus_leave) 976 | 977 | def on_click(self, o): 978 | basedir = os.path.dirname(self.get_val()) 979 | 980 | file = filedialog.askopenfilename( 981 | initialdir=basedir, title=self.get_caption(), filetypes=self._filetypes 982 | ) 983 | 984 | if file: 985 | self.set_val(file) 986 | self._on_select_file() 987 | 988 | def on_focus_enter(self, ev): 989 | pass 990 | 991 | def on_focus_leave(self, ev): 992 | self._on_select_file() 993 | 994 | def _on_select_file(self): 995 | file = self.get_val() 996 | if self.path != file: 997 | print_t("selected", file) 998 | self.pref(ON_SELECT, self.on_select)(file) 999 | 1000 | def on_select(self, file): 1001 | print(ON_SELECT, file) 1002 | 1003 | def get_base(self): 1004 | return os.getcwdb() 1005 | 1006 | def file_types(self): 1007 | return [("all files", "*.*")] 1008 | 1009 | def fullpath(self, path): 1010 | path = os.path.expanduser(path) 1011 | path = os.path.expandvars(path) 1012 | path = os.path.abspath(path) 1013 | return path 1014 | 1015 | 1016 | class TileDirectorySelect(TileFileSelect): 1017 | def on_click(self, o): 1018 | basedir = self.get_val() 1019 | 1020 | file = filedialog.askdirectory( 1021 | initialdir=basedir, 1022 | title=self.get_caption(), 1023 | ) 1024 | 1025 | if file: 1026 | print_t("selected", file) 1027 | self.set_val(file) 1028 | self.pref(ON_SELECT, self.on_select)(file) 1029 | 1030 | 1031 | class TileCompositFlow(Tile): 1032 | def focus(self): 1033 | for e in self.__elem: 1034 | if e.can_receive_focus(): 1035 | print_t("focus", e) 1036 | e.focus() 1037 | break 1038 | return self 1039 | 1040 | def create_element(self): 1041 | # print("create_element", self.__class__) 1042 | self.__elem = [] 1043 | for el in self.pref(SOURCE, []): 1044 | if el == None: 1045 | continue 1046 | 1047 | el.set_parent(self.frame) 1048 | el.layout() 1049 | 1050 | opts = self._flow_pack_options(el) 1051 | if opts: 1052 | el.frame.pack(**opts) 1053 | 1054 | self.__elem.append(el) 1055 | 1056 | return [] 1057 | 1058 | def _flow_pack_options(self, el): 1059 | pass 1060 | 1061 | def unregister_idn(self): 1062 | super().unregister_idn() 1063 | for el in self.pref(SOURCE, []): 1064 | if isinstance(el, Tile): 1065 | try: 1066 | if el.idn: 1067 | del _global_reg[el.idn] 1068 | except: 1069 | pass 1070 | el.unregister_idn() 1071 | 1072 | 1073 | class TileRows(TileCompositFlow): 1074 | pass 1075 | 1076 | 1077 | class TileCols(TileCompositFlow): 1078 | def _flow_pack_options(self, el): 1079 | opts = self.layout_options() 1080 | opts["anchor"] = W 1081 | return opts 1082 | 1083 | def layout_options(self): 1084 | return super().layout_options() 1085 | # return {"anchor": CENTER, "side": "left", "pady": 5, "padx": 5} 1086 | 1087 | 1088 | class TileTab(Tile): 1089 | def __init__internal__(self): 1090 | self._tabs = {} 1091 | self._tabs_show = {} 1092 | 1093 | def create_element(self): 1094 | self._tab = ttk.Notebook(self.frame) 1095 | self._elem = [] 1096 | self._tabs.clear() 1097 | 1098 | for el in self.pref(SOURCE, []): 1099 | caption = "" 1100 | if type(el) == tuple: 1101 | caption = el[0] 1102 | if type(caption) == tuple: 1103 | caption, idn = caption 1104 | else: 1105 | idn = "tab_" + caption 1106 | if len(el) > 2: 1107 | # visible or not 1108 | self._tabs_show[idn] = el[2] 1109 | 1110 | if idn not in self._tabs_show: 1111 | self._tabs_show[idn] = True 1112 | 1113 | if self._tabs_show[idn]: 1114 | elem = el[1] 1115 | elem.set_parent(self.frame) 1116 | elem.layout() 1117 | self._elem.append(elem) 1118 | 1119 | self._tab.add(elem.frame, text=caption) 1120 | 1121 | idx = str(len(self._tab.tabs()) - 1) 1122 | self._tabs[idn] = idx 1123 | 1124 | self._tab_sel = 0 1125 | 1126 | self._tab.bind("<>", self._tab_handler) 1127 | return self._tab 1128 | 1129 | def _tab_handler(self, event): 1130 | cur = self.get_index() 1131 | if cur == self._tab_sel: 1132 | return 1133 | self._tab_sel = cur 1134 | self.pref(ON_CHANGE, self.on_change)() 1135 | 1136 | def on_change(self): 1137 | selidx = self.get_index() 1138 | nam = list(filter(lambda x: int(x[1]) == selidx, self._tabs.items())) 1139 | print_t(self.__class__.__name__, ON_CHANGE, selidx, nam) 1140 | 1141 | def get_index(self): 1142 | return self._tab.index(self._tab.select()) 1143 | 1144 | def set_index(self, idx): 1145 | self._tab.select(idx) 1146 | self._tab_handler(None) 1147 | 1148 | def keys(self): 1149 | return self._tabs.keys() 1150 | 1151 | def select(self, nam): 1152 | print_t("select", self._tabs) 1153 | idx = self._tabs[nam] 1154 | self._tab.select(idx) 1155 | 1156 | def show_tab(self, idn, show=True): 1157 | # todo 1158 | raise Exception("untested") 1159 | self._tabs_show[idn] = show 1160 | 1161 | def hide_tab(self, idn): 1162 | # todo 1163 | raise Exception("untested") 1164 | self.show_tab(idn, False) 1165 | 1166 | def focus_first_active_tab(self): 1167 | print_t("cur sel tab", self._tab_sel) 1168 | el = self._elem[self._tab_sel] 1169 | el.focus() 1170 | 1171 | 1172 | class ContextMenu(object): 1173 | 1174 | def __init__(self, root, ctx): 1175 | self.root = root 1176 | self.ctx = ctx 1177 | self._menu = Menu(root, tearoff=0) 1178 | self._menu.bind("", lambda x: self.hide()) 1179 | 1180 | def hide(self): 1181 | self._menu.destroy() 1182 | self._menu = None 1183 | 1184 | def add_separator(self): 1185 | self._menu.add_separator() 1186 | 1187 | def add_command(self, caption, cmdfunc, args=None): 1188 | def _call(args): 1189 | self.hide() 1190 | if cmdfunc: 1191 | cmdfunc(args) 1192 | self._menu.add_command(label=caption, command=lambda: _call(args)) 1193 | 1194 | def show(self): 1195 | self._menu.post(self.ctx.x_root, self.ctx.y_root) 1196 | 1197 | 1198 | class TileTreeView(Tile): 1199 | 1200 | class Context(object): 1201 | def __repr__(self): 1202 | return str(self.__dict__) 1203 | 1204 | def create_element(self): 1205 | header = self.pref("header", []) 1206 | header_width = self.pref("header_width", []) 1207 | height = self.pref("height", None) 1208 | 1209 | self.header_name = [] 1210 | self.header_title = [] 1211 | 1212 | for key, cap in header: 1213 | self.header_name.append(key) 1214 | if cap is None: 1215 | cap = key 1216 | self.header_title.append(cap) 1217 | 1218 | self._cont = ttk.Frame(self.frame) 1219 | 1220 | self._treeview = ttk.Treeview( 1221 | self._cont, 1222 | columns=self.header_name, 1223 | show="headings", 1224 | height=height, 1225 | ) 1226 | self._treeview.bind("<>", self._select_handler) 1227 | self._treeview.bind("", self._on_double_handler) 1228 | self._treeview.bind("<3>", self._tree_context_menu) 1229 | 1230 | self._yscroll = ttk.Scrollbar( 1231 | self._cont, orient="vertical", command=self._treeview.yview 1232 | ) 1233 | self._treeview.configure(yscrollcommand=self._yscroll.set) 1234 | 1235 | self._treeview.pack(side="left") 1236 | self._yscroll.pack(side="right", fill="both", padx=0) 1237 | 1238 | for key, cap in header: 1239 | if cap is None: 1240 | cap = key 1241 | self._treeview.heading(key, text=cap) 1242 | 1243 | for i in range(0, len(header_width)): 1244 | width = header_width[i] 1245 | if width: 1246 | self._treeview.column(self.header_name[i], width=width) 1247 | 1248 | values = self.pref(VALUES, []) 1249 | self.set_values(values) 1250 | 1251 | return self._cont 1252 | 1253 | def _on_double_handler(self, event): 1254 | print("_on_double_handler", event) 1255 | self.pref(ON_DOUBLE_CLICK, self.on_double)(self) 1256 | 1257 | def on_double(self, ref_self): 1258 | val = self.get_selection_values() 1259 | print_t(self.__class__.__name__, ON_DOUBLE_CLICK, val) 1260 | 1261 | def _select_handler(self, event): 1262 | print("_select_handler", event) 1263 | self.pref(ON_CLICK, self.on_select)(self) 1264 | 1265 | def on_select(self, ref_self): 1266 | print_t( 1267 | self.__class__.__name__, 1268 | ON_SELECT, 1269 | ) 1270 | 1271 | sel = self._treeview.focus() 1272 | sel = self._treeview.item(sel) 1273 | 1274 | nullable = self.pref("nullable", True) 1275 | 1276 | def _tree_context_menu(self, ev): 1277 | ctx = self._context_from_event(ev) 1278 | # selected = self._treeview.selection() 1279 | # print("right-click", ctx, ctx.iid in selected) 1280 | self.pref(ON_RIGHT_CLICK, self.on_right_click)(self, ctx) 1281 | 1282 | def on_right_click(self, cntrl, ctx): 1283 | pass 1284 | 1285 | def _context_from_event(self, ev): 1286 | 1287 | ctx = self.Context() 1288 | 1289 | xp = ctx.x = ev.x 1290 | yp = ctx.y = ev.y 1291 | ctx.button = ev.num 1292 | 1293 | ctx.x_root = ev.x_root 1294 | ctx.y_root = ev.y_root 1295 | 1296 | widg = self._treeview 1297 | 1298 | ctx.region = widg.identify("region", xp, yp) 1299 | # CHECK region for heading or tree 1300 | 1301 | ctx.iid = widg.identify("item", xp, yp) 1302 | 1303 | ctx.row = ctx.iid, widg.index(ctx.iid) 1304 | ctx.column = widg.identify("column", xp, yp) 1305 | 1306 | if ctx.column.startswith("#"): 1307 | no = int(ctx.column[1:])-1 1308 | nam = widg["columns"][no-1] if no > 0 else ctx.column 1309 | ctx.column = (ctx.column, no, nam) 1310 | else: 1311 | print("error in column", ctx) 1312 | 1313 | ctx.isopen = widg.item(ctx.iid, "open") 1314 | 1315 | return ctx 1316 | 1317 | def clear(self): 1318 | self._treeview.delete(*self._treeview.get_children()) 1319 | 1320 | def set_values(self, values, mapval=True): 1321 | print("set_values", values) 1322 | self._values = values 1323 | self._iid = [] 1324 | self.clear() 1325 | for v in self._values: 1326 | if mapval: 1327 | try: 1328 | v = v.__dict__ 1329 | except: 1330 | pass 1331 | rec = self.get_record(v) 1332 | else: 1333 | rec = v 1334 | 1335 | iid = self._treeview.insert("", "end", values=rec) 1336 | self._iid.append(iid) 1337 | 1338 | def get_record(self, vals): 1339 | rec = list(map(lambda x: vals[x], self.header_name)) 1340 | return rec 1341 | 1342 | def get_iid(self, val): 1343 | pos = self._values.index(val) 1344 | return self._iid[pos] 1345 | 1346 | def clr_selection(self): 1347 | if len(self._treeview.selection()) > 0: 1348 | self._treeview.selection_remove(self._treeview.selection()) 1349 | 1350 | def set_selection(self, vals): 1351 | self.clr_selection() 1352 | if vals == -1: 1353 | vals = self._iid 1354 | else: 1355 | vals = list(map(lambda x: self.get_iid(x), vals)) 1356 | for v in vals: 1357 | self._treeview.selection_add(v) 1358 | self._treeview.see(v) 1359 | 1360 | def get_selection(self): 1361 | return self._treeview.selection() 1362 | 1363 | def get_selection_values(self): 1364 | sel = self.get_selection() 1365 | vals = [] 1366 | for iid in sel: 1367 | try: 1368 | pos = self._iid.index(iid) 1369 | vals.append(self._values[pos]) 1370 | except: 1371 | print("not found", iid) 1372 | 1373 | return vals 1374 | 1375 | def set_index(self, idx=0): 1376 | raise Exception("untested") 1377 | self.clr_selection() 1378 | iid = self._iid[idx] 1379 | self._treeview.selection_add(iid) 1380 | self._treeview.see(iid) 1381 | 1382 | def focus(self): 1383 | print("***TREEVIEW FOCUS") 1384 | self._treeview.focus_set() 1385 | return self 1386 | -------------------------------------------------------------------------------- /gitonic/tile/tkcmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | (c)2021 K. Goger - https://github.com/kr-g 3 | legal: https://github.com/kr-g/gitonic/blob/main/LICENSE.md 4 | """ 5 | 6 | 7 | class TkCmd(object): 8 | class Options(object): 9 | def __init__(self, opts): 10 | self.__dict__.update(opts) 11 | 12 | def start( 13 | self, 14 | parent, 15 | command=None, 16 | tout=1000, 17 | detached=False, 18 | info=None, 19 | **opts, 20 | ): 21 | self.parent = parent 22 | self.tout = tout 23 | self.rc = None 24 | self._stop = None 25 | self._cmd = command 26 | self.opts = TkCmd.Options(opts) if opts and type(opts) == dict else opts 27 | self._detached = detached 28 | self._info = info 29 | self._disp(True) 30 | return self 31 | 32 | def _disp(self, schedule=False): 33 | if not self._detached: 34 | get_tk_cmd_manager().remove(self) 35 | if schedule: 36 | if not self._detached: 37 | get_tk_cmd_manager().add(self, self._info) 38 | self.parent.after(self.tout, self._disp) 39 | return 40 | if self._stop: 41 | self.rc = self._stop 42 | return 43 | self.rc = self.run() 44 | if self.rc is None: 45 | self._disp(True) 46 | 47 | def run(self): 48 | """overload this, or use command paramter in start""" 49 | if self._cmd: 50 | return self._cmd(self) 51 | return 0 52 | 53 | 54 | class TkCmdManager(object): 55 | def __init__(self): 56 | self._pending = {} 57 | 58 | def add(self, tkcmd, info=None): 59 | if info is None: 60 | info = f"tkcmd {hex(id(tkcmd))}" 61 | tkcmd._info = info 62 | self._pending[tkcmd] = info 63 | 64 | def remove(self, tkcmd): 65 | try: 66 | del self._pending[tkcmd] 67 | except: 68 | pass 69 | 70 | def clear(self): 71 | self._pending.clear() 72 | 73 | 74 | def get_tk_cmd_manager(): 75 | global _tk_man 76 | try: 77 | if _tk_man is None: 78 | raise Exception("not found") 79 | except: 80 | _tk_man = TkCmdManager() 81 | return _tk_man 82 | -------------------------------------------------------------------------------- /install_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # create virtual environment 4 | cd ~ 5 | python3 -m venv gitonic 6 | 7 | # install gitonic with all extras 8 | ~/gitonic/bin/pip install gitonic[PEP08,MELD] 9 | -------------------------------------------------------------------------------- /patch_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from gitonic.const import VERSION 4 | 5 | with open("README.md") as f: 6 | c = f.read() 7 | 8 | par = "VERSION[ /t]*=[ /t]*(.*)[ /t]*$" 9 | 10 | matches = re.finditer(par, c, re.MULTILINE) 11 | 12 | for m in matches: 13 | c = c.replace(m.groups()[0], VERSION) 14 | print("replaced", m.groups()[0]) 15 | 16 | with open("README.md", "w") as f: 17 | f.write(c) 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | #[build-system] 2 | #requires = ["setuptools","twine","wheel","black","flake8"] 3 | # build-backend = "setuptools.build_meta:__legacy__" 4 | 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | #pyjsoncfg 2 | pytkfaicons 3 | tkinter-tooltip 4 | #black 5 | #autopep8 6 | #flake8 7 | #pycodestyle 8 | #yapf 9 | #meld 10 | -------------------------------------------------------------------------------- /run_main.py: -------------------------------------------------------------------------------- 1 | from gitonic.main import main_func 2 | 3 | if __name__ == "__main__": 4 | main_func(debug_=True) 5 | -------------------------------------------------------------------------------- /run_qs_tool.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # autopep8 -i -r ./gitonic 5 | # flake8 --config flake8.cfg 6 | python3 -m unittest -v 7 | 8 | 9 | for f in build dist *.egg-info __pycache__ ; do 10 | echo remove $f 11 | find . -name $f | xargs rm -rf 12 | done 13 | 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | keywords = python utility shell git git-workspace tkinter 3 | description = manage a multi git workspace 4 | 5 | long_description = file: README.md, CHANGELOG.md, BACKLOG.md, LICENSE.md 6 | long_description_content_type = text/markdown 7 | 8 | license = AGPLv3+ 9 | classifiers = 10 | Development Status :: 3 - Alpha 11 | Operating System :: POSIX :: Linux 12 | Environment :: Other Environment 13 | Intended Audience :: Developers 14 | Topic :: Utilities 15 | Topic :: Desktop Environment 16 | Topic :: Software Development :: Version Control :: Git 17 | Programming Language :: Python :: 3 18 | License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+) 19 | 20 | 21 | [options.extras_require] 22 | PEP08 = 23 | pycodestyle 24 | flake8 25 | autopep8 26 | 27 | PEP08_BLACK = 28 | pycodestyle 29 | flake8 30 | black 31 | 32 | PEP08_FULL = 33 | pycodestyle 34 | flake8 35 | autopep8 36 | black 37 | yapf 38 | 39 | MELD = 40 | meld 41 | 42 | DEFAULT = 43 | pycodestyle 44 | flake8 45 | autopep8 46 | meld 47 | 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuputil import * 2 | import setuptools 3 | 4 | setuptools.setup( 5 | name=projectname, 6 | version=version, 7 | author="k. goger", 8 | author_email=f"k.r.goger+{projectname}@gmail.com", 9 | url=f"https://github.com/kr-g/{projectname}", 10 | packages=setuptools.find_packages(), 11 | python_requires=python_requires, 12 | install_requires=install_requires, 13 | entry_points=entry_points, 14 | ) 15 | 16 | # !!! 17 | # python3 patch_version.py 18 | # !!! 19 | 20 | # python3 -m setup sdist build bdist_wheel 21 | 22 | # test.pypi 23 | # twine upload --repository testpypi dist/* 24 | # python3 -m pip install --index-url https://test.pypi.org/simple/ gitonic --extra-index-url https://pypi.org/simple/ 25 | # python3 -m pip install --index-url https://test.pypi.org/simple/ --no-deps gitonic 26 | 27 | # pypi 28 | # twine upload dist/* 29 | -------------------------------------------------------------------------------- /setupchk.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import entry_points 2 | 3 | eps = entry_points() 4 | 5 | console_scripts = eps["console_scripts"] 6 | -------------------------------------------------------------------------------- /setuputil.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | import os 4 | import importlib 5 | import re 6 | 7 | # import setuptools 8 | 9 | 10 | def find_version(fnam, version="VERSION"): 11 | with open(fnam) as f: 12 | cont = f.read() 13 | regex = f'{version}\\s*=\\s*["]([^"]+)["]' 14 | match = re.search(regex, cont) 15 | if match is None: 16 | raise Exception( 17 | f"version with spec={version} not found, use double quotes for version string" 18 | ) 19 | return match.group(1) 20 | 21 | 22 | def find_projectname(): 23 | cwd = os.getcwd() 24 | name = os.path.basename(cwd) 25 | return name 26 | 27 | 28 | def load_requirements(): 29 | with open("requirements.txt") as f: 30 | lines = f.readlines() 31 | lines = map(lambda x: x.strip(), lines) 32 | lines = filter(lambda x: len(x) > 0, lines) 33 | lines = filter(lambda x: x[0] != "#", lines) 34 | return list(lines) 35 | 36 | 37 | def get_scripts(projectname): 38 | console_scripts = [] 39 | gui_scripts = [] 40 | 41 | try: 42 | mod = importlib.import_module(f"{projectname}.__main__") 43 | if "main_func" in dir(mod): 44 | console_scripts = [ 45 | f"{projectname} = {projectname}.__main__:main_func", 46 | ] 47 | if "gui_func" in dir(mod): 48 | gui_scripts = [ 49 | f"{projectname}-ui = {projectname}.__main__:gui_func", 50 | ] 51 | except: 52 | print("no scripts found", file=sys.stderr) 53 | raise Exception() 54 | 55 | entry_points = { 56 | "console_scripts": console_scripts, 57 | "gui_scripts": gui_scripts, 58 | } 59 | 60 | return entry_points 61 | 62 | 63 | pyver = platform.python_version_tuple()[:2] 64 | pyversion = ".".join(pyver) 65 | python_requires = f">={pyversion}" 66 | 67 | projectname = find_projectname() 68 | 69 | file = os.path.join(projectname, "const.py") 70 | version = find_version(file) 71 | 72 | install_requires = load_requirements() 73 | 74 | entry_points = get_scripts(projectname) 75 | --------------------------------------------------------------------------------