├── .github └── workflows │ └── test.yml ├── .gitignore ├── .project ├── .pydevproject ├── Jenkinsfile ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── TODO ├── applications ├── addtrans.desktop ├── cleartrans-cli.desktop ├── sellstock-cli.desktop ├── sorttrans-cli.desktop ├── updateprices.desktop └── withdraw-cli.desktop ├── bin ├── addtrans ├── cleartrans-cli ├── sellstock-cli ├── sorttrans-cli ├── updateprices └── withdraw-cli ├── doc ├── addtrans-account.png ├── addtrans-amount.png ├── addtrans-dropdown.png ├── addtrans-readyagain.png ├── addtrans-started-up.png └── addtrans.md ├── ledgerhelpers.spec ├── man ├── addtrans.1 ├── cleartrans-cli.1 ├── sellstock-cli.1 ├── sorttrans-cli.1 └── withdraw-cli.1 ├── pyproject.toml ├── setup.cfg ├── src └── ledgerhelpers │ ├── __init__.py │ ├── dateentry.py │ ├── diffing.py │ ├── editabletransactionview.py │ ├── gui.py │ ├── journal.py │ ├── legacy.py │ ├── legacy_needsledger.py │ ├── parser.py │ ├── programs │ ├── __init__.py │ ├── addtrans.py │ ├── cleartranscli.py │ ├── common.py │ ├── sellstockcli.py │ ├── sorttranscli.py │ ├── updateprices.py │ └── withdrawcli.py │ └── transactionstatebutton.py ├── tests ├── __init__.py ├── dogtail │ └── addtrans.py ├── test_base.py ├── test_ledgerhelpers.py ├── test_parser.py └── testdata │ ├── no_end_value.dat │ ├── simple_transaction.dat │ ├── with_comments.dat │ └── zero.dat └── tox.ini /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | python-version: ["3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | set -e 20 | # sudo add-apt-repository universe 21 | # sudo apt-cache policy 22 | sudo apt-get install -qy python3-ledger tox python3-pytest gobject-introspection 23 | # Needed to allow Tox to run in the current env. 24 | # Current env necessary because we are installing GTK+ and other 25 | # libraries needed for the programs. 26 | pip3 install tox-current-env pytest 27 | - name: Run tests 28 | run: | 29 | # pylint $(git ls-files '*.py') 30 | tox --current-env -v 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | dist 4 | *.egg-info 5 | build 6 | .settings 7 | *.tar.gz 8 | *.rpm 9 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | ledgerhelpers 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | /${PROJECT_DIR_NAME}/src 5 | 6 | python 3.7 7 | System Python 3.7 8 | 9 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | // https://github.com/Rudd-O/shared-jenkins-libraries 2 | @Library('shared-jenkins-libraries@master') _ 3 | 4 | genericFedoraRPMPipeline(null, null, ['python3-ledger']) 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include doc/* 3 | include applications/*.desktop 4 | include tests/*py 5 | include tests/testdata/* 6 | include tests/dogtail/*.py 7 | include *.spec 8 | include tox.ini 9 | include Jenkinsfile 10 | include build.parameters 11 | global-exclude *.py[cod] 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ledger helpers (ledgerhelpers) 2 | ============================ 3 | 4 | This is a collection of small single-purpose programs to aid your accounting 5 | with [Ledger](https://github.com/ledger/ledger) (ledger-cli). Think of it 6 | as the batteries that were never included with Ledger. 7 | 8 | Why should you use them? Because: 9 | 10 | 11 | * All the ledgerhelpers have been designed with fast data entry in mind, 12 | and they will remember or evoke existing data as needed, to help you minimize 13 | typing and other drudgery. 14 | * They all have launcher icons in your desktop environment -- this makes it 15 | very easy to add icons or shortcuts for them, so you can run them on the spot. 16 | 17 | This package also contains a library with common functions that you can use 18 | in your project to make it easier to develop software compatible with Ledger. 19 | 20 | What can you do with these programs 21 | ----------------------------------- 22 | 23 | * Enter transactions easily with 24 | [addtrans](https://github.com/Rudd-O/ledgerhelpers/blob/master/bin/addtrans). 25 | * Update your price quotes with 26 | [updateprices](https://github.com/Rudd-O/ledgerhelpers/blob/master/bin/updateprices). 27 | * Record multi-currency ATM withdrawals with 28 | [withdraw-cli](https://github.com/Rudd-O/ledgerhelpers/blob/master/bin/withdraw-cli). 29 | * Record FIFO stock or commodity sales with 30 | [sellstock-cli](https://github.com/Rudd-O/ledgerhelpers/blob/master/bin/sellstock-cli). 31 | * Interactively clear transactions with 32 | [cleartrans-cli](https://github.com/Rudd-O/ledgerhelpers/blob/master/bin/cleartrans-cli). 33 | * Keep your ledger chronologically sorted with 34 | [sorttrans-cli](https://github.com/Rudd-O/ledgerhelpers/blob/master/bin/sorttrans-cli). 35 | 36 | Usage and manuals 37 | ----------------- 38 | 39 | * [How to add transactions with `addtrans`](doc/addtrans.md) 40 | 41 | See also individual [manual pages](man/) in NROFF format. 42 | 43 | How to download and install 44 | --------------------------- 45 | 46 | Here are instructions to install the very latest iteration of ledgerhelpers: 47 | 48 | If you are on a Linux system and want to install it as an RPM package: 49 | 50 | * Obtain the package with `git clone https://github.com/Rudd-O/ledgerhelpers` 51 | * Change to the directory `cd ledgerhelpers` 52 | * Create a source package with `python3 -m build --sdist` 53 | * Create a source RPM with `rpmbuild --define "_srcrpmdir ./" --define "_sourcedir dist/" -bs *.spec` 54 | * You may need to install some dependencies at this point. The process will tell you. 55 | * Create an installable RPM with `rpmbuild --rebuild --nodeps --define "_rpmdir ./" *.src.rpm` 56 | * You may need to install some dependencies at this point. The process will tell you. 57 | * Install the package with `sudo rpm -Uvh noarch/*.noarch.rpm` 58 | 59 | In other circumstances or Linux systems: 60 | 61 | * Obtain the package with `git clone https://github.com/Rudd-O/ledgerhelpers` 62 | * Change to the directory `cd ledgerhelpers` 63 | * Create the source package directly with `python3 -m build --sdist` 64 | * Install the package (to your user directory `~/.local`) with `pip3 install dist/*.tar.gz` 65 | * This will install a number of dependencies for you. Your system should already 66 | have the GTK+ 3 library and the Python GObject introspection library. 67 | 68 | The programs in `bin/` can generally run from the source directory, provided 69 | that the PYTHONPATH points to the `src/` folder inside the source directory, 70 | but you still need to install the right dependencies, such as GTK+ 3 or later, 71 | and the Python GObject introspection library. 72 | 73 | License 74 | ------- 75 | 76 | This program is free software; you can redistribute it and/or modify it under 77 | the terms of the GNU General Public License as published by the Free Software 78 | Foundation; either version 2 of the License, or (at your option) any later 79 | version. 80 | 81 | See [full license terms](LICENSE.txt). 82 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * merge alignpostings from financial machine as alignpostings-cli 2 | * rename sortpostigns to sortpostings-cli 3 | * enhance parser so that sortpostings-cli and alignpostings-cli 4 | can use the parser instead of regexps instead 5 | * add tests for sortpostings-cli and alignpostings-cli 6 | * make parsegmob output the transactions where the a/r accrual happens 7 | * figure out where to store bal, reg, and parsegmob, and parsemypay somewhere 8 | perhaps they should be more than just scripts in the financial vm 9 | * FIXME: whenever a lot is entered with a transaction date in the addtrans 10 | boxes for values, the date is mangled and transformed into a format that 11 | Ledger cannot parse 12 | -------------------------------------------------------------------------------- /applications/addtrans.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Record transaction 3 | Exec=addtrans 4 | Icon=application-x-executable 5 | Terminal=false 6 | TryExec=addtrans 7 | Type=Application 8 | Categories=Office;Finance; 9 | X-AppInstall-Keywords=ledger 10 | -------------------------------------------------------------------------------- /applications/cleartrans-cli.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Clear uncleared transactions (CLI) 3 | Exec=cleartrans-cli 4 | Icon=application-x-executable 5 | Terminal=true 6 | TryExec=cleartrans-cli 7 | Type=Application 8 | Categories=Office;Finance; 9 | X-AppInstall-Keywords=ledger 10 | -------------------------------------------------------------------------------- /applications/sellstock-cli.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Record stock sale (CLI) 3 | Exec=sellstock-cli 4 | Icon=application-x-executable 5 | Terminal=true 6 | TryExec=sellstock-cli 7 | Type=Application 8 | Categories=Office;Finance; 9 | X-AppInstall-Keywords=ledger 10 | -------------------------------------------------------------------------------- /applications/sorttrans-cli.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Sort transactions (CLI) 3 | Exec=sorttrans-cli 4 | Icon=application-x-executable 5 | Terminal=true 6 | TryExec=sorttrans-cli 7 | Type=Application 8 | Categories=Office;Finance; 9 | X-AppInstall-Keywords=ledger 10 | -------------------------------------------------------------------------------- /applications/updateprices.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Update price file 3 | Exec=updateprices 4 | Icon=application-x-executable 5 | Terminal=false 6 | TryExec=updateprices 7 | Type=Application 8 | Categories=Office;Finance; 9 | X-AppInstall-Keywords=ledger 10 | -------------------------------------------------------------------------------- /applications/withdraw-cli.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Record withdrawal (CLI) 3 | Exec=withdraw-cli 4 | Icon=application-x-executable 5 | Terminal=true 6 | TryExec=withdraw-cli 7 | Type=Application 8 | Categories=Office;Finance; 9 | X-AppInstall-Keywords=ledger 10 | -------------------------------------------------------------------------------- /bin/addtrans: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) 7 | 8 | from ledgerhelpers.programs import addtrans 9 | 10 | if __name__ == "__main__": 11 | sys.exit(addtrans.main()) 12 | -------------------------------------------------------------------------------- /bin/cleartrans-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) 7 | 8 | from ledgerhelpers.programs import cleartranscli 9 | 10 | if __name__ == "__main__": 11 | sys.exit(cleartranscli.main()) 12 | -------------------------------------------------------------------------------- /bin/sellstock-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) 7 | 8 | from ledgerhelpers.programs import sellstockcli 9 | 10 | if __name__ == "__main__": 11 | sys.exit(sellstockcli.main()) 12 | -------------------------------------------------------------------------------- /bin/sorttrans-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) 7 | 8 | from ledgerhelpers.programs import sorttranscli 9 | 10 | if __name__ == "__main__": 11 | sys.exit(sorttranscli.main(sys.argv)) 12 | -------------------------------------------------------------------------------- /bin/updateprices: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) 7 | 8 | from ledgerhelpers.programs import updateprices 9 | 10 | if __name__ == "__main__": 11 | sys.exit(updateprices.main(sys.argv)) 12 | -------------------------------------------------------------------------------- /bin/withdraw-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) 7 | 8 | from ledgerhelpers.programs import withdrawcli 9 | 10 | if __name__ == "__main__": 11 | sys.exit(withdrawcli.main()) 12 | -------------------------------------------------------------------------------- /doc/addtrans-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/doc/addtrans-account.png -------------------------------------------------------------------------------- /doc/addtrans-amount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/doc/addtrans-amount.png -------------------------------------------------------------------------------- /doc/addtrans-dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/doc/addtrans-dropdown.png -------------------------------------------------------------------------------- /doc/addtrans-readyagain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/doc/addtrans-readyagain.png -------------------------------------------------------------------------------- /doc/addtrans-started-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/doc/addtrans-started-up.png -------------------------------------------------------------------------------- /doc/addtrans.md: -------------------------------------------------------------------------------- 1 | `addtrans`: add transactions fast 2 | ================================= 3 | 4 | This program helps you enter new transactions as fast as possible. With as little as two or three keystrokes (one for autocompletion, zero or one for date change, and one for confirmation), you can have a correct transaction entered into your ledger. 5 | 6 | This is how `addtrans` assists you: 7 | 8 | * autocompletion of old transactions based on the payee / description, 9 | * autocompletion of existing accounts as you type, 10 | * intelligent currency detection for amounts, based on the last currency used for the account associated with the amount, 11 | * intelligent memory of the last dates used for a transaction, 12 | * extensive keyboard shortcuts, both to focus on fields and to alter unfocused fields (see below for details). 13 | 14 | We'll quickly show you how you can take advantage of `addtrans` in a few screenshots and words. 15 | 16 | Entering data with `addtrans`: a quick tutorial 17 | ----------------------------------------------- 18 | 19 | After you install the program onto your system, you can simply run the *Add transaction* program under your desktop's program menu. 20 | 21 | The program will appear like this: 22 | 23 | ![Add transaction running](addtrans-started-up.png?raw=true "Add transaction running") 24 | 25 | At this point you are ready to begin typing the payee or the description of a transaction. As you type, a dropdown with all matching transactions will appear, and the closest match will be used as a model for the transaction you are about to enter. You can ignore the dropdown and continue typing, or simply select one from the dropdown and then hit *Enter*. 26 | 27 | Note how the preview field below shows you the transaction as it will be entered at the end of your ledger. This lets you have 100% confidence that what you see is what you will get. 28 | 29 | This is how the process looks like (minus the dropdown, as my desktop environment does not let me capture transient windows here): 30 | 31 | ![Add transaction typeahed search](addtrans-dropdown.png?raw=true "Add transaction typeahead search") 32 | 33 | Don't worry too much about this. It's pretty intuitive. Anything that matches a previous transaction gets automatically entered as the transaction you are about to enter. As long as you have not yet altered the account and amount entries below, this autocomplete will replace the entire transaction as you edit it. 34 | 35 | At this point, you might want to set the right dates (main and / or auxiliary) on the transaction you are entering. Suppose you want to backtrack three days from today. There are a number of ways to do so: 36 | 37 | 1. You can click on the calendar icon next to the date you want to change, then select the date and confirming. 38 | 2. You can switch to the respective date entry with the shortcut key associated with the entry (see below), then type the date. 39 | 3. You can simply use the appropriate general shortcut key three times to backtrack the date three days (Control+Minus, see below for full reference). 40 | 41 | We'll choose option 3 for speed. After the main date has gone back 3 days, note that the keyboard focus remains on the *Payee* field. This is excellent — you invested very little effort, and you already have an almost fully finished transaction, with very few changes that need to be made. 42 | 43 | Now you are ready to finish the rest of the transaction. Again, simple. Tab your way out of the *Payee* field and into the first amount, then enter the amount and the first account on the line below: 44 | 45 | ![Add transaction add amount](addtrans-amount.png?raw=true "Add transaction add amount") 46 | 47 | Note that you can specify any currencies there, but *you don't have to*. If you do not, `addtrans` will recall the last currency you used for the corresponding account, then use that currency. What this means is that, in practice, 99% of the transactions you enter will never require you to type any currency. 48 | 49 | Tab yourself out of the amount field, and enter an account. We'll change the asset account to a different one: 50 | 51 | ![Add transaction account entry](addtrans-account.png?raw=true "Add transaction account entry") 52 | 53 | Note that autocomplete works for us here. Either enter your full account, or select the account from the autocomplete suggestions. Remember that you can use either the mouse or the arrow keys and *Enter* to choose an option. 54 | 55 | Follow the same process for the rest of the records of the transaction, then hit *Add* to record the entire transaction on your ledger. Note that you can also hit *Enter* at any point and, if the transaction is valid, it will be recorded. 56 | 57 | After recording a transaction, `addtrans` will leave you ready to enter further transactions: 58 | 59 | ![Add transaction ready for more](addtrans-readyagain.png?raw=true "Add transaction ready for more") 60 | 61 | Things to note: 62 | 63 | * Just as ledger normally allows, you do not need to enter an amount on the last record of the transaction. As long as all but one of your records has an amount, `addtrans` is good with you. 64 | * Adding amounts with currency equivalencies (`xxx CUR1 @ yyy CUR2`) works as you would expect of any ledger entry. 65 | * `addtrans` will note any validation errors on its status line next to the action buttons at the bottom. Note that there is a limitation in ledger (a bug that has been fixed, but whose fix has not been released) that prevents `addtrans` from telling you exactly what's wrong with the transaction, but we're confident at this time that the bug fix will be released soon. In the meantime, use the transaction preview as a guide to understand what might be wrong. 66 | * You can learn to enter transactions at amazing speed and with very little effort. See below for very useful key combinations to help you enter data extremely fast. 67 | 68 | Thus, we come to the end of the quick tutorial. 69 | 70 | How to get to `addtrans` really fast 71 | ------------------------------------ 72 | 73 | `addtrans` starts really quickly, and it's very convenient to come back to your laptop or workstation after an expense to enter it right away. So we recommend you add a global keyboard shortcut on your desktop environment, such that `addtrans` can be started with a single key. 74 | 75 | Instructions vary from environment to environment: 76 | 77 | * KDE: use the `kmenuedit` program: 78 | * Right-click on the Application Menu of your desktop. 79 | * Select *Edit Applications...*. 80 | * Find and select the menu entry for *Add transaction* (usually under the *Financial* category). 81 | * Move to the *Advanced* tab on the right-side menu entry pane. 82 | * Click on the button next to the *Current shortcut key*. 83 | * Tap on the key you want to use to launch the program. 84 | * Exit the menu editor, saving the changes you just made. 85 | 86 | Key combinations and other tricks for fast data entry 87 | ----------------------------------------------------- 88 | 89 | Key combos: 90 | 91 | * General: 92 | * Alt+C: close window without saving 93 | * Enter: save transaction as displayed in the preview 94 | * Alt+A: save transaction as displayed in the preview 95 | * Tab: go to the next field or control 96 | * Shift+Tab: go to the previous field or control 97 | * While any of the entry fields is focused: 98 | * Arrow Up: focus on the field right above the one with focus. 99 | * Arrow Down: focus on the field right below the one with focus. 100 | * Alt+T: focus on the main date 101 | * Alt+L: focus on the auxiliary date 102 | * Alt+P: focus on the payee / description field 103 | * Control+Minus: previous day on the main date 104 | * Control+Plus: next day on the main date 105 | * Control+Page Up: same day previous month on the main date 106 | * Control+Plus Down: same day next month on the main date 107 | * Shift+Control+Minus: previous day on the auxiliary date 108 | * Shift+Control+Plus: next day on the auxiliary date 109 | * Shift+Control+Page Up: same day previous month on the auxiliary date 110 | * Shift+Control+Plus Down: same day next month on the auxiliary date 111 | * While any of the date fields is focused: 112 | * Minus / Underscore: previous day 113 | * Plus / Equals: next day 114 | * Page Up: same day previous month 115 | * Page Down: same day next month 116 | * Home: beginning of month 117 | * End: end of month 118 | * Alt+Down: drop down a calendar picker 119 | * While the calendar picker of one of the dates is focused: 120 | * Same control keys for date selection 121 | * Arrow keys: move around the calendar 122 | * Space: select the day framed by the dotted line 123 | * Enter: confirm and closes the popup 124 | * Alt+Up: hide the calendar picker 125 | * While the transaction state button is focused: 126 | * Space: cycle through the different states 127 | -------------------------------------------------------------------------------- /ledgerhelpers.spec: -------------------------------------------------------------------------------- 1 | # See https://docs.fedoraproject.org/en-US/packaging-guidelines/Python/#_example_spec_file 2 | 3 | %define debug_package %{nil} 4 | 5 | %define _name ledgerhelpers 6 | 7 | %define mybuildnumber %{?build_number}%{?!build_number:1} 8 | 9 | Name: python-%{_name} 10 | Version: 0.3.10 11 | Release: %{mybuildnumber}%{?dist} 12 | Summary: A collection of helper programs and a helper library for Ledger (ledger-cli) 13 | 14 | License: GPLv2+ 15 | URL: https://github.com/Rudd-O/%{_name} 16 | Source: %{url}/archive/v%{version}/%{_name}-%{version}.tar.gz 17 | 18 | BuildArch: noarch 19 | BuildRequires: python3-devel 20 | 21 | %global _description %{expand: 22 | This is a collection of small single-purpose programs to aid your accounting 23 | with [Ledger](https://github.com/ledger/ledger) (ledger-cli). Think of it 24 | as the batteries that were never included with Ledger.} 25 | 26 | %description %_description 27 | 28 | %package -n %{_name} 29 | Summary: %{summary} 30 | 31 | %description -n %{_name} %_description 32 | 33 | %prep 34 | %autosetup -p1 -n %{_name}-%{version} 35 | 36 | %generate_buildrequires 37 | %pyproject_buildrequires -t 38 | 39 | 40 | %build 41 | %pyproject_wheel 42 | 43 | 44 | %install 45 | %pyproject_install 46 | 47 | %pyproject_save_files %{_name} 48 | 49 | 50 | %check 51 | %tox 52 | 53 | 54 | # Note that there is no %%files section for 55 | # the unversioned python module, python-pello. 56 | 57 | # For python3-pello, %%{pyproject_files} handles code files and %%license, 58 | # but executables and documentation must be listed in the spec file: 59 | 60 | %files -n %{_name} -f %{pyproject_files} 61 | %attr(0755, -, -) %{_bindir}/* 62 | %{_mandir}/man1/* 63 | %{_datadir}/applications/* 64 | %doc README.md doc/* 65 | 66 | 67 | %changelog 68 | * Thu Jun 16 2022 Manuel Amador 0.1.0-1 69 | - First RPM packaging release 70 | -------------------------------------------------------------------------------- /man/addtrans.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2022 Marcin Owsiany , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH addtrans 1 "November 11 2022" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | addtrans \- interactively add transactions to a ledger file 22 | .SH SYNOPSIS 23 | .B addtrans 24 | .RI [ options ] 25 | .SH DESCRIPTION 26 | .B addtrans 27 | is a graphical application for quickly adding new transactions to a ledger file. 28 | .PP 29 | A tutorial for using this program, with screenshots, is available at 30 | .BR /usr/share/doc/ledgerhelpers/doc/addtrans.html 31 | .PP 32 | The program must be supplied with locations of the ledger and price database 33 | files to work with. 34 | The location of each file is determined independently, using the following 35 | mechanisms, in this order. 36 | The first mechanism which yields a result, wins. 37 | .SH OPTIONS 38 | .TP 39 | .BR \-h , 40 | .BR \-\-help 41 | Show help message and exit. 42 | .TP 43 | .B \-\-file FILE 44 | Specify path to ledger file to work with. 45 | .TP 46 | .B \-\-price\-db PRICEDB 47 | Specify path to ledger price database to work with. 48 | .TP 49 | .B \-\-debug 50 | Turn on debugging output, may be useful for developers. 51 | .SH ENVIRONMENT 52 | The following environment variables are recognized by this program: 53 | .TP 54 | .BR LEDGER_FILE 55 | Path to ledger file to work with. 56 | .TP 57 | .BR LEDGER_PRICE_DB 58 | Path to ledger price database to work with. 59 | .SH FILES 60 | The config file for 61 | .BR ledger (1), 62 | namely file 63 | .BR .ledgerrc 64 | in user's home directory is scanned looking for the following options. 65 | .TP 66 | .B \-\-file FILE 67 | Path to ledger file to work with. 68 | .TP 69 | .B \-\-price\-db PRICEDB 70 | Path to ledger price database to work with. 71 | 72 | .SH SEE ALSO 73 | .BR ledger (1), 74 | .BR cleartrans\-cli (1), 75 | .BR sellstock\-cli (1), 76 | .BR sorttrans\-cli (1), 77 | .BR withdraw\-cli (1). 78 | .br 79 | A tutorial for using this program, with screenshots, is available at 80 | .BR /usr/share/doc/ledgerhelpers/doc/addtrans.html 81 | -------------------------------------------------------------------------------- /man/cleartrans-cli.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2022 Marcin Owsiany , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH cleartrans\-cli 1 "November 11 2022" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | cleartrans\-cli \- interactively clear transactions in a ledger file 22 | .SH SYNOPSIS 23 | .B cleartrans 24 | .SH DESCRIPTION 25 | .B cleartrans\-cli 26 | is a text program for quickly clearing transactions in a ledger file. 27 | .PP 28 | This program looks for all uncleared transactions in the ledger file, 29 | whose effective date is not in the future. 30 | For each such transaction, it asks whether the transaction should be cleared, 31 | and prompts for an effective date for the transaction. 32 | .PP 33 | The resulting ledger is written to a new file, and atomically renamed to the original 34 | filename. This prevents accidental loss of data. 35 | .PP 36 | The program must be supplied with location of the ledger file to work with. 37 | The location of the file is determined using the following mechanisms, in this 38 | order. 39 | The first mechanism which yields a result, wins. 40 | .SH ENVIRONMENT 41 | The following environment variable is recognized by this program: 42 | .TP 43 | .BR LEDGER_FILE 44 | Path to ledger file to work with. 45 | .SH FILES 46 | The config file for 47 | .BR ledger (1), 48 | namely file 49 | .BR .ledgerrc 50 | in user's home directory is scanned looking for the following option. 51 | .TP 52 | .B \-\-file FILE 53 | Path to ledger file to work with. 54 | .SH SEE ALSO 55 | .BR ledger (1), 56 | .BR addtrans (1), 57 | .BR sellstock\-cli (1), 58 | .BR sorttrans\-cli (1), 59 | .BR withdraw\-cli (1). 60 | -------------------------------------------------------------------------------- /man/sellstock-cli.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2022 Marcin Owsiany , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH sellstock\-cli 1 "November 14 2022" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | sellstock\-cli \- record FIFO stock or commodity sales 22 | .SH SYNOPSIS 23 | .B sellstock\-cli 24 | .SH DESCRIPTION 25 | .B sellstock\-cli 26 | is a text program for quickly recording commodity sales in a ledger file. 27 | .PP 28 | The program first prompts for the account in which the commodity was stored, 29 | the account for commissions, and the account to credit for gains and losses, 30 | as well as the amount and name of sold commodity, its sale price, and the 31 | commission. 32 | .PP 33 | Then the program invokes 34 | .B ledger 35 | to list the dates and prices at which each commodity was purchased, lists them, 36 | subtracts the requested amount and shows the result. 37 | .PP 38 | Finally, the resulting transaction is shown, asking for a confirmation to save 39 | it to the journal. 40 | .PP 41 | The program must be supplied with location of the ledger file to work with. 42 | The location of the file is determined using the following mechanisms, in this 43 | order. 44 | The first mechanism which yields a result, wins. 45 | .SH ENVIRONMENT 46 | The following environment variable is recognized by this program: 47 | .TP 48 | .BR LEDGER_FILE 49 | Path to ledger file to work with. 50 | .SH FILES 51 | The config file for 52 | .BR ledger (1), 53 | namely file 54 | .B .ledgerrc 55 | in user's home directory is scanned looking for the following option. 56 | .TP 57 | .B \-\-file FILE 58 | Path to ledger file to work with. 59 | 60 | .SH SEE ALSO 61 | .BR ledger (1), 62 | .BR addtrans (1), 63 | .BR cleartrans\-cli (1), 64 | .BR sorttrans\-cli (1), 65 | .BR withdraw\-cli (1). 66 | -------------------------------------------------------------------------------- /man/sorttrans-cli.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2022 Marcin Owsiany , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH sorttrans\-cli 1 "November 14 2022" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | sorttrans\-cli \- sorts a ledger file in chronological order 22 | .SH SYNOPSIS 23 | .B sorttrans\-cli 24 | .RI [ options ] 25 | .SH OPTIONS 26 | .TP 27 | .BR -y 28 | Write back the file immediately, without showing the differences. 29 | .TP 30 | .BR \-h , 31 | .BR \-\-help 32 | Show help message and exit. 33 | .TP 34 | .B \-\-file FILE 35 | Specify path to ledger file to work with. 36 | .TP 37 | .B \-\-price\-db PRICEDB 38 | Specify path to ledger price database to work with. 39 | .TP 40 | .B \-\-debug 41 | Turn on debugging output, may be useful for developers. 42 | . 43 | .SH DESCRIPTION 44 | .B sorttrans\-cli 45 | is a text program for sorting a ledger in chronological order. 46 | .PP 47 | The program loads the ledger file and sorts the transactions in memory. 48 | Then, depending on the command-line options, it either writes the sorted file 49 | back, or invokes 50 | .B meld 51 | with the original and sorted version shown, for interactive editing. 52 | .PP 53 | The program must be supplied with location of the ledger file to work with. 54 | If not supplied using the command-line option 55 | .BR \-\-file , 56 | the location of the file is determined using the following mechanisms, in this 57 | order. 58 | The first mechanism which yields a result, wins. 59 | .SH ENVIRONMENT 60 | The following environment variable is recognized by this program: 61 | .TP 62 | .BR LEDGER_FILE 63 | Path to ledger file to work with. 64 | .SH FILES 65 | The config file for 66 | .BR ledger (1), 67 | namely file 68 | .B .ledgerrc 69 | in user's home directory is scanned looking for the following option. 70 | .TP 71 | .B \-\-file FILE 72 | Path to ledger file to work with. 73 | 74 | .SH SEE ALSO 75 | .BR ledger (1), 76 | .BR meld (1), 77 | .BR addtrans (1), 78 | .BR cleartrans\-cli (1), 79 | .BR sellstock\-cli (1), 80 | .BR withdraw\-cli (1). 81 | 82 | -------------------------------------------------------------------------------- /man/withdraw-cli.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2022 Marcin Owsiany , 3 | .\" 4 | .\" First parameter, NAME, should be all caps 5 | .\" Second parameter, SECTION, should be 1-8, maybe w/ subsection 6 | .\" other parameters are allowed: see man(7), man(1) 7 | .TH withdraw\-cli 1 "November 11 2022" 8 | .\" Please adjust this date whenever revising the manpage. 9 | .\" 10 | .\" Some roff macros, for reference: 11 | .\" .nh disable hyphenation 12 | .\" .hy enable hyphenation 13 | .\" .ad l left justify 14 | .\" .ad b justify to both left and right margins 15 | .\" .nf disable filling 16 | .\" .fi enable filling 17 | .\" .br insert line break 18 | .\" .sp insert n+1 empty lines 19 | .\" for manpage-specific macros, see man(7) 20 | .SH NAME 21 | withdraw\-cli \- record multi-currency ATM withdrawals in a ledger file 22 | .SH SYNOPSIS 23 | .B withdraw 24 | .SH DESCRIPTION 25 | .B withdraw\-cli 26 | is a text program for quickly entering cash withdrawal transactions in a 27 | ledger file. 28 | .PP 29 | This program interactively asks for accounts and withdrawn and deposited 30 | amounts, and adds a transaction to the ledger file after confirmation. 31 | .PP 32 | The program must be supplied with location of the ledger file to work with. 33 | The location of the file is determined using the following mechanisms, in this 34 | order. 35 | The first mechanism which yields a result, wins. 36 | .SH ENVIRONMENT 37 | The following environment variable is recognized by this program: 38 | .TP 39 | .BR LEDGER_FILE 40 | Path to ledger file to work with. 41 | .SH FILES 42 | The config file for 43 | .BR ledger (1), 44 | namely file 45 | .BR .ledgerrc 46 | in user's home directory is scanned looking for the following option. 47 | .TP 48 | .B \-\-file FILE 49 | Path to ledger file to work with. 50 | .SH SEE ALSO 51 | .BR ledger (1), 52 | .BR addtrans (1), 53 | .BR cleartrans\-cli (1), 54 | .BR sellstock\-cli (1), 55 | .BR sorttrans\-cli (1). 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = ledgerhelpers 3 | version = attr: ledgerhelpers.__version__ 4 | author = Manuel Amador (Rudd-O) 5 | author_email = rudd-o@rudd-o.com 6 | description = A collection of helper programs and a helper library for Ledger (ledger-cli) 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/Rudd-O/ledgerhelpers 10 | classifiers = 11 | Development Status :: 4 - Beta 12 | Environment :: X11 Applications :: GTK 13 | Intended Audience :: End Users/Desktop 14 | License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+) 15 | Operating System :: POSIX :: Linux 16 | Programming Language :: Python :: 3 :: Only 17 | Programming Language :: Python :: 3.6 18 | Topic :: Office/Business :: Financial :: Accounting 19 | license = GPLv2+ 20 | 21 | [options] 22 | include_package_data = True 23 | install_requires = 24 | PyGObject 25 | ledger 26 | yahoo-finance 27 | package_dir = 28 | = src 29 | packages = find: 30 | scripts = 31 | bin/addtrans 32 | bin/cleartrans-cli 33 | bin/sellstock-cli 34 | bin/sorttrans-cli 35 | bin/updateprices 36 | bin/withdraw-cli 37 | 38 | [options.data_files] 39 | share/applications = 40 | applications/withdraw-cli.desktop 41 | applications/cleartrans-cli.desktop 42 | applications/sorttrans-cli.desktop 43 | applications/updateprices.desktop 44 | applications/sellstock-cli.desktop 45 | applications/addtrans.desktop 46 | share/doc/ledgerhelpers = 47 | doc/addtrans.md 48 | doc/addtrans-account.png 49 | doc/addtrans-amount.png 50 | doc/addtrans-dropdown.png 51 | doc/addtrans-readyagain.png 52 | doc/addtrans-started-up.png 53 | share/man/man1 = 54 | man/addtrans.1 55 | man/cleartrans-cli.1 56 | man/sellstock-cli.1 57 | man/sorttrans-cli.1 58 | man/withdraw-cli.1 59 | 60 | [options.packages.find] 61 | where = src 62 | -------------------------------------------------------------------------------- /src/ledgerhelpers/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import pickle 4 | import calendar 5 | import codecs 6 | import collections 7 | import datetime 8 | import fcntl 9 | import fnmatch 10 | import logging 11 | import re 12 | import os 13 | import signal 14 | import struct 15 | import sys 16 | import termios 17 | import threading 18 | import time 19 | import tty 20 | 21 | __version__ = "0.3.10" 22 | 23 | 24 | log = logging.getLogger(__name__) 25 | 26 | 27 | def debug(string, *args): 28 | log.debug(string, *args) 29 | 30 | 31 | _debug_time = False 32 | 33 | 34 | def debug_time(logger): 35 | def debug_time_inner(kallable): 36 | def f(*a, **kw): 37 | global _debug_time 38 | if not _debug_time: 39 | return kallable(*a, **kw) 40 | start = time.time() 41 | try: 42 | name = kallable.__name__ 43 | except AttributeError: 44 | name = str(kallable) 45 | name = name + "@" + threading.currentThread().getName() 46 | try: 47 | logger.debug("* Timing: %-55s started", name) 48 | return kallable(*a, **kw) 49 | finally: 50 | end = time.time() - start 51 | logger.debug("* Timed: %-55s %.3f seconds", name, end) 52 | return f 53 | return debug_time_inner 54 | 55 | 56 | def enable_debugging(enable): 57 | global _debug_time 58 | if enable: 59 | _debug_time = True 60 | fmt = "%(created)f:%(levelname)8s:%(name)20s: %(message)s" 61 | logging.basicConfig(level=logging.DEBUG, format=fmt) 62 | 63 | 64 | def matches(string, options): 65 | """Returns True if the string case-insensitively glob-matches any of the 66 | globs present in options.""" 67 | for option in options: 68 | if fnmatch.fnmatch(string, option): 69 | return True 70 | return False 71 | 72 | 73 | class LedgerConfigurationError(Exception): 74 | pass 75 | 76 | 77 | class TransactionInputValidationError(ValueError): 78 | pass 79 | 80 | 81 | def find_ledger_file(ledger_file=None): 82 | """Returns main ledger file path or raise exception if it cannot be \ 83 | found. If ledger_file is not None, use that path.""" 84 | if ledger_file is not None: 85 | return os.path.abspath(ledger_file) 86 | ledgerrcpath = os.path.abspath(os.path.expanduser("~/.ledgerrc")) 87 | if "LEDGER_FILE" in os.environ: 88 | return os.path.abspath(os.path.expanduser(os.environ["LEDGER_FILE"])) 89 | elif os.path.exists(ledgerrcpath): 90 | # hacky 91 | ledgerrc = open(ledgerrcpath).readlines() 92 | pat = r"^--file\s+(.*?)\s*$" 93 | matches = [ re.match(pat, m) for m in ledgerrc ] 94 | matches = [ m.group(1) for m in matches if m ] 95 | if not matches: 96 | raise LedgerConfigurationError("LEDGER_FILE environment variable not set, and your .ledgerrc file does not contain a --file parameter.") 97 | return os.path.abspath(os.path.expanduser(matches[0])) 98 | else: 99 | raise LedgerConfigurationError("LEDGER_FILE environment variable not set, and no \ 100 | .ledgerrc file found.") 101 | 102 | 103 | def add_months(sourcedate, months): 104 | month = sourcedate.month - 1 + months 105 | year = int(sourcedate.year + month / 12 ) 106 | month = month % 12 + 1 107 | day = min(sourcedate.day,calendar.monthrange(year,month)[1]) 108 | return datetime.date(year,month,day) 109 | 110 | 111 | def find_ledger_price_file(price_file=None): 112 | """Returns main ledger file path or raise exception if it cannot be \ 113 | found. If price_file is not None, use that path.""" 114 | if price_file is not None: 115 | return os.path.abspath(price_file) 116 | ledgerrcpath = os.path.abspath(os.path.expanduser("~/.ledgerrc")) 117 | if "LEDGER_PRICE_DB" in os.environ: 118 | return os.path.abspath(os.path.expanduser(os.environ["LEDGER_PRICE_DB"])) 119 | elif os.path.exists(ledgerrcpath): 120 | # hacky 121 | ledgerrc = open(ledgerrcpath).readlines() 122 | pat = r"^--price-db\s+(.+)" 123 | matches = [ re.match(pat, m) for m in ledgerrc ] 124 | matches = [ m.group(1) for m in matches if m ] 125 | if not matches: 126 | raise LedgerConfigurationError("LEDGER_PRICE_DB environment variable not set, and your .ledgerrc file does not contain a --price-db parameter.") 127 | return os.path.abspath(os.path.expanduser(matches[0])) 128 | else: 129 | raise LedgerConfigurationError("LEDGER_PRICE_DB environment variable not set, and no \ 130 | .ledgerrc file found.") 131 | 132 | 133 | def generate_price_records(records): 134 | """Generates a set of price records. 135 | 136 | records is a list containing tuples. each tuple contains: 137 | commodity is a ledger commodity 138 | price is the price in ledger.Amount form 139 | date is a datetime.date 140 | """ 141 | lines = [""] 142 | longestcomm = max(list(len(str(a[0])) for a in records)) 143 | longestamount = max(list(len(str(a[1])) for a in records)) 144 | for commodity, price, date in records: 145 | fmt = "P %s %-" + str(longestcomm) + "s %" + str(longestamount) + "s" 146 | lines.append(fmt % ( 147 | date.strftime("%Y-%m-%d %H:%M:%S"), 148 | commodity, 149 | price, 150 | )) 151 | lines.append("") 152 | return lines 153 | 154 | 155 | class Settings(dict): 156 | 157 | def __init__(self, filename): 158 | self.data = dict() 159 | self.filename = filename 160 | 161 | @classmethod 162 | def load_or_defaults(cls, filename): 163 | s = cls(filename) 164 | if os.path.isfile(s.filename): 165 | try: 166 | s.data = pickle.load(open(s.filename, "rb")) 167 | except Exception as e: 168 | log.error("Cannot load %s so loading defaults: %s", s.filename, e) 169 | try: 170 | unused_suggester = s["suggester"] 171 | except KeyError: 172 | s["suggester"] = AccountSuggester() 173 | return s 174 | 175 | def __setitem__(self, item, value): 176 | self.data[item] = value 177 | self.persist() 178 | 179 | def __getitem__(self, item): 180 | return self.data[item] 181 | 182 | def __delitem__(self, item): 183 | if item in self.data: 184 | del self.data[item] 185 | self.persist() 186 | 187 | def keys(self): 188 | return list(self.data.keys()) 189 | 190 | def get(self, item, default): 191 | return self.data.get(item, default) 192 | 193 | def persist(self): 194 | p = open(self.filename, "wb") 195 | pickle.dump(self.data, p) 196 | p.flush() 197 | p.close() 198 | 199 | 200 | class AccountSuggester(object): 201 | 202 | def __init__(self): 203 | self.account_to_words = dict() 204 | 205 | def __str__(self): 206 | dump = str(self.account_to_words) 207 | return "" % dump 208 | 209 | def associate(self, words, account): 210 | words = [ w.lower() for w in words.split() ] 211 | account = str(account) 212 | if account not in self.account_to_words: 213 | self.account_to_words[account] = dict() 214 | for w in words: 215 | if w not in self.account_to_words[account]: 216 | self.account_to_words[account][w] = 0 217 | self.account_to_words[account][w] += 1 218 | 219 | def suggest(self, words): 220 | words = [ w.lower() for w in words.split() ] 221 | account_counts = dict() 222 | for account, ws in list(self.account_to_words.items()): 223 | for w, c in list(ws.items()): 224 | if w in words: 225 | if not account in account_counts: 226 | account_counts[account] = 0 227 | account_counts[account] += c 228 | results = list(reversed(sorted( 229 | list(account_counts.items()), key=lambda x: x[1] 230 | ))) 231 | if results: 232 | return results[0][0] 233 | return None 234 | 235 | 236 | # ====================== GTK ======================= 237 | 238 | 239 | TransactionPosting = collections.namedtuple( 240 | 'TransactionPosting', 241 | ['account', 'amount'] 242 | ) 243 | -------------------------------------------------------------------------------- /src/ledgerhelpers/dateentry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # This code is imported straight from the Kiwi codebase, and ported to work 4 | # with GTK+ 3.x. 5 | # 6 | # The original source code is here: 7 | # http://doc.stoq.com.br/api/kiwi/_modules/kiwi/ui/dateentry.html 8 | # 9 | # The original program was provided under the LGPL 2.1 or later license terms. 10 | # As such, this program reuses it freely, linking it with the program is 11 | # permitted and should be no issue. 12 | 13 | import datetime 14 | 15 | from gi.repository import GObject 16 | import gi 17 | gi.require_version("Gdk", "3.0") 18 | gi.require_version("Gtk", "3.0") 19 | from gi.repository import Gdk 20 | from gi.repository import Gtk 21 | 22 | from ledgerhelpers.legacy import format_date, parse_date 23 | 24 | 25 | def prev_month(date): 26 | if date.month == 1: 27 | return datetime.date(date.year - 1, 12, date.day) 28 | else: 29 | day = date.day 30 | while True: 31 | try: 32 | return datetime.date(date.year, date.month - 1, day) 33 | except ValueError: 34 | day = day - 1 35 | 36 | 37 | def next_month(date): 38 | if date.month == 12: 39 | return datetime.date(date.year + 1, 1, date.day) 40 | else: 41 | day = date.day 42 | while True: 43 | try: 44 | return datetime.date(date.year, date.month + 1, day) 45 | except ValueError: 46 | day = day - 1 47 | 48 | 49 | def beginning_of_month(date): 50 | return datetime.date(date.year, date.month, 1) 51 | 52 | 53 | def end_of_month(date): 54 | if date.month == 12: 55 | date = datetime.date(date.year + 1, 1, 1) 56 | else: 57 | date = datetime.date(date.year, date.month + 1, 1) 58 | return date - datetime.timedelta(1) 59 | 60 | 61 | def prev_day(date): 62 | return date - datetime.timedelta(1) 63 | 64 | 65 | def next_day(date): 66 | return date + datetime.timedelta(1) 67 | 68 | 69 | def _according_to_keyval(keyval, state, date, skip="", in_editbox=False): 70 | if (keyval in (Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up)): 71 | if date: 72 | return True, prev_month(date) 73 | else: 74 | return True, None 75 | if (keyval in (Gdk.KEY_Page_Down, Gdk.KEY_KP_Page_Down)): 76 | if date: 77 | return True, next_month(date) 78 | else: 79 | return True, None 80 | if ( 81 | keyval in (Gdk.KEY_minus, Gdk.KEY_KP_Subtract, Gdk.KEY_underscore) and 82 | "minus" not in skip 83 | ): 84 | if date: 85 | return True, prev_day(date) 86 | else: 87 | return True, None 88 | if (keyval in (Gdk.KEY_plus, Gdk.KEY_KP_Add, 89 | Gdk.KEY_equal, Gdk.KEY_KP_Equal)): 90 | if date: 91 | return True, next_day(date) 92 | else: 93 | return True, None 94 | if ( 95 | keyval in (Gdk.KEY_Home, Gdk.KEY_KP_Home) and 96 | (not in_editbox or state & Gdk.ModifierType.SHIFT_MASK) 97 | ): 98 | if date: 99 | return True, beginning_of_month(date) 100 | else: 101 | return True, None 102 | if ( 103 | keyval in (Gdk.KEY_End, Gdk.KEY_KP_End) and 104 | (not in_editbox or state & Gdk.ModifierType.SHIFT_MASK) 105 | ): 106 | if date: 107 | return True, end_of_month(date) 108 | else: 109 | return True, None 110 | return False, None 111 | 112 | 113 | class _DateEntryPopup(Gtk.Window): 114 | 115 | __gsignals__ = { 116 | 'date-selected': ( 117 | GObject.SIGNAL_RUN_LAST, 118 | None, 119 | (object,) 120 | ) 121 | } 122 | 123 | def __init__(self, dateentry): 124 | Gtk.Window.__init__(self, Gtk.WindowType.POPUP) 125 | self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) 126 | self.connect('key-press-event', self._on__key_press_event) 127 | self.connect('button-press-event', self._on__button_press_event) 128 | self._dateentry = dateentry 129 | 130 | frame = Gtk.Frame() 131 | frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN) 132 | self.add(frame) 133 | frame.show() 134 | 135 | vbox = Gtk.VBox() 136 | vbox.set_border_width(12) 137 | frame.add(vbox) 138 | vbox.show() 139 | self._vbox = vbox 140 | 141 | self.calendar = Gtk.Calendar() 142 | self.calendar.connect('day-selected-double-click', 143 | self._on_calendar__day_selected_double_click) 144 | self.calendar.connect('day-selected', 145 | self._on_calendar__day_selected) 146 | vbox.pack_start(self.calendar, False, False, 0) 147 | self.calendar.show() 148 | 149 | buttonbox = Gtk.HButtonBox() 150 | buttonbox.set_border_width(12) 151 | buttonbox.set_layout(Gtk.ButtonBoxStyle.SPREAD) 152 | vbox.pack_start(buttonbox, False, False, 0) 153 | buttonbox.show() 154 | 155 | for label, callback in [('_Today', self._on_today__clicked), 156 | ('_Close', self._on_close__clicked)]: 157 | button = Gtk.Button(label, use_underline=True) 158 | button.connect('clicked', callback) 159 | buttonbox.pack_start(button, False, False, 0) 160 | button.show() 161 | 162 | self.set_resizable(False) 163 | self.set_screen(dateentry.get_screen()) 164 | 165 | self.realize() 166 | self.height = self._vbox.size_request().height 167 | 168 | def _on_calendar__day_selected_double_click(self, unused_calendar): 169 | self.emit('date-selected', self.get_date()) 170 | self.popdown() 171 | 172 | def _on_calendar__day_selected(self, unused_calendar): 173 | self.emit('date-selected', self.get_date()) 174 | 175 | def _on__button_press_event(self, unused_window, event): 176 | # If we're clicking outside of the window close the popup 177 | hide = False 178 | 179 | # Also if the intersection of self and the event is empty, hide 180 | # the calendar 181 | if (tuple(self.get_allocation().intersect( 182 | Gdk.Rectangle(x=int(event.x), y=int(event.y), 183 | width=1, height=1))) == (0, 0, 0, 0)): 184 | hide = True 185 | 186 | # Toplevel is the window that received the event, and parent is the 187 | # calendar window. If they are not the same, means the popup should 188 | # be hidden. This is necessary for when the event happens on another 189 | # widget 190 | toplevel = event.get_window().get_toplevel() 191 | parent = self.calendar.get_parent_window() 192 | if toplevel != parent: 193 | hide = True 194 | 195 | if hide: 196 | self.popdown() 197 | 198 | def _on__key_press_event(self, unused_window, event): 199 | """ 200 | Mimics Combobox behavior 201 | 202 | Escape, Enter or Alt+Up: Close 203 | Space: Select 204 | """ 205 | keyval = event.keyval 206 | state = event.state & Gtk.accelerator_get_default_mod_mask() 207 | if (keyval == Gdk.KEY_Escape or 208 | keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) or 209 | ((keyval == Gdk.KEY_Up or keyval == Gdk.KEY_KP_Up) and 210 | state == Gdk.ModifierType.MOD1_MASK)): 211 | self.popdown() 212 | return True 213 | processed, new_date = _according_to_keyval(keyval, state, 214 | self.get_date()) 215 | if processed and new_date: 216 | self.set_date(new_date) 217 | return processed 218 | 219 | def _on_select__clicked(self, unused_button): 220 | self.emit('date-selected', self.get_date()) 221 | 222 | def _on_close__clicked(self, unused_button): 223 | self.popdown() 224 | 225 | def _on_today__clicked(self, unused_button): 226 | self.set_date(datetime.date.today()) 227 | 228 | def _popup_grab_window(self): 229 | activate_time = 0 230 | win = self.get_window() 231 | result = Gdk.pointer_grab(win, True, ( 232 | Gdk.EventMask.BUTTON_PRESS_MASK | 233 | Gdk.EventMask.BUTTON_RELEASE_MASK | 234 | Gdk.EventMask.POINTER_MOTION_MASK), 235 | None, None, activate_time 236 | ) 237 | if result == 0: 238 | if Gdk.keyboard_grab(self.get_window(), True, activate_time) == 0: 239 | return True 240 | else: 241 | self.get_window().get_display().pointer_ungrab(activate_time) 242 | return False 243 | return False 244 | 245 | def _get_position(self): 246 | self.realize() 247 | calendar = self 248 | 249 | sample = self._dateentry 250 | 251 | # We need to fetch the coordinates of the entry window 252 | # since comboentry itself does not have a window 253 | origin = sample.entry.get_window().get_origin() 254 | x, y = origin.x, origin.y 255 | width = calendar.size_request().width 256 | height = self.height 257 | 258 | screen = sample.get_screen() 259 | monitor_num = screen.get_monitor_at_window(sample.get_window()) 260 | monitor = screen.get_monitor_geometry(monitor_num) 261 | 262 | if x < monitor.x: 263 | x = monitor.x 264 | elif x + width > monitor.x + monitor.width: 265 | x = monitor.x + monitor.width - width 266 | 267 | alloc = sample.get_allocation() 268 | 269 | if y + alloc.height + height <= monitor.y + monitor.height: 270 | y += alloc.height 271 | elif y - height >= monitor.y: 272 | y -= height 273 | elif (monitor.y + monitor.height - (y + alloc.height) > 274 | y - monitor.y): 275 | y += alloc.height 276 | height = monitor.y + monitor.height - y 277 | else: 278 | height = y - monitor.y 279 | y = monitor.y 280 | 281 | return x, y, width, height 282 | 283 | def popup(self, date): 284 | """ 285 | Shows the list of options. And optionally selects an item 286 | :param date: date to select 287 | """ 288 | combo = self._dateentry 289 | if not (combo.get_realized()): 290 | return 291 | 292 | treeview = self.calendar 293 | if treeview.get_mapped(): 294 | return 295 | toplevel = combo.get_toplevel() 296 | if isinstance(toplevel, Gtk.Window) and toplevel.get_group(): 297 | toplevel.get_group().add_window(self) 298 | 299 | x, y, width, height = self._get_position() 300 | self.set_size_request(width, height) 301 | self.move(x, y) 302 | 303 | if date is not None: 304 | self.set_date(date) 305 | self.grab_focus() 306 | 307 | if not (self.calendar.has_focus()): 308 | self.calendar.grab_focus() 309 | 310 | self.show_all() 311 | if not self._popup_grab_window(): 312 | self.hide() 313 | return 314 | 315 | self.grab_add() 316 | 317 | def popdown(self): 318 | """Hides the list of options""" 319 | combo = self._dateentry 320 | if not (combo.get_realized()): 321 | return 322 | 323 | self.grab_remove() 324 | self.hide() 325 | 326 | # month in gtk.Calendar is zero-based (i.e the allowed values are 0-11) 327 | # datetime one-based (i.e. the allowed values are 1-12) 328 | # So convert between them 329 | 330 | def get_date(self): 331 | """Gets the date of the date entry 332 | :returns: date of the entry 333 | :rtype date: datetime.date 334 | """ 335 | y, m, d = self.calendar.get_date() 336 | return datetime.date(y, m + 1, d) 337 | 338 | def set_date(self, date): 339 | """Sets the date of the date entry 340 | :param date: date to set 341 | :type date: datetime.date 342 | """ 343 | self.calendar.select_month(date.month - 1, date.year) 344 | self.calendar.select_day(date.day) 345 | # FIXME: Only mark the day in the current month? 346 | self.calendar.clear_marks() 347 | self.calendar.mark_day(date.day) 348 | 349 | 350 | @GObject.type_register 351 | class DateEntry(Gtk.HBox): 352 | """I am an entry which you can input a date on. 353 | I make entering a date blazing fast. 354 | 355 | The date you input in me must be of the form YYYY-MM-DD or YYYY/MM/DD. 356 | These are the date formats expected by Ledger. 357 | 358 | In addition to the text box where you can type, I also contain a button 359 | with an icon you can click, to get a popup window with a date picker 360 | where you can select the date. You can also show the date picker 361 | by focusing me and then hitting Alt+Down on your keyboard. 362 | 363 | There are a number of cool combos you can use, whether in the text box 364 | or in the date picker popup: 365 | 366 | * Minus:\t\tprevious day 367 | * Plus:\t\tnext day 368 | * Page Up:\tprevious month 369 | * Page Down:\tnext month 370 | * Home:\t\tbeginning of the month (if textbox is focused, Shift+Home) 371 | * End:\t\tend of the month (if textbox is focused, Shift+End) 372 | 373 | In the date picker popup, hitting Enter, or Escape, or Alt+Up after 374 | selecting a date (making it blue with a click or with the Space bar) 375 | makes the date picker popup go away. Clicking outside the date picker 376 | popup also closes it. 377 | """ 378 | 379 | __gsignals__ = { 380 | 'changed': ( 381 | GObject.SIGNAL_RUN_LAST, 382 | None, 383 | () 384 | ), 385 | 'activate': ( 386 | GObject.SIGNAL_RUN_LAST, 387 | None, 388 | () 389 | ), 390 | } 391 | 392 | def __init__(self): 393 | Gtk.HBox.__init__(self) 394 | 395 | self._old_date = None 396 | 397 | self.entry = Gtk.Entry() 398 | self.entry.set_max_length(10) 399 | self.entry.set_width_chars(13) 400 | self.entry.set_overwrite_mode(True) 401 | self.entry.set_tooltip_text( 402 | self.__doc__.replace("\n ", "\n").strip() 403 | ) 404 | self.entry.set_property('primary-icon-name', "x-office-calendar") 405 | self.entry.connect('changed', self._on_entry__changed) 406 | self.entry.connect('activate', self._on_entry__activate) 407 | self.entry.connect('key-press-event', self._on_entry__key_press_event) 408 | self.entry.connect('icon-press', self._on_entry__icon_press) 409 | # ADD SUPPORT 410 | # self._button.connect('scroll-event', self._on_entry__scroll_event) 411 | self.entry.set_placeholder_text("Date") 412 | self.pack_start(self.entry, False, False, 0) 413 | self.entry.show() 414 | 415 | self._popup = _DateEntryPopup(self) 416 | self._popup.connect('date-selected', self._on_popup__date_selected) 417 | self._popup.connect('hide', self._on_popup__hide) 418 | self._popup.set_size_request(-1, 24) 419 | 420 | def _on_entry__icon_press(self, unused_entry, 421 | entry_icon_position, 422 | event_button): 423 | if ( 424 | entry_icon_position == Gtk.EntryIconPosition.PRIMARY and 425 | event_button.get_button() == (True, 1) 426 | ): 427 | self._popup_date_picker() 428 | return True 429 | 430 | def _on_entry__key_press_event(self, unused_window, event): 431 | keyval = event.keyval 432 | state = event.state & Gtk.accelerator_get_default_mod_mask() 433 | if ( 434 | (keyval == Gdk.KEY_Down or keyval == Gdk.KEY_KP_Down) and 435 | state == Gdk.ModifierType.MOD1_MASK 436 | ): 437 | self._popup_date_picker() 438 | return True 439 | 440 | skip_minus = "" 441 | if not self.get_date(): 442 | skip_minus = "minus" 443 | if self.entry.get_property("cursor-position") in (4, 7): 444 | skip_minus = "minus" 445 | processed, new_date = _according_to_keyval(keyval, 446 | state, 447 | self.get_date(), 448 | skip=skip_minus, 449 | in_editbox=True) 450 | if processed and new_date: 451 | self.set_date(new_date) 452 | return processed 453 | 454 | # Virtual methods 455 | 456 | def do_grab_focus(self): 457 | self.entry.grab_focus() 458 | 459 | # Callbacks 460 | 461 | def _on_entry__changed(self, unused_entry): 462 | try: 463 | date = self.get_date() 464 | except ValueError: 465 | date = None 466 | self._changed(date) 467 | 468 | def _on_entry__activate(self, unused_entry): 469 | self.emit('activate') 470 | 471 | def _on_entry__scroll_event(self, unused_entry, event): 472 | if event.direction == Gdk.SCROLL_UP: 473 | days = 1 474 | elif event.direction == Gdk.SCROLL_DOWN: 475 | days = -1 476 | else: 477 | return 478 | 479 | try: 480 | date = self.get_date() 481 | except ValueError: 482 | date = None 483 | 484 | if not date: 485 | newdate = datetime.date.today() 486 | else: 487 | newdate = date + datetime.timedelta(days=days) 488 | self.set_date(newdate) 489 | 490 | def _popup_date_picker(self): 491 | try: 492 | date = self.get_date() 493 | except ValueError: 494 | date = None 495 | self._popup.popup(date) 496 | 497 | def _on_popup__hide(self, popup): 498 | pass 499 | 500 | def _on_popup__date_selected(self, unused_popup, date): 501 | self.set_date(date) 502 | self.entry.grab_focus() 503 | self.entry.set_position(len(self.entry.get_text())) 504 | self._changed(date) 505 | 506 | def _changed(self, date): 507 | if self._old_date != date: 508 | self.emit('changed') 509 | self._old_date = date 510 | 511 | # Public API 512 | 513 | def set_activates_default(self, val): 514 | return self.entry.set_activates_default(val) 515 | 516 | def set_date(self, date): 517 | """Sets the date. 518 | :param date: date to set 519 | :type date: a datetime.date instance or None 520 | """ 521 | if not isinstance(date, datetime.date) and date is not None: 522 | raise TypeError( 523 | "date must be a datetime.date instance or None, not %r" % ( 524 | date, 525 | ) 526 | ) 527 | 528 | if date is None: 529 | value = '' 530 | else: 531 | try: 532 | value = format_date(date, self.entry.get_text()) 533 | except ValueError: 534 | value = format_date(date, "2016-12-01") 535 | self.entry.set_text(value) 536 | 537 | def get_date(self): 538 | """Get the selected date 539 | :returns: the date. 540 | :rtype: datetime.date or None 541 | """ 542 | try: 543 | date = self.entry.get_text() 544 | date = parse_date(date) 545 | except ValueError: 546 | date = None 547 | return date 548 | 549 | def follow(self, other_calendar): 550 | self.followed = other_calendar 551 | self.followed_last_value = other_calendar.get_date() 552 | 553 | def copy_when(other_calendar, *unused_args): 554 | if ( 555 | self.get_date() == self.followed_last_value or 556 | other_calendar.get_date() > self.get_date() 557 | ): 558 | self.set_date(other_calendar.get_date()) 559 | self.followed_last_value = other_calendar.get_date() 560 | other_calendar.connect("changed", copy_when) 561 | 562 | 563 | class TestWindow(Gtk.Window): 564 | 565 | def __init__(self): 566 | Gtk.Window.__init__(self, title="Whatever") 567 | self.set_border_width(12) 568 | 569 | combobox = DateEntry() 570 | self.add(combobox) 571 | 572 | 573 | def main(): 574 | klass = TestWindow 575 | win = klass() 576 | win.connect("delete-event", Gtk.main_quit) 577 | GObject.idle_add(win.show_all) 578 | Gtk.main() 579 | 580 | 581 | if __name__ == "__main__": 582 | main() 583 | -------------------------------------------------------------------------------- /src/ledgerhelpers/diffing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import subprocess 4 | import tempfile 5 | 6 | 7 | def three_way_diff(basefilename, leftcontents, rightcontents): 8 | """Given a file which is assumed to be utf-8, two utf-8 strings, 9 | left and right, launch a three-way diff. 10 | 11 | Raises: subprocess.CalledProcessError.""" 12 | if isinstance(leftcontents, str): 13 | leftcontents = leftcontents.encode("utf-8") 14 | if isinstance(rightcontents, str): 15 | rightcontents = rightcontents.encode("utf-8") 16 | 17 | prevfile = tempfile.NamedTemporaryFile(prefix=basefilename + ".previous.") 18 | prevfile.write(leftcontents) 19 | prevfile.flush() 20 | newfile = tempfile.NamedTemporaryFile(prefix=basefilename + ".new.") 21 | newfile.write(rightcontents) 22 | newfile.flush() 23 | try: 24 | subprocess.check_call( 25 | ('meld', prevfile.name, basefilename, newfile.name) 26 | ) 27 | finally: 28 | prevfile.close() 29 | newfile.close() 30 | 31 | 32 | def two_way_diff(leftcontents, rightcontents): 33 | """Given two strings which are assumed to be utf-8, open a 34 | two-way diff view with them. 35 | 36 | Raises: subprocess.CalledProcessError.""" 37 | if isinstance(leftcontents, str): 38 | leftcontents = leftcontents.encode("utf-8") 39 | if isinstance(rightcontents, str): 40 | rightcontents = rightcontents.encode("utf-8") 41 | 42 | prevfile = tempfile.NamedTemporaryFile(prefix=".base.") 43 | prevfile.write(leftcontents) 44 | prevfile.flush() 45 | newfile = tempfile.NamedTemporaryFile(prefix=".new.") 46 | newfile.write(rightcontents) 47 | newfile.flush() 48 | try: 49 | subprocess.check_call( 50 | ('meld', prevfile.name, newfile.name) 51 | ) 52 | finally: 53 | prevfile.close() 54 | newfile.close() 55 | -------------------------------------------------------------------------------- /src/ledgerhelpers/editabletransactionview.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | from gi.repository import GObject 5 | import gi 6 | gi.require_version("Gdk", "3.0") 7 | gi.require_version("Gtk", "3.0") 8 | from gi.repository import Gdk 9 | from gi.repository import Gtk 10 | 11 | import ledgerhelpers as h 12 | import ledgerhelpers.legacy_needsledger as hln 13 | from ledgerhelpers import gui 14 | from ledgerhelpers.dateentry import DateEntry 15 | from ledgerhelpers.transactionstatebutton import TransactionStateButton 16 | 17 | 18 | class EditableTransactionView(Gtk.Grid): 19 | 20 | __gsignals__ = { 21 | 'changed': (GObject.SIGNAL_RUN_LAST, None, ()), 22 | 'payee-focus-out-event': (GObject.SIGNAL_RUN_LAST, None, ()), 23 | 'payee-changed': (GObject.SIGNAL_RUN_LAST, None, ()), 24 | } 25 | 26 | css = b""" 27 | editabletransactionview { 28 | border: 1px @borders inset; 29 | background: #fff; 30 | } 31 | 32 | editabletransactionview grid { 33 | border: none; 34 | } 35 | 36 | editabletransactionview entry { 37 | background: transparent; 38 | border: none; 39 | } 40 | 41 | editabletransactionview button { 42 | background: transparent; 43 | border: 1px solid transparent; 44 | } 45 | """ 46 | 47 | def __init__(self): 48 | gui.add_css(self.css) 49 | Gtk.Grid.__init__(self) 50 | self.set_column_spacing(0) 51 | 52 | self._postings_modified = False 53 | 54 | row = 0 55 | 56 | container = Gtk.Grid() 57 | container.set_hexpand(False) 58 | container.set_column_spacing(0) 59 | 60 | self.when = DateEntry() 61 | self.when.set_activates_default(True) 62 | self.when.set_hexpand(False) 63 | container.attach(self.when, 0, 0, 1, 1) 64 | self.when.connect("changed", self.child_changed) 65 | 66 | self.clearing_when = DateEntry() 67 | self.clearing_when.set_activates_default(True) 68 | self.clearing_when.set_hexpand(False) 69 | self.clearing_when.follow(self.when) 70 | container.attach(self.clearing_when, 1, 0, 1, 1) 71 | self.clearing_when.connect("changed", self.child_changed) 72 | 73 | self.clearing = TransactionStateButton() 74 | container.attach(self.clearing, 2, 0, 1, 1) 75 | self.clearing.connect("clicked", self.child_changed) 76 | 77 | self.payee = gui.EagerCompletingEntry() 78 | self.payee.set_hexpand(True) 79 | self.payee.set_activates_default(True) 80 | self.payee.set_size_request(300, -1) 81 | self.payee.set_placeholder_text( 82 | "Payee or description (type for completion)" 83 | ) 84 | container.attach(self.payee, 3, 0, 1, 1) 85 | self.payee.connect("changed", self.payee_changed) 86 | self.payee.connect("changed", self.child_changed) 87 | self.payee.connect("focus-out-event", self.payee_focused_out) 88 | 89 | container.set_focus_chain( 90 | [self.when, self.clearing_when, self.clearing, self.payee] 91 | ) 92 | 93 | container_evbox = Gtk.EventBox() 94 | container_evbox.add(container) 95 | self.attach(container_evbox, 0, row, 1, 1) 96 | 97 | row += 1 98 | 99 | self.lines_grid = Gtk.Grid() 100 | self.lines_grid.set_column_spacing(0) 101 | 102 | lines_evbox = Gtk.EventBox() 103 | lines_evbox.add(self.lines_grid) 104 | self.attach(lines_evbox, 0, row, 1, 1) 105 | 106 | self.lines = [] 107 | self.accounts_for_completion = Gtk.ListStore(GObject.TYPE_STRING) 108 | self.payees_for_completion = Gtk.ListStore(GObject.TYPE_STRING) 109 | self.add_line() 110 | 111 | for x in container_evbox, lines_evbox: 112 | x.connect("key-press-event", self.handle_keypresses) 113 | x.add_events(Gdk.EventMask.KEY_PRESS_MASK) 114 | 115 | def handle_keypresses(self, obj, ev): 116 | if ( 117 | ev.state & Gdk.ModifierType.CONTROL_MASK and 118 | (ev.keyval in ( 119 | Gdk.KEY_plus, Gdk.KEY_KP_Add, 120 | Gdk.KEY_equal, Gdk.KEY_KP_Equal, 121 | Gdk.KEY_minus, Gdk.KEY_KP_Subtract, Gdk.KEY_underscore, 122 | Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up, 123 | Gdk.KEY_Page_Down, Gdk.KEY_KP_Page_Down, 124 | )) 125 | ): 126 | if ev.state & Gdk.ModifierType.SHIFT_MASK: 127 | return self.clearing_when._on_entry__key_press_event(obj, ev) 128 | else: 129 | return self.when._on_entry__key_press_event(obj, ev) 130 | 131 | if (ev.state & Gdk.ModifierType.MOD1_MASK): 132 | keybobjects = { 133 | Gdk.KEY_t: self.when, 134 | Gdk.KEY_l: self.clearing_when, 135 | Gdk.KEY_p: self.payee, 136 | } 137 | for keyval, obj in list(keybobjects.items()): 138 | if ev.keyval == keyval: 139 | obj.grab_focus() 140 | return True 141 | 142 | def set_transaction_date(self, date): 143 | self.when.set_date(date) 144 | 145 | def set_accounts_for_completion(self, account_list): 146 | accounts = Gtk.ListStore(GObject.TYPE_STRING) 147 | [accounts.append((str(a),)) for a in account_list] 148 | for account, unused_amount in self.lines: 149 | account.get_completion().set_model(accounts) 150 | self.accounts_for_completion = accounts 151 | 152 | def set_payees_for_completion(self, payees_list): 153 | payees = Gtk.ListStore(GObject.TYPE_STRING) 154 | [payees.append((a,)) for a in payees_list] 155 | self.payee.get_completion().set_model(payees) 156 | self.payees_for_completion = payees 157 | 158 | def handle_data_changes(self, widget, unused_eventfocus): 159 | numlines = len(self.lines) 160 | for n, (account, amount) in reversed(list(enumerate(self.lines))): 161 | if n + 1 == numlines: 162 | continue 163 | p = amount.get_amount_and_price_formatted() 164 | if not account.get_text().strip() and not p: 165 | self.remove_line(n) 166 | last_account = self.lines[-1][0] 167 | last_amount = self.lines[-1][1] 168 | a, p = last_amount.get_amount_and_price() 169 | if (a or p) and last_account.get_text().strip(): 170 | self.add_line() 171 | acctswidgets = dict((w[0], n) for n, w in enumerate(self.lines)) 172 | if widget in acctswidgets: 173 | # If the account in the account widget has an associated 174 | # default commodity, then we set the default commodity of the 175 | # amount widget to the commodity for the account. 176 | account = widget.get_text().strip() 177 | amountwidget = self.lines[acctswidgets[widget]][1] 178 | c = self._get_default_commodity(account) 179 | if c: 180 | amountwidget.set_default_commodity(c) 181 | amtwidgets = dict((w[1], n) for n, w in enumerate(self.lines)) 182 | if widget in amtwidgets: 183 | # If the amount widget has an amount, and the account associated 184 | # with it does not have a default commodity, and the widget itself 185 | # has a default commodity that differs from its current amount's 186 | # commodity, then we set the default commodity of the amount widget 187 | # to match the commodity of the amount entered in it. 188 | # This is extremely useful when clients of this data entry grid 189 | # are autofilling this grid, but haven't yet given us a default 190 | # commodity getter to discover account commodities. 191 | if widget.get_amount(): 192 | currdef = widget.get_default_commodity() 193 | currcom = widget.get_amount().commodity 194 | accountwidget = self.lines[amtwidgets[widget]][0] 195 | account = accountwidget.get_text().strip() 196 | c = self._get_default_commodity(account) 197 | if not c and str(currdef) != str(currcom): 198 | widget.set_default_commodity(currcom) 199 | if widget in [x[0] for x in self.lines] + [x[1] for x in self.lines]: 200 | self._postings_modified = True 201 | 202 | def set_default_commodity_getter(self, getter): 203 | """Records the new commodity getter. 204 | 205 | A getter is a callable that takes one account name and returns 206 | one commodity to be used as default for that account. If the 207 | getter cannot find a default commodity, it must return None. 208 | """ 209 | self._default_commodity_getter = getter 210 | for line in self.lines: 211 | accountwidget = line[0] 212 | amountwidget = line[1] 213 | account = accountwidget.get_text().strip() 214 | c = self._get_default_commodity(account) 215 | if c: 216 | amountwidget.set_default_commodity(c) 217 | 218 | def _get_default_commodity(self, account_name): 219 | getter = getattr(self, "_default_commodity_getter", None) 220 | if getter: 221 | return getter(account_name) 222 | 223 | def child_changed(self, w, unused=None): 224 | self.handle_data_changes(w, None) 225 | self.emit("changed") 226 | 227 | def payee_changed(self, unused_w, unused=None): 228 | self.emit("payee-changed") 229 | 230 | def payee_focused_out(self, unused_w, unused=None): 231 | self.emit("payee-focus-out-event") 232 | 233 | def get_payee_text(self): 234 | return self.payee.get_text() 235 | 236 | def remove_line(self, number): 237 | account, amount = self.lines[number] 238 | account_is_focus = account.is_focus() 239 | amount_is_focus = amount.is_focus() 240 | for hid in account._handler_ids: 241 | account.disconnect(hid) 242 | for hid in amount._handler_ids: 243 | amount.disconnect(hid) 244 | self.lines.pop(number) 245 | self.lines_grid.remove_row(number) 246 | try: 247 | account, amount = self.lines[number] 248 | except IndexError: 249 | account, amount = self.lines[number - 1] 250 | if account_is_focus: 251 | account.grab_focus() 252 | if amount_is_focus: 253 | amount.grab_focus() 254 | 255 | def postings_modified(self): 256 | return self._postings_modified 257 | 258 | def postings_empty(self): 259 | return ( 260 | len(self.lines) < 2 and 261 | not self.lines[0][0].get_text() and 262 | not self.lines[0][0].get_text() 263 | ) 264 | 265 | def _clear_postings(self): 266 | while len(self.lines) > 1: 267 | self.remove_line(0) 268 | self.lines[0][0].set_text("") 269 | self.lines[0][1].set_amount_and_price(None, None) 270 | 271 | def clear(self): 272 | self._clear_postings() 273 | self.payee.set_text("") 274 | self._postings_modified = False 275 | 276 | def set_clearing(self, clearingstate): 277 | self.clearing.set_state(clearingstate) 278 | 279 | def replace_postings(self, transactionpostings): 280 | """Replace postings with a list of TransactionPosting.""" 281 | self._clear_postings() 282 | for n, tp in enumerate(transactionpostings): 283 | self.add_line() 284 | self.lines[n][0].set_text(tp.account) 285 | self.lines[n][1].set_text(tp.amount) 286 | self._postings_modified = False 287 | 288 | def add_line(self): 289 | account = gui.EagerCompletingEntry() 290 | account.set_hexpand(True) 291 | account.set_width_chars(40) 292 | account.set_activates_default(True) 293 | account.get_completion().set_model(self.accounts_for_completion) 294 | account.set_placeholder_text( 295 | "Account (type for completion)" 296 | ) 297 | hid3 = account.connect("changed", self.child_changed) 298 | account._handler_ids = [hid3] 299 | 300 | amount = gui.LedgerAmountWithPriceEntry(display=False) 301 | amount.set_activates_default(True) 302 | amount.set_placeholder_text("Amount") 303 | hid3 = amount.connect("changed", self.child_changed) 304 | amount._handler_ids = [hid3] 305 | 306 | row = len(self.lines) 307 | 308 | if amount.display: 309 | amount.remove(amount.display) 310 | self.lines_grid.attach(amount.display, 0, row, 1, 1) 311 | amount.remove(amount.entry) 312 | self.lines_grid.attach(amount.entry, 1, row, 1, 1) 313 | else: 314 | amount.remove(amount.entry) 315 | self.lines_grid.attach(amount.entry, 0, row, 1, 1) 316 | self.lines_grid.attach(account, 2, row, 1, 1) 317 | 318 | account.show() 319 | amount.show() 320 | 321 | self.lines.append((account, amount)) 322 | 323 | def title_grab_focus(self): 324 | self.payee.grab_focus() 325 | 326 | def lines_grab_focus(self): 327 | for account, amount in self.lines: 328 | if not account.get_text().strip(): 329 | account.grab_focus() 330 | return 331 | if not amount.get_amount_and_price_formatted(): 332 | amount.grab_focus() 333 | return 334 | else: 335 | if self.lines: 336 | self.lines[0][1].grab_focus() 337 | pass 338 | 339 | def get_data_for_transaction_record(self): 340 | title = self.payee.get_text().strip() 341 | date = self.when.get_date() 342 | clearing_state = self.clearing.get_state_char() 343 | clearing_when = self.clearing_when.get_date() 344 | 345 | def get_entries(): 346 | entries = [] 347 | for account, amount in self.lines: 348 | account = account.get_text().strip() 349 | p = amount.get_amount_and_price_formatted() 350 | if account or p: 351 | entries.append((account, p)) 352 | return entries 353 | 354 | accountamounts = [(x, y) for x, y in get_entries()] 355 | return title, date, clearing_when, clearing_state, accountamounts 356 | 357 | def validate(self, grab_focus=False): 358 | """Raises ValidationError if the transaction is not valid.""" 359 | title, date, auxdate, statechar, lines = ( 360 | self.get_data_for_transaction_record() 361 | ) 362 | if not title: 363 | if grab_focus: 364 | self.payee.grab_focus() 365 | raise h.TransactionInputValidationError( 366 | "Transaction title cannot be empty" 367 | ) 368 | if len(lines) < 2: 369 | if grab_focus: 370 | self.lines_grab_focus() 371 | raise h.TransactionInputValidationError( 372 | "Enter at least two transaction entries" 373 | ) 374 | try: 375 | hln.generate_record(title, date, auxdate, statechar, lines, 376 | validate=True) 377 | except hln.LedgerParseError as e: 378 | raise h.TransactionInputValidationError(str(e)) 379 | 380 | 381 | EditableTransactionView.set_css_name("editabletransactionview") 382 | -------------------------------------------------------------------------------- /src/ledgerhelpers/gui.py: -------------------------------------------------------------------------------- 1 | import ledger 2 | import ledgerhelpers 3 | import ledgerhelpers.legacy as hl 4 | import ledgerhelpers.legacy_needsledger as hln 5 | import os 6 | import sys 7 | import threading 8 | 9 | import gi 10 | gi.require_version("Gdk", "3.0") 11 | gi.require_version("Gtk", "3.0") 12 | from gi.repository import GObject 13 | from gi.repository import Gdk 14 | from gi.repository import Gtk 15 | from gi.repository import Pango 16 | 17 | 18 | EVENT_TAB = 65289 19 | EVENT_SHIFTTAB = 65056 20 | EVENT_ESCAPE = 65307 21 | 22 | 23 | _css_adjusted = {} 24 | 25 | 26 | def g_async(func, success_func, failure_func): 27 | def f(): 28 | try: 29 | GObject.idle_add(success_func, func()) 30 | except BaseException as e: 31 | GObject.idle_add(failure_func, e) 32 | t = threading.Thread(target=f) 33 | t.setDaemon(True) 34 | t.start() 35 | return t 36 | 37 | 38 | def add_css(css): 39 | # Must only ever be called at runtime, not at import time. 40 | global _css_adjusted 41 | if css not in _css_adjusted: 42 | style_provider = Gtk.CssProvider() 43 | if isinstance(css, str): 44 | css = css.encode("utf-8") 45 | style_provider.load_from_data(css) 46 | Gtk.StyleContext.add_provider_for_screen( 47 | Gdk.Screen.get_default(), 48 | style_provider, 49 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 50 | ) 51 | _css_adjusted[css] = True 52 | 53 | 54 | def FatalError(message, secondary=None, parent=None): 55 | d = Gtk.MessageDialog( 56 | parent, 57 | Gtk.DialogFlags.DESTROY_WITH_PARENT, 58 | Gtk.MessageType.ERROR, 59 | Gtk.ButtonsType.CLOSE, 60 | message, 61 | ) 62 | if secondary: 63 | d.format_secondary_text(secondary) 64 | d.run() 65 | 66 | 67 | def cannot_start_dialog(msg): 68 | return FatalError("Cannot start program", msg) 69 | 70 | 71 | class EagerCompletion(Gtk.EntryCompletion): 72 | """Completion class that substring matches within a builtin ListStore.""" 73 | 74 | def __init__(self, *args): 75 | Gtk.EntryCompletion.__init__(self, *args) 76 | self.set_model(Gtk.ListStore(GObject.TYPE_STRING)) 77 | self.set_match_func(self.iter_points_to_matching_entry) 78 | self.set_text_column(0) 79 | self.set_inline_completion(True) 80 | 81 | def iter_points_to_matching_entry(self, unused_c, k, i, unused=None): 82 | model = self.get_model() 83 | acc = model.get(i, 0)[0].lower() 84 | if k.lower() in acc: 85 | return True 86 | return False 87 | 88 | 89 | def load_journal_and_settings_for_gui(price_file_mandatory=False, 90 | ledger_file=None, 91 | price_file=None): 92 | try: 93 | ledger_file = ledgerhelpers.find_ledger_file(ledger_file) 94 | except Exception as e: 95 | cannot_start_dialog(str(e)) 96 | sys.exit(4) 97 | try: 98 | price_file = ledgerhelpers.find_ledger_price_file(price_file) 99 | except ledgerhelpers.LedgerConfigurationError as e: 100 | if price_file_mandatory: 101 | cannot_start_dialog(str(e)) 102 | sys.exit(4) 103 | else: 104 | price_file = None 105 | except Exception as e: 106 | cannot_start_dialog(str(e)) 107 | sys.exit(4) 108 | try: 109 | from ledgerhelpers.journal import Journal 110 | journal = Journal.from_file(ledger_file, price_file) 111 | except Exception as e: 112 | cannot_start_dialog("Cannot open ledger file: %s" % e) 113 | sys.exit(5) 114 | s = ledgerhelpers.Settings.load_or_defaults( 115 | os.path.expanduser("~/.ledgerhelpers.ini") 116 | ) 117 | return journal, s 118 | 119 | 120 | def find_ledger_file_for_gui(): 121 | try: 122 | ledger_file = ledgerhelpers.find_ledger_file() 123 | return ledger_file 124 | except Exception as e: 125 | cannot_start_dialog(str(e)) 126 | sys.exit(4) 127 | 128 | 129 | class EagerCompletingEntry(Gtk.Entry): 130 | """Entry that substring-matches eagerly using a builtin ListStore-based 131 | Completion, and also accepts defaults. 132 | """ 133 | 134 | prevent_completion = False 135 | 136 | def __init__(self, *args): 137 | Gtk.Entry.__init__(self, *args) 138 | self.default_text = '' 139 | self.old_default_text = '' 140 | self.set_completion(EagerCompletion()) 141 | 142 | def set_default_text(self, default_text): 143 | self.old_default_text = self.default_text 144 | self.default_text = default_text 145 | if not self.get_text() or self.get_text() == self.old_default_text: 146 | self.set_text(self.default_text) 147 | 148 | 149 | class LedgerAmountEntry(Gtk.Grid): 150 | 151 | __gsignals__ = { 152 | 'changed': (GObject.SIGNAL_RUN_LAST, None, ()) 153 | } 154 | default_commodity = None 155 | 156 | def show(self): 157 | Gtk.Grid.show(self) 158 | self.entry.show() 159 | if self.display: 160 | self.display.show() 161 | 162 | def do_changed(self): 163 | pass 164 | 165 | def set_placeholder_text(self, text): 166 | self.entry.set_placeholder_text(text) 167 | 168 | def __init__(self, display=True): 169 | Gtk.Grid.__init__(self) 170 | self.amount = None 171 | self.entry = Gtk.Entry() 172 | self.entry.set_width_chars(8) 173 | if display: 174 | self.display = Gtk.Label() 175 | else: 176 | self.display = None 177 | self.entry.set_alignment(1.0) 178 | self.attach(self.entry, 0, 0, 1, 1) 179 | if self.display: 180 | self.attach(self.display, 1, 0, 1, 1) 181 | self.display.set_halign(Gtk.Align.END) 182 | self.display.set_justify(Gtk.Justification.RIGHT) 183 | self.set_column_spacing(4) 184 | self.donotreact = False 185 | self.entry.connect("changed", self.entry_changed) 186 | self.set_default_commodity(ledger.Amount("$ 1").commodity) 187 | self.set_activates_default = self.entry.set_activates_default 188 | 189 | def get_default_commodity(self): 190 | return self.default_commodity 191 | 192 | def set_default_commodity(self, commodity): 193 | if isinstance(commodity, ledger.Amount): 194 | commodity = commodity.commodity 195 | if str(self.default_commodity) != str(commodity): 196 | self.default_commodity = commodity 197 | self.entry.emit("changed") 198 | 199 | def is_focus(self): 200 | return self.entry.is_focus() 201 | 202 | def grab_focus(self): 203 | self.entry.grab_focus() 204 | 205 | def get_amount(self): 206 | return self.amount 207 | 208 | def set_amount(self, amount, skip_entry_update=False): 209 | self.amount = amount 210 | if self.display: 211 | self.display.set_text(str(amount) if amount is not None else "") 212 | self.donotreact = True 213 | if not skip_entry_update: 214 | self.entry.set_text(str(amount) if amount is not None else "") 215 | self.donotreact = False 216 | self.emit("changed") 217 | 218 | def set_text(self, text): 219 | self.entry.set_text(text) 220 | 221 | def _adjust_entry_size(self, w): 222 | text = w.get_text() 223 | w.set_width_chars(max([8, len(text)])) 224 | 225 | def entry_changed(self, w, *unused_args): 226 | self._adjust_entry_size(w) 227 | 228 | if self.donotreact: 229 | return 230 | 231 | text = self.entry.get_text() 232 | 233 | try: 234 | p = ledger.Amount(text) 235 | except ArithmeticError: 236 | self.set_amount(None, True) 237 | self.emit("changed") 238 | return 239 | 240 | if not str(p.commodity): 241 | p.commodity = self.default_commodity 242 | if str(p): 243 | self.set_amount(p, True) 244 | else: 245 | self.set_amount(None, True) 246 | 247 | self.emit("changed") 248 | 249 | 250 | class LedgerAmountWithPriceEntry(LedgerAmountEntry): 251 | 252 | def __init__(self, display=True): 253 | self.price = None 254 | LedgerAmountEntry.__init__(self, display=display) 255 | 256 | def get_amount_and_price(self): 257 | return self.amount, self.price 258 | 259 | def get_amount_and_price_formatted(self): 260 | if self.amount and self.price: 261 | return str(self.amount) + " " + self.price.strip() 262 | elif self.amount: 263 | return str(self.amount) 264 | elif self.price: 265 | return self.price 266 | else: 267 | return "" 268 | 269 | def set_amount_and_price(self, amount, price, skip_entry_update=False): 270 | self.amount = amount 271 | self.price = price 272 | p = [ 273 | str(amount if amount is not None else "").strip(), 274 | str(price if price is not None else "").strip(), 275 | ] 276 | p = [x for x in p if x] 277 | concat = " ".join(p) 278 | if self.display: 279 | self.display.set_text(concat) 280 | self.donotreact = True 281 | if not skip_entry_update: 282 | self.entry.set_text(concat) 283 | self.donotreact = False 284 | 285 | def entry_changed(self, w, *unused_args): 286 | self._adjust_entry_size(w) 287 | 288 | if self.donotreact: 289 | return 290 | 291 | text = self.entry.get_text() 292 | i = text.find("@") 293 | if i != -1: 294 | price = text[i:] if text[i:] else None 295 | text = text[:i] 296 | else: 297 | price = None 298 | 299 | try: 300 | p = ledger.Amount(text) 301 | except ArithmeticError: 302 | self.set_amount_and_price(None, price, True) 303 | self.emit("changed") 304 | return 305 | 306 | if not str(p.commodity): 307 | p.commodity = self.default_commodity 308 | if str(p): 309 | self.set_amount_and_price(p, price, True) 310 | else: 311 | self.set_amount_and_price(None, price, True) 312 | 313 | self.emit("changed") 314 | 315 | 316 | class EditableTabFocusFriendlyTextView(Gtk.TextView): 317 | 318 | def __init__(self, *args): 319 | Gtk.TextView.__init__(self, *args) 320 | self.connect("key-press-event", self.handle_tab) 321 | 322 | def handle_tab(self, widget, event): 323 | if event.keyval == EVENT_TAB: 324 | widget.get_toplevel().child_focus(Gtk.DirectionType.TAB_FORWARD) 325 | return True 326 | elif event.keyval == EVENT_SHIFTTAB: 327 | widget.get_toplevel().child_focus(Gtk.DirectionType.TAB_BACKWARD) 328 | return True 329 | return False 330 | 331 | 332 | class LedgerTransactionView(Gtk.Box): 333 | 334 | css = b""" 335 | ledgertransactionview { 336 | border: 1px @borders inset; 337 | } 338 | """ 339 | 340 | def __init__(self, *args): 341 | add_css(self.css) 342 | Gtk.Box.__init__(self) 343 | self.textview = EditableTabFocusFriendlyTextView(*args) 344 | self.textview.override_font( 345 | Pango.font_description_from_string('monospace') 346 | ) 347 | self.textview.set_border_width(12) 348 | self.textview.set_hexpand(True) 349 | self.textview.set_vexpand(True) 350 | self.add(self.textview) 351 | self.textview.get_buffer().set_text( 352 | "# A live preview will appear here as you input data." 353 | ) 354 | 355 | def get_buffer(self): 356 | return self.textview.get_buffer() 357 | 358 | def generate_record(self, *args): 359 | lines = hln.generate_record(*args) 360 | self.textview.get_buffer().set_text("\n".join(lines)) 361 | 362 | 363 | LedgerTransactionView.set_css_name("ledgertransactionview") 364 | 365 | 366 | class EscapeHandlingMixin(object): 367 | 368 | escape_handling_suspended = False 369 | 370 | def activate_escape_handling(self): 371 | self.connect("key-press-event", self.handle_escape) 372 | 373 | def suspend_escape_handling(self): 374 | self.escape_handling_suspended = True 375 | 376 | def resume_escape_handling(self): 377 | self.escape_handling_suspended = False 378 | 379 | def handle_escape(self, unused_window, event, unused_user_data=None): 380 | if ( 381 | not self.escape_handling_suspended and 382 | event.keyval == EVENT_ESCAPE 383 | ): 384 | self.emit('delete-event', None) 385 | return True 386 | return False 387 | -------------------------------------------------------------------------------- /src/ledgerhelpers/journal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import collections 4 | import errno 5 | import ledger 6 | from ledgerhelpers import parser, debug_time 7 | import ledgerhelpers.legacy_needsledger as hln 8 | import logging 9 | from multiprocessing import Process, Pipe 10 | import os 11 | import threading 12 | import time 13 | 14 | 15 | UNCHANGED = "unchanged" 16 | UNCONDITIONAL = "unconditional" 17 | IFCHANGED = "ifchanged" 18 | 19 | CMD_GET_A_LCFA_C = "get_accounts_last_commodity_for_account_and_commodities" 20 | 21 | 22 | def transactions_with_payee(payee, 23 | internal_parsing_result, 24 | case_sensitive=True): 25 | """Given a payee string, and an internal_parsing() result from the 26 | journal, return the transactions with matching payee, 27 | perhaps ignoring case.""" 28 | transes = [] 29 | if not case_sensitive: 30 | payee = payee.lower() 31 | for xact in internal_parsing_result: 32 | if not hasattr(xact, "payee"): 33 | continue 34 | xact_payee = xact.payee if case_sensitive else xact.payee.lower() 35 | if xact_payee == payee: 36 | transes.append(xact) 37 | return transes 38 | 39 | 40 | class Joinable(threading.Thread): 41 | """A subclass of threading.Thread that catches exception in run(), if any, 42 | and re-throws it in join().""" 43 | exception = None 44 | 45 | def __init__(self): 46 | threading.Thread.__init__(self, daemon=True) 47 | 48 | def join(self): 49 | threading.Thread.join(self) 50 | if self.exception: 51 | raise self.exception 52 | 53 | def run(self): 54 | try: 55 | self.__run__() 56 | except BaseException as e: 57 | self.exception = e 58 | 59 | 60 | class JournalCommon(): 61 | 62 | path = None 63 | path_mtime = None 64 | price_path = None 65 | price_path_mtime = None 66 | 67 | def changed(self): 68 | path_mtime = None 69 | price_path_mtime = None 70 | 71 | try: 72 | path_mtime = os.stat(self.path).st_mtime 73 | except OSError as e: 74 | if e.errno != errno.ENOENT: 75 | raise 76 | try: 77 | if self.price_path is not None: 78 | price_path_mtime = os.stat(self.price_path).st_mtime 79 | except OSError as e: 80 | if e.errno != errno.ENOENT: 81 | raise 82 | 83 | if ( 84 | path_mtime != self.path_mtime or 85 | price_path_mtime != self.price_path_mtime 86 | ): 87 | self.path_mtime = path_mtime 88 | self.price_path_mtime = price_path_mtime 89 | self.logger.debug("Files have changed, rereading.") 90 | return True 91 | else: 92 | return False 93 | 94 | def _get_text(self, prepend_price_path=False): 95 | files = [] 96 | if self.price_path and prepend_price_path: 97 | files.append(self.price_path) 98 | if self.path: 99 | files.append(self.path) 100 | t = [] 101 | for f in files: 102 | with open(f, "r") as fo: 103 | t.append(fo.read()) 104 | text = "\n".join(t) 105 | self.logger.debug("Read %d characters of journal%s.", len(text), " and price file" if len(files) > 1 else "") 106 | return text 107 | 108 | def get_journal_text_with_prices(self): 109 | return self._get_text(True) 110 | 111 | def get_journal_text(self): 112 | return self._get_text(False) 113 | 114 | 115 | class Journal(JournalCommon): 116 | 117 | logger = logging.getLogger("journal.master") 118 | 119 | pipe = None 120 | slave = None 121 | slave_lock = None 122 | cache = None 123 | internal_parsing_cache = None 124 | internal_parsing_cache_lock = None 125 | 126 | def __init__(self): 127 | """Do not instantiate directly. Use class methods.""" 128 | self.cache = {} 129 | self.internal_parsing_cache = [] 130 | self.internal_parsing_cache_lock = threading.Lock() 131 | self.slave_lock = threading.Lock() 132 | 133 | def _start_slave(self): 134 | if self.pipe: 135 | self.pipe.close() 136 | self.pipe, theirconn = Pipe() 137 | if self.slave: 138 | try: 139 | self.slave.terminate() 140 | except Exception: 141 | pass 142 | try: 143 | self.slave = JournalSlave(theirconn, self.path, self.price_path) 144 | self.slave.start() 145 | finally: 146 | theirconn.close() 147 | 148 | @classmethod 149 | def from_file(klass, journal_file, price_file): 150 | j = klass() 151 | j.path = journal_file 152 | j.price_path = price_file 153 | j._start_slave() 154 | j._cache_internal_parsing() 155 | return j 156 | 157 | def _cache_internal_parsing(self): 158 | self.internal_parsing_cache_lock.acquire() 159 | 160 | if self.changed(): 161 | me = self 162 | self.internal_parsing_cache = None 163 | 164 | class Rpi(Joinable): 165 | @debug_time(self.logger) 166 | def __run__(self): 167 | try: 168 | me.logger.debug("Reparsing internal.") 169 | res = parser.lex_ledger_file_contents(me.get_journal_text()) 170 | me.internal_parsing_cache = res 171 | finally: 172 | me.internal_parsing_cache_lock.release() 173 | 174 | internal_parsing_thread = Rpi() 175 | internal_parsing_thread.name = "Internal reparser" 176 | internal_parsing_thread.start() 177 | return internal_parsing_thread 178 | else: 179 | # Dummy thread. Just serves to unlock the parsing cache lock. 180 | nothread = threading.Thread(target=self.internal_parsing_cache_lock.release) 181 | nothread.start() 182 | return nothread 183 | 184 | def _cache_accounts_last_commodity_for_account_and_commodities(self): 185 | with self.slave_lock: 186 | try: 187 | self.pipe.send( 188 | (CMD_GET_A_LCFA_C, 189 | IFCHANGED if "accounts" in self.cache 190 | else UNCONDITIONAL) 191 | ) 192 | result = self.pipe.recv() 193 | if isinstance(result, BaseException): 194 | raise result 195 | if result == UNCHANGED: 196 | assert "accounts" in self.cache 197 | else: 198 | accounts = result[0] if result[0] is not None else [] 199 | last_commodity_for_account = dict( 200 | (acc, ledger.Amount(amt)) 201 | for acc, amt in list(result[1].items()) 202 | ) if result[1] is not None else {} 203 | all_commodities = [ 204 | ledger.Amount(c) 205 | for c in result[2] 206 | ] if result[2] is not None else [] 207 | self.cache["accounts"] = accounts 208 | self.cache["last_commodity_for_account"] = ( 209 | last_commodity_for_account 210 | ) 211 | self.cache["all_commodities"] = all_commodities 212 | except BaseException: 213 | self.cache = {} 214 | self._start_slave() 215 | raise 216 | 217 | @debug_time(logger) 218 | def accounts_and_last_commodity_for_account(self): 219 | self._cache_accounts_last_commodity_for_account_and_commodities() 220 | return self.cache["accounts"], self.cache["last_commodity_for_account"] 221 | 222 | @debug_time(logger) 223 | def commodities(self): 224 | self._cache_accounts_last_commodity_for_account_and_commodities() 225 | return self.cache["all_commodities"] 226 | 227 | def commodity(self, label, create=False): 228 | pool = ledger.Amount("$ 1").commodity.pool() 229 | if create: 230 | return pool.find_or_create(label) 231 | else: 232 | return pool.find(label) 233 | 234 | @debug_time(logger) 235 | def all_payees(self): 236 | """Returns a list of strings with payees (transaction titles).""" 237 | titles = collections.OrderedDict() 238 | for xact in self.internal_parsing(): 239 | if hasattr(xact, "payee") and xact.payee not in titles: 240 | titles[xact.payee] = xact.payee 241 | return list(titles.keys()) 242 | 243 | @debug_time(logger) 244 | def internal_parsing(self): 245 | self._cache_internal_parsing().join() 246 | with self.internal_parsing_cache_lock: 247 | return self.internal_parsing_cache 248 | 249 | def generate_record(self, *args): 250 | return hln.generate_record(*args) 251 | 252 | def generate_price_records(self, prices): 253 | from ledgerhelpers import generate_price_records 254 | return generate_price_records(prices) 255 | 256 | def _add_text_to_file(self, text, f): 257 | if not isinstance(text, str): 258 | text = "\n".join(text) 259 | f = open(f, "a") 260 | print(text, file=f) 261 | f.flush() 262 | f.close() 263 | 264 | def add_text_to_file(self, text): 265 | return self._add_text_to_file(text, self.path) 266 | 267 | def add_text_to_price_file(self, text): 268 | return self._add_text_to_file(text, self.price_path) 269 | 270 | 271 | class JournalSlave(JournalCommon, Process): 272 | 273 | session = None 274 | journal = None 275 | accounts = None 276 | last_commodity_for_account = None 277 | all_commodities = None 278 | logger = logging.getLogger("journal.slave") 279 | 280 | def __init__(self, pipe, path, price_path): 281 | Process.__init__(self) 282 | self.daemon = True 283 | self.pipe = pipe 284 | self.path = path 285 | self.price_path = price_path 286 | self.clear_caches() 287 | 288 | def clear_caches(self): 289 | self.session = None 290 | self.journal = None 291 | self.accounts = None 292 | self.last_commodity_for_account = None 293 | self.all_commodities = None 294 | 295 | def reparse_ledger(self): 296 | self.logger.debug("Reparsing ledger.") 297 | session = ledger.Session() 298 | journal = session.read_journal_from_string(self.get_journal_text_with_prices()) 299 | self.session = session 300 | self.journal = journal 301 | 302 | def harvest_accounts_and_last_commodities(self): 303 | self.logger.debug("Harvesting accounts and last commodities.") 304 | # Commodities returned by this method do not contain any annotations. 305 | accts = [] 306 | commos = dict() 307 | amts = dict() 308 | for post in self.journal.query(""): 309 | for subpost in post.xact.posts(): 310 | if str(subpost.account) not in accts: 311 | accts.append(str(subpost.account)) 312 | comm = ledger.Amount(1).with_commodity(subpost.amount.commodity) 313 | comm.commodity = comm.commodity.strip_annotations() 314 | commos[str(subpost.account)] = str(comm) 315 | amts[str(comm)] = True 316 | self.accounts = accts 317 | self.last_commodity_for_account = commos 318 | self.all_commodities = [str(k) for k in list(amts.keys())] 319 | 320 | def reparse_all_if_needed(self): 321 | me = self 322 | 323 | changed = self.changed() 324 | if changed: 325 | self.clear_caches() 326 | 327 | class Rpl(Joinable): 328 | @debug_time(self.logger) 329 | def __run__(self): 330 | me.reparse_ledger() 331 | me.harvest_accounts_and_last_commodities() 332 | 333 | ledger_parsing_thread = Rpl() 334 | ledger_parsing_thread.name = "Ledger reparser" 335 | 336 | else: 337 | ledger_parsing_thread = threading.Thread(target=len, args=([],)) 338 | ledger_parsing_thread.name = "Dummy ledger reparser" 339 | 340 | ledger_parsing_thread.start() 341 | 342 | return ( 343 | changed, ledger_parsing_thread 344 | ) 345 | 346 | def run(self): 347 | logger = logging.getLogger("journal.slave.loop") 348 | _, initial_parsing_thread = self.reparse_all_if_needed() 349 | while True: 350 | cmd_args = self.pipe.recv() 351 | if initial_parsing_thread: 352 | initial_parsing_thread.join() 353 | initial_parsing_thread = None 354 | cmd = cmd_args[0] 355 | start = time.time() 356 | logger.debug("* Servicing: %-55s started", cmd) 357 | args = cmd_args[1:] 358 | try: 359 | changed, lpt = self.reparse_all_if_needed() 360 | if cmd == CMD_GET_A_LCFA_C: 361 | if ( 362 | not changed and 363 | args[0] == IFCHANGED and 364 | self.journal 365 | ): 366 | logger.debug("* Serviced: %-55s %.3f seconds - %s", 367 | cmd, time.time() - start, UNCHANGED) 368 | self.pipe.send(UNCHANGED) 369 | continue 370 | lpt.join() 371 | logger.debug("* Serviced: %-55s %.3f seconds - new data", 372 | cmd, time.time() - start) 373 | self.pipe.send(( 374 | self.accounts, 375 | self.last_commodity_for_account, 376 | self.all_commodities, 377 | )) 378 | else: 379 | assert 0, "not reached" 380 | except BaseException as e: 381 | logger.exception("Unrecoverable error in slave.") 382 | self.pipe.send(e) 383 | -------------------------------------------------------------------------------- /src/ledgerhelpers/legacy.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import fcntl 3 | import struct 4 | import termios 5 | import tty 6 | 7 | 8 | CURSOR_UP = "\033[F" 9 | QUIT = "quit" 10 | 11 | 12 | yes_chars = "yY\n\r" 13 | no_chars = "nN\x7f" 14 | yesno_choices = dict() 15 | for char in yes_chars: 16 | yesno_choices[char] = True 17 | for char in no_chars: 18 | yesno_choices[char] = False 19 | del char 20 | del yes_chars 21 | del no_chars 22 | 23 | 24 | class Escaped(KeyboardInterrupt): pass 25 | 26 | 27 | def go_cursor_up(fd): 28 | fd.write(CURSOR_UP) 29 | 30 | 31 | def blank_line(fd, chars): 32 | fd.write(" " * chars) 33 | print() 34 | 35 | 36 | def read_one_character(from_): 37 | old_settings = termios.tcgetattr(from_.fileno()) 38 | try: 39 | tty.setraw(from_.fileno()) 40 | char = from_.read(1) 41 | if char == "\x1b": 42 | raise Escaped() 43 | if char == "\x03": 44 | raise KeyboardInterrupt() 45 | finally: 46 | termios.tcsetattr(from_.fileno(), termios.TCSADRAIN, old_settings) 47 | return char 48 | 49 | 50 | def print_line_ellipsized(fileobj, maxlen, text): 51 | if len(text) > maxlen: 52 | text = text[:maxlen] 53 | fileobj.write(text) 54 | fileobj.flush() 55 | print() 56 | 57 | 58 | def get_terminal_size(fd): 59 | def ioctl_GWINSZ(fd): 60 | return struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234')) 61 | return ioctl_GWINSZ(fd) 62 | 63 | 64 | def get_terminal_width(fd): 65 | return get_terminal_size(fd)[1] 66 | 67 | 68 | def prompt_for_account(fdin, fdout, accounts, prompt, default): 69 | cols = get_terminal_width(fdin) 70 | line = prompt + ("" if not default else " '': %s" % default) 71 | print_line_ellipsized(fdout, cols, line) 72 | x = [] 73 | match = default 74 | while True: 75 | char = read_one_character(fdin) 76 | if char in "\n\r\t": 77 | break 78 | elif char == "\x7f": 79 | if x: x.pop() 80 | else: 81 | x.append(char) 82 | inp = "".join(x) 83 | if not inp: 84 | match = default 85 | else: 86 | matches = [ a for a in accounts if inp.lower() in a.lower() ] 87 | match = matches[0] if matches else inp if inp else default 88 | cols = get_terminal_width(fdin) 89 | go_cursor_up(fdout) 90 | blank_line(fdout, cols) 91 | go_cursor_up(fdout) 92 | line = prompt + " " + "'%s': %s" % (inp, match) 93 | print_line_ellipsized(fdout, cols, line) 94 | return match 95 | 96 | 97 | def choose(fdin, fdout, prompt, map_choices): 98 | """Based on single-char input, return a value from map_choices.""" 99 | cols = get_terminal_width(fdin) 100 | line = prompt 101 | print_line_ellipsized(fdout, cols, line) 102 | while True: 103 | char = read_one_character(fdin) 104 | if char in map_choices: 105 | return map_choices[char] 106 | 107 | 108 | def yesno(fdin, fdout, prompt): 109 | """Return True upon yY or ENTER, return False upon nN or BACKSPACE.""" 110 | return choose(fdin, fdout, prompt, yesno_choices) 111 | 112 | 113 | def prompt_for_expense(prompt): 114 | return input(prompt + " ").strip() 115 | 116 | 117 | def prompt_for_date(fdin, fdout, prompt, initial, optional=False): 118 | """Return None if bool(optional) evaluates to True.""" 119 | cols = get_terminal_width(fdin) 120 | if optional: 121 | opt = "[+/- changes, n skips, ENTER/tab accepts]" 122 | else: 123 | opt = "[+/- changes, ENTER/tab accepts]" 124 | line = prompt + ("" if not initial else " %s" % initial) 125 | print_line_ellipsized(fdout, cols, line + " " + opt) 126 | while True: 127 | char = read_one_character(fdin) 128 | if char in "\n\r\t": 129 | break 130 | elif char == "+": 131 | initial = initial + datetime.timedelta(1) 132 | elif char == "-": 133 | initial = initial + datetime.timedelta(-1) 134 | elif char in "nN" and optional: 135 | return None 136 | cols = get_terminal_width(fdin) 137 | go_cursor_up(fdout) 138 | blank_line(fdout, cols) 139 | go_cursor_up(fdout) 140 | line = prompt + " " + "%s" % initial 141 | print_line_ellipsized(fdout, cols, line + " " + opt) 142 | return initial 143 | 144 | 145 | def prompt_for_date_optional(fdin, fdout, prompt, initial): 146 | return prompt_for_date(fdin, fdout, prompt, initial, True) 147 | 148 | 149 | def parse_date(putative_date, return_format=False): 150 | """Returns a date substring in a ledger entry, parsed as datetime.date.""" 151 | # FIXME: use Ledger functions to parse dates, not mine. 152 | formats = ["%Y-%m-%d", "%Y/%m/%d"] 153 | for f in formats: 154 | try: 155 | d = datetime.datetime.strptime(putative_date, f).date() 156 | break 157 | except ValueError as e: 158 | last_exception = e 159 | continue 160 | try: 161 | if return_format: 162 | return d, f 163 | else: 164 | return d 165 | except UnboundLocalError: 166 | raise ValueError("cannot parse date from format %s: %s" % (f, last_exception)) 167 | 168 | 169 | def format_date(date_obj, sample_date): 170 | _, fmt = parse_date(sample_date, True) 171 | return date_obj.strftime(fmt) 172 | 173 | 174 | def generate_record(title, date, auxdate, state, accountamounts): 175 | """Generates a transaction record. 176 | 177 | date is a datetime.date 178 | title is a string describing the title of the transaction 179 | auxdate is the date when the transaction cleared, or None 180 | statechar is a char from parser.CHAR_* or empty string 181 | accountamounts is a list of: 182 | (account, amount) 183 | """ 184 | def stramt(amt): 185 | assert type(amt) not in (tuple, list), amt 186 | if not amt: 187 | return "" 188 | return str(amt).strip() 189 | 190 | if state: 191 | state = state + " " 192 | else: 193 | state = "" 194 | 195 | lines = [""] 196 | linesemptyamts = [] 197 | if auxdate: 198 | if auxdate != date: 199 | lines.append("%s=%s %s%s" % (date, auxdate, state, title)) 200 | else: 201 | lines.append("%s %s%s" % (date, state, title)) 202 | else: 203 | lines.append("%s %s%s" % (date, state, title)) 204 | 205 | try: 206 | longest_acct = max(list(len(a) for a, _ in accountamounts)) 207 | longest_amt = max(list(len(stramt(am)) for _, am in accountamounts)) 208 | except ValueError: 209 | longest_acct = 30 210 | longest_amt = 30 211 | pattern = " %-" + str(longest_acct) + "s %" + str(longest_amt) + "s" 212 | pattern2 = " %-" + str(longest_acct) + "s" 213 | for account, amount in accountamounts: 214 | if stramt(amount): 215 | lines.append(pattern % (account, stramt(amount))) 216 | else: 217 | linesemptyamts.append((pattern2 % (account,)).rstrip()) 218 | lines = lines + linesemptyamts 219 | lines.append("") 220 | return lines 221 | -------------------------------------------------------------------------------- /src/ledgerhelpers/legacy_needsledger.py: -------------------------------------------------------------------------------- 1 | from ledgerhelpers.legacy import get_terminal_width, \ 2 | print_line_ellipsized, read_one_character, go_cursor_up, blank_line, \ 3 | generate_record as generate_record_novalidate 4 | 5 | import ledger 6 | 7 | 8 | class LedgerParseError(ValueError): 9 | pass 10 | 11 | 12 | def prompt_for_amount(fdin, fdout, prompt, commodity_example): 13 | cols = get_terminal_width(fdin) 14 | line = prompt + ("" if not commodity_example else " '': %s" % commodity_example) 15 | print_line_ellipsized(fdout, cols, line) 16 | x = [] 17 | match = commodity_example 18 | while True: 19 | char = read_one_character(fdin) 20 | if char in "\n\r\t": 21 | break 22 | elif char == "\x7f": 23 | if x: x.pop() 24 | else: 25 | x.append(char) 26 | inp = "".join(x) 27 | try: 28 | match = ledger.Amount(inp) * commodity_example 29 | except ArithmeticError: 30 | try: 31 | match = ledger.Amount(inp) 32 | except ArithmeticError: 33 | match = "" 34 | cols = get_terminal_width(fdin) 35 | go_cursor_up(fdout) 36 | blank_line(fdout, cols) 37 | go_cursor_up(fdout) 38 | line = prompt + " " + "'%s': %s" % (inp, match) 39 | print_line_ellipsized(fdout, cols, line) 40 | assert match is not None 41 | return match 42 | 43 | 44 | def generate_record(title, date, auxdate, state, accountamounts, 45 | validate=False): 46 | """Generates a transaction record. 47 | See callee. Validate uses Ledger to validate the record. 48 | """ 49 | lines = generate_record_novalidate( 50 | title, date, auxdate, 51 | state, accountamounts 52 | ) 53 | 54 | if validate: 55 | sess = ledger.Session() 56 | try: 57 | sess.read_journal_from_string("\n".join(lines)) 58 | except RuntimeError as e: 59 | lines = [x.strip() for x in str(e).splitlines() if x.strip()] 60 | lines = [x for x in lines if not x.startswith("While")] 61 | lines = [x + ("." if not x.endswith(":") else "") for x in lines] 62 | lines = " ".join(lines) 63 | if lines: 64 | raise LedgerParseError(lines) 65 | else: 66 | raise LedgerParseError("Ledger could not validate this transaction") 67 | 68 | return lines 69 | -------------------------------------------------------------------------------- /src/ledgerhelpers/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import ledgerhelpers.legacy 4 | from ledgerhelpers import diffing 5 | 6 | 7 | CHAR_ENTER = "\n" 8 | CHAR_COMMENT = ";#" 9 | CHAR_NUMBER = "1234567890" 10 | CHAR_TAB = "\t" 11 | CHAR_WHITESPACE = " \t" 12 | CHAR_CLEARED = "*" 13 | CHAR_PENDING = "!" 14 | 15 | STATE_CLEARED = CHAR_CLEARED 16 | STATE_PENDING = CHAR_PENDING 17 | STATE_UNCLEARED = None 18 | 19 | 20 | def pos_within_items_to_row_and_col(pos, items): 21 | row = 1 22 | col = 1 23 | for i, c in enumerate(items): 24 | if i >= pos: 25 | break 26 | if c in CHAR_ENTER: 27 | row += 1 28 | col = 1 29 | else: 30 | col += 1 31 | return row, col 32 | 33 | 34 | def parse_date_from_transaction_contents(contents): 35 | return ledgerhelpers.legacy.parse_date("".join(contents)) 36 | 37 | 38 | class Token(object): 39 | 40 | def __init__(self, pos, contents): 41 | self.pos = pos 42 | if not isinstance(contents, str): 43 | contents = "".join(contents) 44 | self.contents = contents 45 | 46 | def __str__(self): 47 | return """<%s at pos %d len %d 48 | %s>""" % (self.__class__.__name__, self.pos, len(self.contents), self.contents) 49 | 50 | 51 | class TokenComment(Token): 52 | pass 53 | 54 | 55 | class TokenTransactionComment(Token): 56 | pass 57 | 58 | 59 | class TokenTransactionClearedFlag(Token): 60 | pass 61 | 62 | 63 | class TokenTransactionPendingFlag(Token): 64 | pass 65 | 66 | 67 | class TokenWhitespace(Token): 68 | pass 69 | 70 | 71 | class TokenTransaction(Token): 72 | 73 | def __init__(self, pos, contents): 74 | Token.__init__(self, pos, contents) 75 | lexer = LedgerTransactionLexer(contents) 76 | lexer.run() 77 | 78 | def find_token(klass): 79 | try: 80 | return [t for t in lexer.tokens if isinstance(t, klass)][0] 81 | except IndexError: 82 | return None 83 | 84 | try: 85 | self.date = find_token(TokenTransactionDate).date 86 | except AttributeError: 87 | raise TransactionLexingError("no transaction date in transaction") 88 | 89 | try: 90 | self.secondary_date = find_token( 91 | TokenTransactionSecondaryDate 92 | ).date 93 | except AttributeError: 94 | self.secondary_date = None 95 | 96 | if find_token(TokenTransactionClearedFlag): 97 | self.state = STATE_CLEARED 98 | elif find_token(TokenTransactionPendingFlag): 99 | self.state = STATE_PENDING 100 | else: 101 | self.state = STATE_UNCLEARED 102 | 103 | if self.state != STATE_UNCLEARED: 104 | self.clearing_date = ( 105 | self.secondary_date if self.secondary_date else self.date 106 | ) 107 | else: 108 | self.clearing_date = None 109 | 110 | try: 111 | self.payee = find_token(TokenTransactionPayee).payee 112 | except AttributeError: 113 | raise TransactionLexingError("no payee in transaction") 114 | 115 | accountsamounts = [ 116 | t for t in lexer.tokens 117 | if isinstance(t, TokenTransactionPostingAccount) or 118 | isinstance(t, TokenTransactionPostingAmount) 119 | ] 120 | 121 | x = [] 122 | last = None 123 | for v in accountsamounts: 124 | if isinstance(v, TokenTransactionPostingAccount): 125 | assert type(last) in [ 126 | type(None), TokenTransactionPostingAmount 127 | ], lexer.tokens 128 | elif isinstance(v, TokenTransactionPostingAmount): 129 | assert type(last) in [ 130 | TokenTransactionPostingAccount 131 | ], lexer.tokens 132 | x.append( 133 | ledgerhelpers.TransactionPosting( 134 | last.account, v.amount 135 | ) 136 | ) 137 | last = v 138 | assert len(x) * 2 == len(accountsamounts), lexer.tokens 139 | self.postings = x 140 | 141 | 142 | class TokenTransactionWithContext(TokenTransaction): 143 | 144 | def __init__(self, pos, tokens): 145 | self.transaction = [ 146 | t for t in tokens if isinstance(t, TokenTransaction) 147 | ][0] 148 | self.pos = pos 149 | self.contents = "".join(t.contents for t in tokens) 150 | 151 | @property 152 | def date(self): 153 | return self.transaction.date 154 | 155 | 156 | class TokenConversion(Token): 157 | pass 158 | 159 | 160 | class TokenPrice(Token): 161 | pass 162 | 163 | 164 | class TokenEmbeddedPython(Token): 165 | pass 166 | 167 | 168 | class TokenTransactionPostingAccount(Token): 169 | 170 | def __init__(self, pos, contents): 171 | Token.__init__(self, pos, contents) 172 | self.account = ''.join(contents) 173 | 174 | 175 | class TokenTransactionPostingAmount(Token): 176 | 177 | def __init__(self, pos, contents): 178 | Token.__init__(self, pos, contents) 179 | self.amount = ''.join(contents) 180 | 181 | 182 | class TokenEmbeddedTag(Token): 183 | pass 184 | 185 | 186 | class TokenTransactionDate(Token): 187 | 188 | def __init__(self, pos, contents): 189 | Token.__init__(self, pos, contents) 190 | self.date = parse_date_from_transaction_contents(self.contents) 191 | 192 | 193 | class TokenTransactionSecondaryDate(Token): 194 | 195 | def __init__(self, pos, contents): 196 | Token.__init__(self, pos, contents) 197 | self.date = parse_date_from_transaction_contents(self.contents) 198 | 199 | 200 | class TokenTransactionPayee(Token): 201 | 202 | def __init__(self, pos, contents): 203 | Token.__init__(self, pos, contents) 204 | self.payee = ''.join(contents) 205 | 206 | 207 | class LexingError(Exception): 208 | pass 209 | 210 | 211 | class TransactionLexingError(Exception): 212 | pass 213 | 214 | 215 | class EOF(LexingError): 216 | pass 217 | 218 | 219 | class GenericLexer(object): 220 | 221 | def __init__(self, items): 222 | if isinstance(items, str) and not isinstance(items, str): 223 | self.items = tuple(items.decode("utf-8")) 224 | else: 225 | self.items = tuple(items) 226 | self.start = 0 227 | self.pos = 0 228 | self._last_emitted_pos = self.pos 229 | self.tokens = [] 230 | 231 | def __next__(self): 232 | """Returns the item at the current position, and advances the position.""" 233 | try: 234 | t = self.items[self.pos] 235 | except IndexError: 236 | raise EOF() 237 | self.pos += 1 238 | return t 239 | 240 | def peek(self): 241 | """Returns the item at the current position.""" 242 | try: 243 | t = self.items[self.pos] 244 | except IndexError: 245 | raise EOF() 246 | return t 247 | 248 | def confirm_next(self, seq): 249 | """Returns True if each item in seq matches each corresponding item 250 | from the current position onward.""" 251 | for n, i in enumerate(seq): 252 | try: 253 | if self.items[self.pos + n] != i: 254 | return False 255 | except IndexError: 256 | return False 257 | return True 258 | 259 | def emit(self, klass, items): 260 | """Creates an instance of klass (a Token class) with the current 261 | position and the supplied items as parameters, then 262 | accumulates the instance into the self.tokens accumulator.""" 263 | token = klass(self.pos, items) 264 | self._last_emitted_pos = self.pos 265 | self.tokens += [token] 266 | 267 | def more(self): 268 | return self.pos < len(self.items) 269 | 270 | 271 | class LedgerTextLexer(GenericLexer): 272 | 273 | def __init__(self, text): 274 | assert isinstance(text, str), type(text) 275 | GenericLexer.__init__(self, text) 276 | 277 | def state_parsing_toplevel_text(self): 278 | """Returns another state function.""" 279 | chars = [] 280 | while self.more(): 281 | if self.peek() in CHAR_COMMENT: 282 | self.emit(TokenWhitespace, chars) 283 | return self.state_parsing_comment 284 | if self.peek() in CHAR_NUMBER: 285 | self.emit(TokenWhitespace, chars) 286 | return self.state_parsing_transaction 287 | if self.confirm_next("P"): 288 | self.emit(TokenWhitespace, chars) 289 | return self.state_parsing_price 290 | if self.confirm_next("C"): 291 | self.emit(TokenWhitespace, chars) 292 | return self.state_parsing_conversion 293 | if self.confirm_next("python"): 294 | self.emit(TokenWhitespace, chars) 295 | return self.state_parsing_embedded_python 296 | if self.confirm_next("tag"): 297 | self.emit(TokenWhitespace, chars) 298 | return self.state_parsing_embedded_tag 299 | if self.peek() not in CHAR_WHITESPACE + CHAR_ENTER: 300 | _, _, l2, c2 = self._coords() 301 | raise LexingError( 302 | "unparsable data at line %d, char %d" % (l2, c2) 303 | ) 304 | chars += [next(self)] 305 | self.emit(TokenWhitespace, chars) 306 | return 307 | 308 | def state_parsing_comment(self): 309 | chars = [next(self)] 310 | while self.more(): 311 | if chars[-1] in CHAR_ENTER and self.peek() not in CHAR_COMMENT: 312 | break 313 | chars.append(next(self)) 314 | self.emit(TokenComment, chars) 315 | return self.state_parsing_toplevel_text 316 | 317 | def state_parsing_price(self): 318 | return self.state_parsing_embedded_directive(TokenPrice, False) 319 | 320 | def state_parsing_conversion(self): 321 | return self.state_parsing_embedded_directive(TokenConversion, False) 322 | 323 | def state_parsing_embedded_tag(self): 324 | return self.state_parsing_embedded_directive(TokenEmbeddedTag) 325 | 326 | def state_parsing_embedded_python(self): 327 | return self.state_parsing_embedded_directive(TokenEmbeddedPython) 328 | 329 | def state_parsing_embedded_directive(self, klass, maybe_multiline=True): 330 | chars = [next(self)] 331 | while self.more(): 332 | if chars[-1] in CHAR_ENTER: 333 | if not maybe_multiline: 334 | break 335 | if self.peek() in CHAR_WHITESPACE + CHAR_ENTER: 336 | chars.append(next(self)) 337 | continue 338 | if self.peek() in CHAR_COMMENT: 339 | self.emit(klass, chars) 340 | return self.state_parsing_comment 341 | if self.peek() in CHAR_NUMBER: 342 | self.emit(klass, chars) 343 | return self.state_parsing_transaction 344 | self.emit(klass, chars) 345 | return self.state_parsing_toplevel_text 346 | chars.append(next(self)) 347 | self.emit(klass, chars) 348 | return self.state_parsing_toplevel_text 349 | 350 | def state_parsing_transaction(self): 351 | chars = [next(self)] 352 | while self.more(): 353 | if chars[-1] in CHAR_ENTER and self.peek() not in CHAR_WHITESPACE: 354 | break 355 | chars.append(next(self)) 356 | self.emit(TokenTransaction, chars) 357 | return self.state_parsing_toplevel_text 358 | 359 | def _coords(self): 360 | r, c = pos_within_items_to_row_and_col(self._last_emitted_pos, self.items) 361 | r2, c2 = pos_within_items_to_row_and_col(self.pos, self.items) 362 | return r, c, r2, c2 363 | 364 | def run(self): 365 | state = self.state_parsing_toplevel_text 366 | while state: 367 | try: 368 | state = state() 369 | except LexingError: 370 | raise 371 | except Exception as e: 372 | l, c, l2, c2 = self._coords() 373 | raise LexingError( 374 | "bad ledger data between line %d, char %d and line %d, char %d: %s" % ( 375 | l, c, l2, c2, e 376 | ) 377 | ) 378 | 379 | 380 | class LedgerTransactionLexer(GenericLexer): 381 | 382 | def __init__(self, text): 383 | GenericLexer.__init__(self, text) 384 | 385 | def state_parsing_transaction_date(self): 386 | chars = [] 387 | while self.more(): 388 | if self.peek() not in "0123456789-/": 389 | self.emit(TokenTransactionDate, chars) 390 | if self.confirm_next("="): 391 | next(self) 392 | return self.state_parsing_clearing_date 393 | elif self.peek() in CHAR_WHITESPACE: 394 | return self.state_parsing_cleared_flag_or_payee 395 | else: 396 | raise TransactionLexingError("invalid character %s" % self.peek()) 397 | chars += [next(self)] 398 | raise TransactionLexingError("incomplete transaction") 399 | 400 | def state_parsing_clearing_date(self): 401 | chars = [] 402 | while self.more(): 403 | if self.peek() not in "0123456789-/": 404 | next(self) 405 | self.emit(TokenTransactionSecondaryDate, chars) 406 | return self.state_parsing_cleared_flag_or_payee 407 | chars += [next(self)] 408 | raise TransactionLexingError("incomplete transaction") 409 | 410 | def state_parsing_cleared_flag_or_payee(self): 411 | while self.more(): 412 | if self.peek() in CHAR_WHITESPACE: 413 | next(self) 414 | continue 415 | if self.peek() in CHAR_ENTER: 416 | break 417 | if self.confirm_next(CHAR_CLEARED): 418 | self.emit(TokenTransactionClearedFlag, [next(self)]) 419 | return self.state_parsing_payee 420 | if self.confirm_next(CHAR_PENDING): 421 | self.emit(TokenTransactionPendingFlag, [next(self)]) 422 | return self.state_parsing_payee 423 | return self.state_parsing_payee 424 | raise TransactionLexingError("incomplete transaction") 425 | 426 | def state_parsing_payee(self): 427 | return self.state_parsing_rest_of_line( 428 | TokenTransactionPayee, 429 | self.state_parsing_transaction_posting_indentation) 430 | 431 | def state_parsing_rest_of_line( 432 | self, 433 | klass, next_state, 434 | allow_empty_values=False 435 | ): 436 | chars = [] 437 | while self.more(): 438 | if self.peek() in CHAR_ENTER: 439 | next(self) 440 | while chars and chars[-1] in CHAR_WHITESPACE: 441 | chars = chars[:-1] 442 | break 443 | if self.peek() in CHAR_WHITESPACE and not chars: 444 | next(self) 445 | continue 446 | chars.append(next(self)) 447 | if allow_empty_values or chars: 448 | self.emit(klass, chars) 449 | return next_state 450 | raise TransactionLexingError("incomplete transaction") 451 | 452 | def state_parsing_transaction_posting_indentation(self): 453 | chars = [] 454 | while self.more(): 455 | if self.peek() not in CHAR_WHITESPACE: 456 | break 457 | chars.append(next(self)) 458 | if not chars: 459 | return 460 | if self.more() and self.peek() in CHAR_ENTER: 461 | next(self) 462 | return self.state_parsing_transaction_posting_indentation 463 | return self.state_parsing_transaction_posting_account 464 | 465 | def state_parsing_transaction_comment(self): 466 | return self.state_parsing_rest_of_line( 467 | TokenTransactionComment, 468 | self.state_parsing_transaction_posting_indentation) 469 | 470 | def state_parsing_transaction_posting_account(self): 471 | chars = [] 472 | if self.more() and self.peek() in CHAR_COMMENT: 473 | return self.state_parsing_transaction_comment 474 | while self.more(): 475 | if ( 476 | (self.peek() in CHAR_WHITESPACE and 477 | chars and chars[-1] in CHAR_WHITESPACE) or 478 | self.peek() in CHAR_TAB or 479 | self.peek() in CHAR_ENTER 480 | ): 481 | while chars[-1] in CHAR_WHITESPACE: 482 | chars = chars[:-1] 483 | break 484 | chars.append(next(self)) 485 | if not chars: 486 | raise TransactionLexingError("truncated transaction posting") 487 | self.emit(TokenTransactionPostingAccount, chars) 488 | return self.state_parsing_transaction_posting_amount 489 | 490 | def state_parsing_transaction_posting_amount(self): 491 | return self.state_parsing_rest_of_line( 492 | TokenTransactionPostingAmount, 493 | self.state_parsing_transaction_posting_indentation, 494 | allow_empty_values=True) 495 | 496 | def run(self): 497 | state = self.state_parsing_transaction_date 498 | while state: 499 | state = state() 500 | 501 | 502 | class LedgerContextualLexer(GenericLexer): 503 | 504 | def state_parsing_toplevel(self): 505 | while self.more(): 506 | if isinstance(self.peek(), TokenComment): 507 | return self.state_parsing_comment 508 | token = next(self) 509 | self.emit(token.__class__, token.contents) 510 | 511 | def state_parsing_comment(self): 512 | token = next(self) 513 | if ( 514 | self.more() and 515 | isinstance(token, TokenComment) and 516 | isinstance(self.peek(), TokenTransaction) 517 | ): 518 | transaction_token = next(self) 519 | additional_comments = [] 520 | while self.more() and isinstance(self.peek(), TokenComment): 521 | additional_comments.append(next(self)) 522 | self.emit(TokenTransactionWithContext, 523 | [token, transaction_token] + additional_comments) 524 | else: 525 | self.emit(token.__class__, token.contents) 526 | return self.state_parsing_toplevel 527 | 528 | def run(self): 529 | state = self.state_parsing_toplevel 530 | while state: 531 | try: 532 | state = state() 533 | except LexingError: 534 | raise 535 | except Exception as e: 536 | raise LexingError( 537 | "error parsing ledger data between chunk %d and chunk %d): %s" % ( 538 | self._last_emitted_pos, self.pos, e 539 | ) 540 | ) 541 | 542 | 543 | def lex_ledger_file_contents(text, debug=False): 544 | lexer = LedgerTextLexer(text) 545 | lexer.run() 546 | concat_lexed = "".join([x.contents for x in lexer.tokens]) 547 | if concat_lexed != text: 548 | if debug: 549 | u = "Debugging error lexing text: files differ\n\n" 550 | diffing.two_way_diff(u + text, u + concat_lexed) 551 | raise LexingError("the lexed contents and the original contents are not the same") 552 | lexer = LedgerContextualLexer(lexer.tokens) 553 | lexer.run() 554 | concat_lexed = "".join([ x.contents for x in lexer.tokens ]) 555 | if concat_lexed != text: 556 | if debug: 557 | u = "Debugging error lexing chunks: files differ\n\n" 558 | diffing.two_way_diff(u + text, u + concat_lexed) 559 | raise LexingError("the lexed chunks and the original chunks are not the same") 560 | return lexer.tokens 561 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/src/ledgerhelpers/programs/__init__.py -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/addtrans.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import datetime 5 | import logging 6 | import traceback 7 | 8 | import gi 9 | gi.require_version("Gtk", "3.0") 10 | from gi.repository import GObject 11 | from gi.repository import Gtk 12 | from gi.repository import Pango 13 | 14 | import ledgerhelpers as common 15 | from ledgerhelpers import gui 16 | from ledgerhelpers import journal 17 | from ledgerhelpers.programs import common as common_programs 18 | import ledgerhelpers.editabletransactionview as ed 19 | 20 | 21 | ASYNC_LOAD_MESSAGE = "Loading completion data from your ledger..." 22 | ASYNC_LOADING_ACCOUNTS_MESSAGE = ( 23 | "Loading account and commodity data from your ledger..." 24 | ) 25 | 26 | 27 | class AddTransWindow(Gtk.Window, gui.EscapeHandlingMixin): 28 | 29 | def __init__(self): 30 | Gtk.Window.__init__(self, title="Add transaction") 31 | self.set_border_width(12) 32 | 33 | grid = Gtk.Grid() 34 | grid.set_column_spacing(8) 35 | grid.set_row_spacing(12) 36 | self.add(grid) 37 | 38 | row = 0 39 | 40 | self.transholder = ed.EditableTransactionView() 41 | grid.attach(self.transholder, 0, row, 2, 1) 42 | 43 | row += 1 44 | 45 | self.transaction_view = gui.LedgerTransactionView() 46 | self.transaction_view.set_vexpand(True) 47 | self.transaction_view.get_accessible().set_name("Transaction preview") 48 | grid.attach(self.transaction_view, 0, row, 2, 1) 49 | 50 | row += 1 51 | 52 | self.status = Gtk.Label() 53 | self.status.set_line_wrap(True) 54 | self.status.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) 55 | self.status.set_hexpand(True) 56 | grid.attach(self.status, 0, row, 1, 1) 57 | 58 | button_box = Gtk.ButtonBox() 59 | button_box.set_layout(Gtk.ButtonBoxStyle.END) 60 | button_box.set_spacing(12) 61 | button_box.set_hexpand(False) 62 | self.close_button = Gtk.Button(stock=Gtk.STOCK_CLOSE) 63 | button_box.add(self.close_button) 64 | self.add_button = Gtk.Button(stock=Gtk.STOCK_ADD) 65 | button_box.add(self.add_button) 66 | grid.attach(button_box, 1, row, 1, 1) 67 | self.add_button.set_can_default(True) 68 | self.add_button.grab_default() 69 | 70 | 71 | class AddTransApp(AddTransWindow, gui.EscapeHandlingMixin): 72 | 73 | logger = logging.getLogger("addtrans") 74 | internal_parsing = [] 75 | 76 | def __init__(self, journal, preferences): 77 | AddTransWindow.__init__(self) 78 | self.journal = journal 79 | self.preferences = preferences 80 | self.successfully_loaded_accounts_and_commodities = False 81 | 82 | self.accounts = [] 83 | self.commodities = dict() 84 | self.internal_parsing = [] 85 | self.payees = [] 86 | 87 | self.activate_escape_handling() 88 | 89 | self.close_button.connect("clicked", 90 | lambda _: self.emit('delete-event', None)) 91 | self.add_button.connect("clicked", 92 | lambda _: self.process_transaction()) 93 | date = self.preferences.get("last_date", datetime.date.today()) 94 | self.transholder.set_transaction_date(date) 95 | self.transholder.connect( 96 | "payee-changed", 97 | self.payee_changed 98 | ) 99 | self.transholder.connect( 100 | "changed", 101 | self.update_transaction_view 102 | ) 103 | 104 | self.add_button.set_sensitive(False) 105 | self.transholder.title_grab_focus() 106 | self.status.set_text(ASYNC_LOAD_MESSAGE) 107 | 108 | self.connect("delete-event", lambda _, _a: self.save_preferences()) 109 | self.reload_completion_data() 110 | 111 | def reload_completion_data(self): 112 | gui.g_async( 113 | lambda: self.journal.internal_parsing(), 114 | lambda payees: self.internal_parsing_loaded(payees), 115 | self.journal_load_failed, 116 | ) 117 | 118 | def internal_parsing_loaded(self, internal_parsing): 119 | self.internal_parsing = internal_parsing 120 | gui.g_async( 121 | lambda: self.journal.all_payees(), 122 | lambda payees: self.all_payees_loaded(payees), 123 | self.journal_load_failed, 124 | ) 125 | 126 | def all_payees_loaded(self, payees): 127 | self.payees = payees 128 | self.transholder.set_payees_for_completion(self.payees) 129 | gui.g_async( 130 | lambda: self.journal.accounts_and_last_commodity_for_account(), 131 | lambda r: self.accounts_and_last_commodities_loaded(*r), 132 | self.journal_load_failed, 133 | ) 134 | if self.status.get_text() == ASYNC_LOAD_MESSAGE: 135 | self.status.set_text(ASYNC_LOADING_ACCOUNTS_MESSAGE) 136 | 137 | def accounts_and_last_commodities_loaded(self, accounts, last_commos): 138 | self.accounts = accounts 139 | self.commodities = last_commos 140 | self.transholder.set_accounts_for_completion(self.accounts) 141 | self.transholder.set_default_commodity_getter( 142 | self.get_commodity_for_account 143 | ) 144 | self.successfully_loaded_accounts_and_commodities = True 145 | if self.status.get_text() == ASYNC_LOADING_ACCOUNTS_MESSAGE: 146 | self.status.set_text("") 147 | 148 | def journal_load_failed(self, e): 149 | traceback.print_exception(e) 150 | gui.FatalError( 151 | "Add transaction loading failed", 152 | "An unexpected error took place:\n%s" % e, 153 | ) 154 | self.emit('delete-event', None) 155 | 156 | def get_commodity_for_account(self, account_name): 157 | try: 158 | return self.commodities[account_name] 159 | except KeyError: 160 | pass 161 | 162 | def payee_changed(self, emitter=None): 163 | if emitter.postings_modified() and not emitter.postings_empty(): 164 | return 165 | text = emitter.get_payee_text() 166 | self.try_autofill(emitter, text) 167 | 168 | def try_autofill(self, transaction_view, autofill_text): 169 | ts = journal.transactions_with_payee( 170 | autofill_text, 171 | self.internal_parsing, 172 | case_sensitive=False 173 | ) 174 | if not ts: 175 | return 176 | return self.autofill_transaction_view(transaction_view, ts[-1]) 177 | 178 | def autofill_transaction_view(self, transaction_view, transaction): 179 | transaction_view.replace_postings(transaction.postings) 180 | transaction_view.set_clearing(transaction.state) 181 | 182 | def update_transaction_view(self, unused_ignored=None): 183 | self.update_validation() 184 | k = self.transholder.get_data_for_transaction_record 185 | title, date, clear, statechar, lines = k() 186 | self.transaction_view.generate_record( 187 | title, date, clear, statechar, lines 188 | ) 189 | 190 | def update_validation(self, grab_focus=False): 191 | try: 192 | self.transholder.validate(grab_focus=grab_focus) 193 | self.status.set_text("") 194 | self.add_button.set_sensitive(True) 195 | return True 196 | except common.TransactionInputValidationError as e: 197 | self.status.set_text(str(e)) 198 | self.add_button.set_sensitive(False) 199 | return False 200 | 201 | def process_transaction(self): 202 | if not self.update_validation(True): 203 | return 204 | buf = self.transaction_view.get_buffer() 205 | text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), True) 206 | self.journal.add_text_to_file(text) 207 | self.reset_after_save() 208 | 209 | def reset_after_save(self): 210 | self.transholder.clear() 211 | self.transholder.title_grab_focus() 212 | self.status.set_text("Transaction saved") 213 | self.reload_completion_data() 214 | 215 | def save_preferences(self): 216 | if not self.successfully_loaded_accounts_and_commodities: 217 | return 218 | self.preferences["default_to_clearing"] = ( 219 | self.transholder.clearing.get_state() != 220 | self.transholder.clearing.STATE_UNCLEARED 221 | ) 222 | if self.transholder.when.get_date() in (datetime.date.today(), None): 223 | del self.preferences["last_date"] 224 | else: 225 | self.preferences["last_date"] = ( 226 | self.transholder.when.get_date() 227 | ) 228 | self.preferences.persist() 229 | 230 | 231 | def get_argparser(): 232 | parser = argparse.ArgumentParser( 233 | 'Add new transactions to your Ledger file', 234 | parents=[common_programs.get_common_argparser()] 235 | ) 236 | parser.add_argument('--debug', dest='debug', action='store_true', 237 | help='activate debugging') 238 | return parser 239 | 240 | 241 | def main(): 242 | args = get_argparser().parse_args() 243 | common.enable_debugging(args.debug) 244 | 245 | GObject.threads_init() 246 | 247 | journal, s = gui.load_journal_and_settings_for_gui( 248 | ledger_file=args.file, 249 | price_file=args.pricedb, 250 | ) 251 | klass = AddTransApp 252 | win = klass(journal, s) 253 | win.connect("delete-event", Gtk.main_quit) 254 | GObject.idle_add(win.show_all) 255 | Gtk.main() 256 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/cleartranscli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import datetime 4 | import os 5 | import re 6 | import sys 7 | sys.path.append(os.path.dirname(__file__)) 8 | import ledgerhelpers.legacy as common 9 | from ledgerhelpers import gui 10 | 11 | 12 | date_re = "^([0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9])(=[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]|)\\s+(.+)" 13 | date_re = re.compile(date_re, re.RegexFlag.DOTALL) 14 | 15 | 16 | def clear(f): 17 | changed = False 18 | lines = open(f).readlines() 19 | 20 | for n, line in enumerate(lines): 21 | m = date_re.match(line) 22 | if not m: 23 | continue 24 | if m.group(3).strip().startswith("*"): 25 | continue 26 | lines_to_write = [line] 27 | originaln = n 28 | while True: 29 | n = n + 1 30 | try: 31 | nextline = lines[n] 32 | except IndexError: 33 | break 34 | if nextline.startswith(" ") or nextline.startswith("\t"): 35 | lines_to_write.append(nextline) 36 | else: 37 | break 38 | initial_unparsed = m.group(2)[1:] if m.group(2) else m.group(1) 39 | initial = common.parse_date(initial_unparsed) 40 | if initial > datetime.date.today(): 41 | continue 42 | for line in lines_to_write: 43 | sys.stdout.write(line) 44 | sys.stdout.flush() 45 | choice = common.prompt_for_date_optional( 46 | sys.stdin, sys.stdout, 47 | "Mark cleared at this date?", 48 | initial, 49 | ) 50 | if choice is not None: 51 | choice_formatted = common.format_date(choice, initial_unparsed) 52 | if m.group(1) == choice_formatted: 53 | lines[originaln] = "%s * %s" % ( 54 | m.group(1), 55 | m.group(3) 56 | ) 57 | else: 58 | lines[originaln] = "%s=%s * %s" % ( 59 | m.group(1), 60 | choice_formatted, 61 | m.group(3) 62 | ) 63 | for number in range(originaln + 1, n): 64 | # remove cleared bits on legs of the transaction 65 | lines[number] = re.sub("^(\\s+)\\*\\s+", "\\1", lines[number]) 66 | changed = True 67 | else: 68 | pass 69 | if changed: 70 | y = open(f + ".new", "w") 71 | y.write("".join(lines)) 72 | y.flush() 73 | try: 74 | os.rename(f + ".new", f) 75 | except Exception: 76 | os.unlink(f + ".new") 77 | raise 78 | 79 | 80 | def main(): 81 | ledger_file = gui.find_ledger_file_for_gui() 82 | return clear(ledger_file) 83 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | 5 | 6 | def get_common_argparser(): 7 | parser = argparse.ArgumentParser(add_help=False) 8 | parser.add_argument('--file', dest='file', action='store', 9 | help='specify path to ledger file to work with') 10 | parser.add_argument('--price-db', dest='pricedb', action='store', 11 | help='specify path to ledger price database to work with') 12 | return parser 13 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/sellstockcli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import datetime 4 | import fnmatch 5 | import ledger 6 | import re 7 | import subprocess 8 | import sys 9 | import ledgerhelpers.legacy as common 10 | from ledgerhelpers import gui 11 | 12 | 13 | class Lot(object): 14 | 15 | def __init__(self, number, date, amount, acct): 16 | self.number = number 17 | self.date = date 18 | self.amount = amount 19 | quantity = ledger.Amount(amount.strip_annotations()) 20 | self.price = self.amount.price() / quantity 21 | self.account = acct 22 | 23 | def __str__(self): 24 | return ( 25 | "" 27 | ) % ( 28 | self.number, self.amount, self.price, self.account, 29 | self.date 30 | ) 31 | 32 | 33 | class NotEnough(Exception): 34 | pass 35 | 36 | 37 | class Lots(object): 38 | 39 | def __init__(self): 40 | self.lots = [] 41 | self.unfinished = [] 42 | 43 | def __getitem__(self, idx): 44 | return list(self)[idx] 45 | 46 | def __iter__(self): 47 | return iter(sorted( 48 | self.lots, 49 | key=lambda l: "%s" % (l.date,) 50 | )) 51 | 52 | def parse_ledger_bal(self, text): 53 | """Demands '--balance-format=++ %(account)\n%(amount)\n' format. 54 | Demands '--date-format=%Y-%m-%d' date format.""" 55 | lines = [x.strip() for x in text.splitlines() if x.strip()] 56 | account = None 57 | for line in lines: 58 | if line.startswith("++ "): 59 | account = line[3:] 60 | else: 61 | amount = ledger.Amount(line) 62 | date = re.findall(r'\[\d\d\d\d-\d\d-\d\d]', line) 63 | assert len(date) < 2 64 | if date: 65 | date = common.parse_date(date[0][1:-1]) 66 | else: 67 | date = None 68 | try: 69 | lot = Lot(self.nextnum(), 70 | date, 71 | amount, 72 | account) 73 | self.lots.append(lot) 74 | except TypeError: 75 | # At this point, we know the commodity does not have a price. 76 | # So we ignore this. 77 | pass 78 | 79 | def nextnum(self): 80 | if not self.lots: 81 | return 1 82 | return max([l.number for l in self.lots]) + 1 83 | 84 | def first_lot_by_commodity(self, commodity): 85 | return [s for s in self if str(s.amount.commodity) == str(commodity)][0] 86 | 87 | def subtract(self, amount): 88 | lots = [] 89 | subtracted = amount - amount 90 | while subtracted < amount: 91 | try: 92 | l = self.first_lot_by_commodity(amount.commodity) 93 | except IndexError: 94 | raise NotEnough(amount - subtracted) 95 | to_reduce = min([l.amount.strip_annotations(), amount - subtracted]) 96 | if str(to_reduce) == str(l.amount.strip_annotations()): 97 | lots.append(l) 98 | self.lots.remove(l) 99 | else: 100 | l.amount -= to_reduce.number() 101 | new_amount = l.amount - l.amount + to_reduce.number() 102 | lots.append(Lot(l.number, 103 | l.date, 104 | new_amount, 105 | l.account)) 106 | subtracted += to_reduce 107 | return lots 108 | 109 | 110 | def matches(string, options): 111 | for option in options: 112 | if fnmatch.fnmatch(string, option): 113 | return True 114 | return False 115 | 116 | 117 | def main(): 118 | journal, s = gui.load_journal_and_settings_for_gui() 119 | accts, unused_commodities = journal.accounts_and_last_commodity_for_account() 120 | 121 | saleacct = common.prompt_for_account( 122 | sys.stdin, sys.stdout, 123 | accts, "Which account was the sold commodity stored in?", 124 | s.get("last_sellstock_account", None) 125 | ) 126 | assert saleacct, "Not an account: %s" % saleacct 127 | s["last_sellstock_account"] = saleacct 128 | 129 | commissionsaccount = common.prompt_for_account( 130 | sys.stdin, sys.stdout, 131 | accts, "Which account to account for commissions?", 132 | s.get("last_commissions_account", None) 133 | ) 134 | assert commissionsaccount, "Not an account: %s" % commissionsaccount 135 | s["last_commissions_account"] = commissionsaccount 136 | 137 | gainslossesacct = common.prompt_for_account( 138 | sys.stdin, sys.stdout, 139 | accts, "Which account to credit gains and losses?", 140 | s.get("last_gainslosses_account", 141 | "Capital:Recognized gains and losses") 142 | ) 143 | assert gainslossesacct, "Not an account: %s" % gainslossesacct 144 | s["last_gainslosses_account"] = gainslossesacct 145 | 146 | target_amount = common.prompt_for_amount( 147 | sys.stdin, sys.stdout, 148 | "How many units of what commodity?", ledger.Amount("$ 1") 149 | ) 150 | target_sale_price = common.prompt_for_amount( 151 | sys.stdin, sys.stdout, 152 | "What is the sale price of the commodity?", ledger.Amount("$ 1") 153 | ) 154 | commission = common.prompt_for_amount( 155 | sys.stdin, sys.stdout, 156 | "What was the commission of the trade?", ledger.Amount("$ 1") 157 | ) 158 | 159 | all_lots = Lots() 160 | lots_text = subprocess.check_output([ 161 | 'ledger', 'bal', 162 | '--lots', '--lot-dates', '--lot-prices', 163 | '--date-format=%Y-%m-%d', '--sort=date', 164 | '--balance-format=++ %(account)\n%(amount)\n', 165 | saleacct 166 | ]) 167 | all_lots.parse_ledger_bal(lots_text) 168 | 169 | print("=========== Read ===========") 170 | for l in all_lots: 171 | print(l) 172 | 173 | lots_produced = all_lots.subtract(target_amount) 174 | 175 | print("========= Computed =========") 176 | for l in lots_produced: 177 | print(l) 178 | 179 | print("=========== Left ===========") 180 | for l in all_lots: 181 | print(l) 182 | 183 | lines = [] 184 | tpl = "%s {%s}%s @ %s" 185 | datetpl = ' [%s]' 186 | for l in lots_produced: 187 | m = -1 * l.amount 188 | if m.commodity.details.date: 189 | datetext = datetpl % m.commodity.details.date.strftime("%Y-%m-%d") 190 | else: 191 | datetext = '' 192 | lines.append(( 193 | l.account, 194 | tpl % (m.strip_annotations(), 195 | m.commodity.details.price, 196 | datetext, 197 | target_sale_price) 198 | )) 199 | diff = (l.price - target_sale_price) * l.amount 200 | lines.append((gainslossesacct, diff)) 201 | totalsale = target_sale_price * sum( 202 | l.amount.number() for l in lots_produced 203 | ) 204 | lines.append((saleacct, totalsale - commission)) 205 | lines.append((commissionsaccount, commission)) 206 | 207 | lines = journal.generate_record( 208 | "Sale of %s" % (target_amount), 209 | datetime.date.today(), None, "", 210 | lines, 211 | ) 212 | print("========== Record ==========") 213 | print("\n".join(lines)) 214 | save = common.yesno( 215 | sys.stdin, sys.stderr, 216 | "Hit ENTER or y to save it to the file, BACKSPACE or n to skip saving: " 217 | ) 218 | if save: 219 | journal.add_text_to_file(lines) 220 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/sorttranscli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import codecs 5 | import collections 6 | import datetime 7 | import itertools 8 | import subprocess 9 | import sys 10 | 11 | from ledgerhelpers import diffing 12 | from ledgerhelpers import parser 13 | from ledgerhelpers import gui 14 | from ledgerhelpers.programs import common as common_programs 15 | 16 | 17 | def get_argparser(): 18 | parser = argparse.ArgumentParser( 19 | 'Sort transactions in a ledger file chronologically', 20 | parents=[common_programs.get_common_argparser()] 21 | ) 22 | parser.add_argument('-y', dest='assume_yes', action='store_true', 23 | help='record changes immediately, instead of ' 24 | 'showing a three-way diff for you to resolve') 25 | parser.add_argument('--debug', dest='debug', action='store_true', 26 | help='do not capture exceptions into a dialog box') 27 | return parser 28 | 29 | 30 | def sort_transactions(items): 31 | smallest_date = datetime.date(1000, 1, 1) 32 | largest_date = datetime.date(3000, 1, 1) 33 | bydates = collections.OrderedDict() 34 | first_transaction_seen = False 35 | for n, item in enumerate(items): 36 | if hasattr(item, "date"): 37 | first_transaction_seen = True 38 | if first_transaction_seen: 39 | later_dates = itertools.chain( 40 | (getattr(items[i], "date", None) for i in range(n, len(items))), 41 | [largest_date] 42 | ) 43 | for date in later_dates: 44 | if date is not None: 45 | break 46 | else: 47 | date = smallest_date 48 | if date not in bydates: 49 | bydates[date] = [] 50 | bydates[date] += [item] 51 | for date in sorted(bydates): 52 | for item in bydates[date]: 53 | yield item 54 | 55 | 56 | def main(argv): 57 | p = get_argparser() 58 | args = p.parse_args(argv[1:]) 59 | if args.file: 60 | ledgerfile = args.file 61 | else: 62 | ledgerfile = gui.find_ledger_file_for_gui() 63 | try: 64 | leftcontents = codecs.open(ledgerfile, "rb", "utf-8").read() 65 | items = parser.lex_ledger_file_contents(leftcontents, debug=args.debug) 66 | rightcontents = "".join(i.contents for i in sort_transactions(items)) 67 | if args.assume_yes: 68 | with open(ledgerfile, "w") as out_file: 69 | out_file.write(rightcontents) 70 | return 0 71 | try: 72 | diffing.three_way_diff(ledgerfile, leftcontents, rightcontents) 73 | except subprocess.CalledProcessError as e: 74 | if args.debug: 75 | raise 76 | print("Meld failed", file=sys.stderr) 77 | print("Meld process failed with return code %s" % e.returncode, file=sys.stderr) 78 | return e.returncode 79 | except Exception as e: 80 | if args.debug: 81 | raise 82 | print("Transaction sort failed", file=sys.stderr) 83 | print("An unexpected error took place:\n%s" % e, file=sys.stderr) 84 | return 9 85 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/updateprices.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import collections 5 | import datetime 6 | import http.client 7 | import json 8 | import ledger 9 | import ledgerhelpers 10 | from ledgerhelpers import gui 11 | import threading 12 | import traceback 13 | import urllib.parse 14 | import sys 15 | import yahoo_finance 16 | 17 | import gi 18 | gi.require_version("Gtk", "3.0") 19 | from gi.repository import GObject 20 | from gi.repository import Gtk 21 | 22 | 23 | def get_argparser(): 24 | parser = argparse.ArgumentParser( 25 | 'Update prices in a Ledger price file' 26 | ) 27 | parser.add_argument('-b', dest='batch', action='store_true', 28 | help='update price file in batch (non-GUI) mode') 29 | parser.add_argument('--debug', dest='debug', action='store_true', 30 | help='do not capture exceptions into a dialog box') 31 | return parser 32 | 33 | 34 | class QuoteSource(object): 35 | pass 36 | 37 | 38 | class DontQuote(QuoteSource): 39 | 40 | def __str__(self): 41 | return "skip quoting" 42 | 43 | def get_quote(self, commodity, denominated_in): # @UnusedVariable 44 | return None, None 45 | 46 | 47 | class YahooFinanceCommodities(QuoteSource): 48 | 49 | def __str__(self): 50 | return "Yahoo! Finance commodities" 51 | 52 | def get_quote( 53 | self, 54 | commodity, 55 | denominated_in, 56 | commodity_is_currency_pair=False 57 | ): 58 | """Returns the price in the appraised_as currency, and the datetime. 59 | 60 | Args: 61 | commodity: a Ledger commodity representing a non-currency 62 | commodity 63 | denominated_in: a Ledger commodity 64 | 65 | Returns: 66 | price: ledger.Amount instance 67 | datetime: datetime.datetime instance 68 | """ 69 | if not isinstance(commodity, ledger.Commodity): 70 | raise ValueError("commodity must be a Ledger commodity") 71 | if not isinstance(denominated_in, ledger.Commodity): 72 | raise ValueError("denominated_in must be a Ledger commodity") 73 | if commodity_is_currency_pair: 74 | source = str(commodity) 75 | source = source if source != "$" else "USD" 76 | target = str(denominated_in) 77 | target = target if target != "$" else "USD" 78 | pair = source + target 79 | s = yahoo_finance.Currency(pair) 80 | try: 81 | price, date = s.get_rate(), s.get_trade_datetime() 82 | except KeyError: 83 | raise ValueError( 84 | "Yahoo! Finance can't find currency pair %s" % pair 85 | ) 86 | else: 87 | if str(denominated_in) not in ["$", "USD"]: 88 | raise ValueError( 89 | "Yahoo! Finance can't quote in %s" % denominated_in 90 | ) 91 | s = yahoo_finance.Share(str(commodity)) 92 | try: 93 | price, date = s.get_price(), s.get_trade_datetime() 94 | except KeyError: 95 | raise ValueError( 96 | "Yahoo! Finance can't find commodity %s" % commodity 97 | ) 98 | a = ledger.Amount(price) 99 | a.commodity = denominated_in 100 | d = datetime.datetime.strptime( 101 | date, 102 | '%Y-%m-%d %H:%M:%S UTC+0000' 103 | ) 104 | return a, d 105 | 106 | 107 | class YahooFinanceCurrencies(YahooFinanceCommodities): 108 | 109 | def __str__(self): 110 | return "Yahoo! Finance currencies" 111 | 112 | def get_quote(self, commodity, denominated_in="$"): 113 | """Returns the price in the appraised_as currency, and the datetime. 114 | 115 | Args: 116 | commodity: a Ledger commodity representing a currency 117 | denominated_in: a Ledger commodity 118 | 119 | Returns: 120 | price: ledger.Amount instance 121 | datetime: datetime.datetime instance 122 | """ 123 | return YahooFinanceCommodities.get_quote( 124 | self, 125 | commodity, 126 | denominated_in, 127 | commodity_is_currency_pair=True 128 | ) 129 | 130 | 131 | def json_from_uri(uri): 132 | c = http.client.HTTPSConnection(urllib.parse.urlsplit(uri).netloc) # @UndefinedVariable 133 | c.request('GET', uri) 134 | response = c.getresponse().read() 135 | try: 136 | return json.loads(response) 137 | except ValueError: 138 | raise ValueError("JSON object undecodable: %s" % response) 139 | 140 | 141 | class BitcoinCharts(QuoteSource): 142 | 143 | def __str__(self): 144 | return "bitcoin charts" 145 | 146 | def get_quote(self, commodity, denominated_in): 147 | """Returns the price in the denominated_in currency, 148 | and the datetime. 149 | 150 | Args: 151 | commodity: a Ledger commodity 152 | denominated_in: a Ledger commodity 153 | 154 | Returns: 155 | price: ledger.Amount instance 156 | datetime: datetime.datetime instance 157 | """ 158 | if not isinstance(commodity, ledger.Commodity): 159 | raise ValueError("commodity must be a Ledger commodity") 160 | if not isinstance(denominated_in, ledger.Commodity): 161 | raise ValueError("denominated_in must be a Ledger commodity") 162 | if str(commodity) not in ["BTC", "XBT"]: 163 | raise ValueError( 164 | "bitcoin charts can only provide quotes for BTC / XBT" 165 | ) 166 | 167 | data = json_from_uri( 168 | "https://api.bitcoincharts.com/v1/weighted_prices.json" 169 | ) 170 | try: 171 | k = "USD" if str(denominated_in) == "$" else str(denominated_in) 172 | amount = data[k].get("24h", data[k]["7d"]) 173 | except KeyError: 174 | raise ValueError( 175 | "bitcoin charts can't provide quotes in %s" % denominated_in 176 | ) 177 | a = ledger.Amount(amount) 178 | a.commodity = denominated_in 179 | d = datetime.datetime.now() 180 | return a, d 181 | 182 | 183 | class PriceGatheringDatabase(Gtk.ListStore): 184 | 185 | def __init__(self): 186 | # Columns: 187 | # 0: Ledger.commodity to appraise 188 | # 1: data source to fetch prices from 189 | # 2: list of (Ledger.commodity) representing which denominations 190 | # the (0) commodity must be appraised in 191 | # 3: list of (gathered price, datetime) 192 | # 4: errors (exceptions) 193 | Gtk.ListStore.__init__(self, object, object, object, object, object) 194 | self.commodities_added = dict() 195 | 196 | def add_to_gather_list(self, commodity, datasource, fetch_prices_in): 197 | if self.commodities_added.get(str(commodity)): 198 | return 199 | if isinstance(commodity, ledger.Amount): 200 | commodity = commodity.commodity 201 | assert isinstance(commodity, ledger.Commodity), commodity 202 | assert isinstance(datasource, QuoteSource) 203 | for f in fetch_prices_in: 204 | assert isinstance(f, ledger.Commodity) 205 | self.append((commodity, datasource, list(fetch_prices_in), 206 | list(), list())) 207 | self.commodities_added[str(commodity)] = True 208 | 209 | def clear_gathered(self): 210 | for row in self: 211 | while row[3]: 212 | row[3].pop() 213 | while row[4]: 214 | row[4].pop() 215 | self.emit('row-changed', row.path, row.iter) 216 | 217 | def record_gathered(self, commodity, amount, timeobject): 218 | assert isinstance(commodity, ledger.Commodity), commodity 219 | assert isinstance(amount, ledger.Amount), amount 220 | found = False 221 | for row in self: 222 | if commodity == row[0]: 223 | found = True 224 | break 225 | assert found, "%s not found in gather list" % commodity 226 | row[3].append((amount, timeobject)) 227 | self.emit('row-changed', row.path, row.iter) 228 | 229 | def record_gathered_error(self, commodity, error): 230 | assert isinstance(commodity, ledger.Commodity), commodity 231 | found = False 232 | for row in self: 233 | if commodity == row[0]: 234 | found = True 235 | break 236 | assert found, "%s not found in gather list" % commodity 237 | row[4].append(error) 238 | self.emit('row-changed', row.path, row.iter) 239 | 240 | def get_currency_by_path(self, treepath): 241 | it = self.get_iter(treepath) 242 | return self.get_value(it, 0) 243 | 244 | def update_quoter(self, treepath, new_datasource): 245 | i = self.get_iter(treepath) 246 | self.set_value(i, 1, new_datasource) 247 | 248 | def update_price_in(self, treepath, new_price_in): 249 | i = self.get_iter(treepath) 250 | self.set_value(i, 2, new_price_in) 251 | 252 | def get_prices(self): 253 | for row in self: 254 | for p, d in row[3]: 255 | yield row[0], p, d 256 | 257 | def get_errors(self): 258 | for row in self: 259 | for e in row[4]: 260 | yield row[0], e 261 | 262 | 263 | @GObject.type_register 264 | class PriceGatherer(GObject.GObject): 265 | 266 | __gsignals__ = { 267 | "gathering-started": ( 268 | GObject.SIGNAL_RUN_LAST, None, () 269 | ), 270 | "gathering-done": ( 271 | GObject.SIGNAL_RUN_LAST, None, () 272 | ), 273 | } 274 | 275 | def __init__(self, quoters): 276 | GObject.GObject.__init__(self) 277 | assert quoters 278 | self.quoters = quoters 279 | self.database = PriceGatheringDatabase() 280 | 281 | def load_commodities_from_journal( 282 | self, 283 | journal, 284 | map_from_currencystrs_to_quotesources, 285 | map_from_currencystrs_to_priceins, 286 | ): 287 | DontQuote = self.quoters.get("DontQuote", list(self.quoters.values())[0]) 288 | default = "$" 289 | coms = [c.strip_annotations().commodity for c in journal.commodities()] 290 | strcoms = [str(c) for c in coms] 291 | if "USD" in strcoms and "$" not in strcoms: 292 | default = "USD" 293 | already = dict() 294 | for c in coms: 295 | if str(c) in already: 296 | continue 297 | already[str(c)] = True 298 | quoter = list(self.quoters.values())[0] 299 | if str(c) in ["USD", "$"] and default in ["USD", "$"]: 300 | quoter = DontQuote 301 | if str(c) in map_from_currencystrs_to_quotesources: 302 | quoter = map_from_currencystrs_to_quotesources[str(c)] 303 | quoteins = [journal.commodity(default)] 304 | if str(c) in map_from_currencystrs_to_priceins: 305 | quoteins = list(map_from_currencystrs_to_priceins[str(c)]) 306 | self.database.add_to_gather_list( 307 | c, 308 | quoter, 309 | quoteins, 310 | ) 311 | 312 | def _gather_inner(self, sync=False): 313 | def do(f, *a): 314 | if not sync: 315 | return GObject.idle_add(f, *a) 316 | return f(*a) 317 | 318 | do(self.database.clear_gathered) 319 | for row in self.database: 320 | commodity = row[0] 321 | quotesource = row[1] 322 | for denominated_in in row[2]: 323 | try: 324 | price, time = quotesource.get_quote( 325 | commodity, 326 | denominated_in=denominated_in 327 | ) 328 | if price is None and time is None: 329 | continue 330 | do( 331 | self.database.record_gathered, 332 | commodity, 333 | price, 334 | time 335 | ) 336 | except Exception as e: 337 | error = str(e) 338 | do( 339 | self.database.record_gathered_error, 340 | commodity, 341 | error, 342 | ) 343 | traceback.print_exc() 344 | GObject.idle_add(self.emit, "gathering-done") 345 | 346 | def gather_quotes(self, sync=False): 347 | GObject.idle_add(self.emit, "gathering-started") 348 | if not sync: 349 | t = threading.Thread(target=self._gather_inner) 350 | t.setDaemon(True) 351 | t.start() 352 | else: 353 | return self._gather_inner(sync=True) 354 | 355 | 356 | @GObject.type_register 357 | class CellRendererCommodity(Gtk.CellRendererText): 358 | 359 | __gproperties__ = { 360 | "commodity": ( 361 | GObject.TYPE_PYOBJECT, 362 | "commodity to display", 363 | "the commodity to render in the cell", 364 | GObject.PARAM_READWRITE 365 | ) 366 | } 367 | 368 | def do_set_property(self, prop, val): 369 | if prop.name == "commodity": 370 | self.set_property("text", str(val)) 371 | else: 372 | self.set_property(prop, val) 373 | 374 | 375 | @GObject.type_register 376 | class CellRendererQuoteSource(Gtk.CellRendererCombo): 377 | 378 | __gproperties__ = { 379 | "source": ( 380 | GObject.TYPE_PYOBJECT, 381 | "source of quotes", 382 | "the QuoteSource data source to render", 383 | GObject.PARAM_READWRITE 384 | ) 385 | } 386 | 387 | def do_set_property(self, prop, val): 388 | if prop.name == "source": 389 | self.set_property("text", str(val)) 390 | else: 391 | self.set_property(prop, val) 392 | 393 | 394 | @GObject.type_register 395 | class CellRendererPriceIn(Gtk.CellRendererText): 396 | 397 | __gproperties__ = { 398 | "price-in": ( 399 | GObject.TYPE_PYOBJECT, 400 | "which commodities to denominate the quotes in", 401 | "list of denominations for quote fetches", 402 | GObject.PARAM_READWRITE 403 | ) 404 | } 405 | 406 | def do_set_property(self, prop, val): 407 | if prop.name == "price-in": 408 | self.set_property("text", "\n".join(str(s) for s in val)) 409 | else: 410 | self.set_property(prop, val) 411 | 412 | 413 | @GObject.type_register 414 | class CellRendererFetchedList(Gtk.CellRendererText): 415 | 416 | render_datum = 0 417 | 418 | __gproperties__ = { 419 | "list": ( 420 | GObject.TYPE_PYOBJECT, 421 | "list to display", 422 | "the list of fetched quotes to render", 423 | GObject.PARAM_READWRITE 424 | ), 425 | "render-datum": ( 426 | GObject.TYPE_INT, 427 | "which datum to render", 428 | "render either 0 for price or 1 for time", 429 | render_datum, 430 | 1, 431 | 0, 432 | GObject.PARAM_READWRITE 433 | ), 434 | } 435 | 436 | def do_get_property(self, prop): 437 | if prop.name == "render-datum": 438 | return self.render_datum 439 | else: 440 | assert 0, prop 441 | 442 | def do_set_property(self, prop, val): 443 | def render(obj): 444 | if self.render_datum != 1: 445 | return str(obj) 446 | else: 447 | return obj.strftime("%Y-%m-%d %H:%M:%S") 448 | if prop.name == "list": 449 | self.set_property( 450 | "text", 451 | "\n".join(render(p[self.render_datum]) for p in val) 452 | ) 453 | elif prop.name == "render-datum": 454 | self.render_datum = val 455 | else: 456 | assert 0, (prop, val) 457 | 458 | 459 | @GObject.type_register 460 | class CellRendererFetchErrors(Gtk.CellRendererText): 461 | 462 | __gproperties__ = { 463 | "errors": ( 464 | GObject.TYPE_PYOBJECT, 465 | "list of errors to render", 466 | "list of error strings to render", 467 | GObject.PARAM_READWRITE 468 | ) 469 | } 470 | 471 | def do_set_property(self, prop, val): 472 | if prop.name == "errors": 473 | self.set_property("text", "\n".join(str(s) for s in val)) 474 | else: 475 | self.set_property(prop, val) 476 | 477 | 478 | class PriceGatheringView(Gtk.TreeView): 479 | 480 | def __init__(self): 481 | Gtk.TreeView.__init__(self) 482 | commodity_renderer = CellRendererCommodity() 483 | commodity_renderer.set_property("yalign", 0.0) 484 | commodity_column = Gtk.TreeViewColumn( 485 | "Commodity", 486 | commodity_renderer, 487 | commodity=0, 488 | ) 489 | self.append_column(commodity_column) 490 | 491 | quotesource_renderer = CellRendererQuoteSource() 492 | quotesource_renderer.set_property("yalign", 0.0) 493 | quotesource_column = Gtk.TreeViewColumn( 494 | "Quote source", 495 | quotesource_renderer, 496 | source=1, 497 | ) 498 | self.append_column(quotesource_column) 499 | self.quotesource_renderer = quotesource_renderer 500 | 501 | price_in_renderer = CellRendererPriceIn() 502 | price_in_renderer.set_property("yalign", 0.0) 503 | price_in_column = Gtk.TreeViewColumn( 504 | "Quote in", 505 | price_in_renderer, 506 | price_in=2, 507 | ) 508 | self.append_column(price_in_column) 509 | self.price_in_renderer = price_in_renderer 510 | 511 | fetched_prices_renderer = CellRendererFetchedList() 512 | fetched_prices_renderer.set_property("yalign", 0.0) 513 | fetched_prices_renderer.set_property("render-datum", 0) 514 | fetched_prices_column = Gtk.TreeViewColumn( 515 | "Price", 516 | fetched_prices_renderer, 517 | list=3, 518 | ) 519 | self.append_column(fetched_prices_column) 520 | 521 | fetched_dates_renderer = CellRendererFetchedList() 522 | fetched_dates_renderer.set_property("yalign", 0.0) 523 | fetched_dates_renderer.set_property("render-datum", 1) 524 | fetched_dates_column = Gtk.TreeViewColumn( 525 | "Date", 526 | fetched_dates_renderer, 527 | list=3, 528 | ) 529 | self.append_column(fetched_dates_column) 530 | 531 | errors_renderer = CellRendererFetchErrors() 532 | errors_renderer.set_property("yalign", 0.0) 533 | errors_column = Gtk.TreeViewColumn( 534 | "Status", 535 | errors_renderer, 536 | errors=4, 537 | ) 538 | self.append_column(errors_column) 539 | 540 | 541 | class UpdatePricesWindow(Gtk.Window): 542 | 543 | def __init__(self): 544 | Gtk.Window.__init__(self, title="Update prices") 545 | self.set_border_width(12) 546 | self.set_default_size(800, 430) 547 | 548 | grid = Gtk.Grid() 549 | grid.set_column_spacing(8) 550 | grid.set_row_spacing(8) 551 | self.add(grid) 552 | 553 | row = 0 554 | 555 | self.gatherer_view = PriceGatheringView() 556 | self.gatherer_view.set_hexpand(True) 557 | self.gatherer_view.set_vexpand(True) 558 | 559 | sw = Gtk.ScrolledWindow() 560 | sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) 561 | sw.set_shadow_type(Gtk.ShadowType.IN) 562 | sw.add(self.gatherer_view) 563 | grid.attach(sw, 0, row, 1, 1) 564 | 565 | row += 1 566 | 567 | button_box = Gtk.ButtonBox() 568 | button_box.set_layout(Gtk.ButtonBoxStyle.END) 569 | button_box.set_spacing(12) 570 | self.status = Gtk.Label() 571 | button_box.add(self.status) 572 | self.close_button = Gtk.Button(stock=Gtk.STOCK_CLOSE) 573 | button_box.add(self.close_button) 574 | self.fetch_button = Gtk.Button(label="Fetch") 575 | button_box.add(self.fetch_button) 576 | self.save_button = Gtk.Button(stock=Gtk.STOCK_SAVE) 577 | button_box.add(self.save_button) 578 | grid.attach(button_box, 0, row, 2, 1) 579 | self.fetch_button.set_can_default(True) 580 | self.fetch_button.grab_default() 581 | 582 | 583 | class UpdatePricesCommon(object): 584 | 585 | def __init__(self, journal, preferences): 586 | self.journal = journal 587 | self.preferences = preferences 588 | try: 589 | self.preferences["quotesources"] 590 | except KeyError: 591 | self.preferences["quotesources"] = dict() 592 | try: 593 | self.preferences["quotecurrencies"] 594 | except KeyError: 595 | self.preferences["quotecurrencies"] = dict() 596 | self.quoters = collections.OrderedDict( 597 | (str(q), q) for q in [ 598 | YahooFinanceCommodities(), 599 | YahooFinanceCurrencies(), 600 | BitcoinCharts(), 601 | DontQuote(), 602 | ] 603 | ) 604 | self.gatherer = PriceGatherer(self.quoters) 605 | 606 | def get_ready(self): 607 | prefquotesources = dict( 608 | (cur, self.quoters.get(n, list(self.quoters.values())[0])) 609 | for cur, n 610 | in list(self.preferences["quotesources"].items()) 611 | if type(n) is not list 612 | ) 613 | prefquotecurrencies = dict( 614 | (cur, [self.journal.commodity(v, True) for v in pins]) 615 | for cur, pins 616 | in list(self.preferences["quotecurrencies"].items()) 617 | ) 618 | self.gatherer.load_commodities_from_journal( 619 | self.journal, 620 | prefquotesources, 621 | prefquotecurrencies, 622 | ) 623 | 624 | def save_fetched_prices(self): 625 | recs = list(self.gatherer.database.get_prices()) 626 | if recs: 627 | lines = self.journal.generate_price_records(recs) 628 | self.journal.add_text_to_price_file(lines) 629 | 630 | def output_errors(self): 631 | recs = list(self.gatherer.database.get_errors()) 632 | if recs: 633 | print("There were errors obtaining prices:", file=sys.stderr) 634 | for comm, error in recs: 635 | print("* Gathering %s: %s" % (comm, error)) 636 | return bool(recs) 637 | 638 | def run(self): 639 | self.get_ready() 640 | self.gatherer.gather_quotes(sync=True) 641 | errors = self.output_errors() 642 | self.save_fetched_prices() 643 | if errors: 644 | return 3 645 | 646 | 647 | class UpdatePricesApp( 648 | UpdatePricesCommon, 649 | UpdatePricesWindow, 650 | gui.EscapeHandlingMixin 651 | ): 652 | 653 | def __init__(self, journal, preferences): 654 | UpdatePricesCommon.__init__(self, journal, preferences) 655 | UpdatePricesWindow.__init__(self) 656 | 657 | self.fetch_level = 0 658 | self.connect("delete-event", lambda *unused_a: self.save_preferences()) 659 | self.activate_escape_handling() 660 | 661 | self.gatherer_view.set_model(self.gatherer.database) 662 | self.close_button.connect( 663 | "clicked", 664 | lambda _: self.emit('delete-event', None) 665 | ) 666 | # FIXME: do asynchronously. 667 | self.get_ready() 668 | 669 | def get_ready(self): 670 | UpdatePricesCommon.get_ready(self) 671 | 672 | quotesource_model = Gtk.ListStore(str, object) 673 | for q in list(self.quoters.items()): 674 | quotesource_model.append(q) 675 | self.gatherer_view.quotesource_renderer.set_property( 676 | "model", 677 | quotesource_model 678 | ) 679 | self.gatherer_view.quotesource_renderer.set_property( 680 | "text-column", 681 | 0 682 | ) 683 | self.gatherer_view.quotesource_renderer.connect( 684 | "editing-started", 685 | self.on_quotesource_editing_started 686 | ) 687 | self.gatherer_view.quotesource_renderer.connect( 688 | "edited", 689 | self.on_quotesource_editing_done 690 | ) 691 | self.gatherer_view.quotesource_renderer.connect( 692 | "editing-canceled", 693 | self.on_quotesource_editing_canceled 694 | ) 695 | self.gatherer_view.price_in_renderer.connect( 696 | "editing-started", 697 | self.on_price_in_editing_started 698 | ) 699 | self.gatherer_view.price_in_renderer.connect( 700 | "edited", 701 | self.on_price_in_editing_done 702 | ) 703 | self.gatherer_view.price_in_renderer.connect( 704 | "editing-canceled", 705 | self.on_price_in_editing_canceled 706 | ) 707 | self.save_button.connect("clicked", self.save_fetched_prices) 708 | self.fetch_button.connect("clicked", lambda _: self.do_fetch()) 709 | self.gatherer.connect("gathering-started", self.prevent_fetch) 710 | self.gatherer.connect("gathering-started", self.disallow_save) 711 | self.gatherer.connect("gathering-started", self.disable_cell_editing) 712 | self.gatherer.connect("gathering-done", self.enable_cell_editing) 713 | self.gatherer.connect("gathering-done", self.allow_fetch) 714 | self.gatherer.connect("gathering-done", self.allow_save) 715 | self.gatherer.connect("gathering-done", self.focus_save) 716 | self.allow_fetch() 717 | self.disallow_save() 718 | self.enable_cell_editing() 719 | 720 | def enable_cell_editing(self, unused_w=None): 721 | self.gatherer_view.quotesource_renderer.set_property( 722 | "editable", 723 | True 724 | ) 725 | self.gatherer_view.price_in_renderer.set_property( 726 | "editable", 727 | True 728 | ) 729 | 730 | def disable_cell_editing(self, unused_w=None): 731 | self.gatherer_view.quotesource_renderer.set_property( 732 | "editable", 733 | False 734 | ) 735 | self.gatherer_view.price_in_renderer.set_property( 736 | "editable", 737 | False 738 | ) 739 | 740 | def disallow_save(self, unused_w=None): 741 | self.save_button.set_sensitive(False) 742 | 743 | def allow_save(self, unused_w=None): 744 | self.save_button.set_sensitive(True) 745 | 746 | def focus_save(self, unused_w=None): 747 | self.save_button.grab_focus() 748 | 749 | def prevent_fetch(self, unused_w=None): 750 | self.fetch_level -= 1 751 | self.fetch_button.set_sensitive(self.fetch_level > 0) 752 | 753 | def allow_fetch(self, unused_w=None): 754 | self.fetch_level += 1 755 | self.fetch_button.set_sensitive(self.fetch_level > 0) 756 | 757 | def on_quotesource_editing_started(self, *unused_a): 758 | self.prevent_fetch() 759 | self.suspend_escape_handling() 760 | 761 | def on_quotesource_editing_done(self, cell, path, new_text, *unused_a, **unused_kw): 762 | thedict = dict(x for x in cell.props.model) 763 | try: 764 | new_quotesource = thedict[new_text] 765 | except KeyError: 766 | return 767 | self.gatherer.database.update_quoter(path, new_quotesource) 768 | currency = self.gatherer.database.get_currency_by_path(path) 769 | self.preferences["quotesources"][str(currency)] = new_text 770 | self.allow_fetch() 771 | self.resume_escape_handling() 772 | 773 | def on_quotesource_editing_canceled(self, *unused_a): 774 | self.allow_fetch() 775 | self.resume_escape_handling() 776 | 777 | def on_price_in_editing_started(self, unused_cell, entry, *unused_a): 778 | self.prevent_fetch() 779 | self.suspend_escape_handling() 780 | text = entry.get_text() 781 | text = text.split("\n") 782 | entry.set_text(", ".join(text)) 783 | 784 | def on_price_in_editing_done(self, unused_cell, path, new_text, *unused_a, **unused_kw): 785 | new_currencies = [ 786 | self.journal.commodity(x.strip(), True) 787 | for x in new_text.split(",") 788 | ] 789 | self.gatherer.database.update_price_in(path, new_currencies) 790 | currency = self.gatherer.database.get_currency_by_path(path) 791 | self.preferences["quotecurrencies"][str(currency)] = [ 792 | str(s) for s in new_currencies 793 | ] 794 | self.allow_fetch() 795 | self.resume_escape_handling() 796 | 797 | def on_price_in_editing_canceled(self, *unused_a): 798 | self.allow_fetch() 799 | self.resume_escape_handling() 800 | 801 | def do_fetch(self, *unused_a): 802 | self.gatherer.gather_quotes() 803 | 804 | def save_fetched_prices(self, *unused_a): 805 | UpdatePricesCommon.save_fetched_prices(self) 806 | self.emit("delete-event", None) 807 | 808 | def save_preferences(self): 809 | self.preferences.persist() 810 | 811 | def run(self): 812 | self.connect("delete-event", Gtk.main_quit) 813 | GObject.idle_add(self.show_all) 814 | Gtk.main() 815 | 816 | 817 | def main(argv): 818 | for datum in "PUBLIC_API_URL OAUTH_API_URL DATATABLES_URL".split(): 819 | if getattr(yahoo_finance.yql, datum).startswith("http:"): 820 | setattr(yahoo_finance.yql, datum, "https" + yahoo_finance.yql.PUBLIC_API_URL[4:]) 821 | 822 | p = get_argparser() 823 | args = p.parse_args(argv[1:]) 824 | ledgerhelpers.enable_debugging(args.debug) 825 | 826 | GObject.threads_init() 827 | 828 | journal, settings = gui.load_journal_and_settings_for_gui( 829 | price_file_mandatory=True 830 | ) 831 | klass = UpdatePricesApp if not args.batch else UpdatePricesCommon 832 | app = klass(journal, settings) 833 | return app.run() 834 | -------------------------------------------------------------------------------- /src/ledgerhelpers/programs/withdrawcli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import datetime 4 | import ledger 5 | import os 6 | import sys 7 | import ledgerhelpers 8 | import ledgerhelpers.legacy as common 9 | import ledgerhelpers.legacy_needsledger as common2 10 | import ledgerhelpers.journal as journal 11 | 12 | 13 | def main(): 14 | s = ledgerhelpers.Settings.load_or_defaults(os.path.expanduser("~/.ledgerhelpers.ini")) 15 | j = journal.Journal.from_file(ledgerhelpers.find_ledger_file(), None) 16 | accts, commodities = j.accounts_and_last_commodity_for_account() 17 | 18 | when = common.prompt_for_date( 19 | sys.stdin, sys.stdout, 20 | "When?", 21 | s.get("last_date", datetime.date.today()) 22 | ) 23 | if when == datetime.date.today(): 24 | del s["last_date"] 25 | else: 26 | s["last_date"] = when 27 | 28 | asset1 = common.prompt_for_account( 29 | sys.stdin, sys.stdout, 30 | accts, "From where?", 31 | s.get("last_withdrawal_account", None) 32 | ) 33 | assert asset1, "Not an account: %s" % asset1 34 | s["last_withdrawal_account"] = asset1 35 | asset1_currency = commodities.get(asset1, ledger.Amount("$ 1")) 36 | 37 | asset2 = common.prompt_for_account( 38 | sys.stdin, sys.stdout, 39 | accts, "To where?", 40 | s.get("last_deposit_account", None) 41 | ) 42 | assert asset2, "Not an account: %s" % asset2 43 | s["last_deposit_account"] = asset2 44 | asset2_currency = commodities.get(asset2, ledger.Amount("$ 1")) 45 | 46 | amount1 = common2.prompt_for_amount( 47 | sys.stdin, sys.stdout, 48 | "How much?", asset1_currency 49 | ) 50 | 51 | amount2 = common2.prompt_for_amount( 52 | sys.stdin, sys.stdout, 53 | "What was deposited?", asset2_currency 54 | ) 55 | 56 | lines = j.generate_record("Withdrawal", when, None, "", [ 57 | (asset1, -1 * amount1), 58 | (asset2, amount2), 59 | ]) 60 | print("========== Record ==========") 61 | print("\n".join(lines)) 62 | save = common.yesno( 63 | sys.stdin, sys.stderr, 64 | "Hit ENTER or y to save it to the file, BACKSPACE or n to skip saving: " 65 | ) 66 | if save: 67 | j.add_text_to_file(lines) 68 | -------------------------------------------------------------------------------- /src/ledgerhelpers/transactionstatebutton.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # coding: utf-8 3 | 4 | import gi; gi.require_version("Gtk", "3.0") 5 | from gi.repository import Gtk 6 | 7 | from ledgerhelpers import parser 8 | 9 | 10 | class TransactionStateButton(Gtk.Button): 11 | 12 | STATE_CLEARED = parser.STATE_CLEARED 13 | STATE_UNCLEARED = parser.STATE_UNCLEARED 14 | STATE_PENDING = parser.STATE_PENDING 15 | 16 | def __init__(self): 17 | Gtk.Button.__init__(self) 18 | self.label = Gtk.Label() 19 | self.add(self.label) 20 | self.state = "uninitialized" 21 | self.connect("clicked", lambda _: self._rotate_state()) 22 | self.get_style_context().add_class("circular") 23 | self._rotate_state() 24 | 25 | def _rotate_state(self): 26 | if self.state == self.STATE_UNCLEARED: 27 | self.state = self.STATE_CLEARED 28 | elif self.state == self.STATE_CLEARED: 29 | self.state = self.STATE_PENDING 30 | else: 31 | self.state = self.STATE_UNCLEARED 32 | self._reflect_state() 33 | 34 | def _reflect_state(self): 35 | addtext = "\n\nToggle this to change the transaction state." 36 | if self.state == self.STATE_UNCLEARED: 37 | self.label.set_markup("∅") 38 | self.set_tooltip_text( 39 | "This transaction is uncleared." + addtext 40 | ) 41 | elif self.state == self.STATE_CLEARED: 42 | self.label.set_markup("✻") 43 | self.set_tooltip_text( 44 | "This transaction is cleared." + addtext 45 | ) 46 | else: 47 | self.label.set_markup("!") 48 | self.set_tooltip_text( 49 | "This transaction is pending." + addtext 50 | ) 51 | 52 | def get_state(self): 53 | return self.state 54 | 55 | def get_state_char(self): 56 | if self.state == parser.STATE_CLEARED: 57 | clearing_state = parser.CHAR_CLEARED 58 | elif self.state == parser.STATE_UNCLEARED: 59 | clearing_state = "" 60 | elif self.state == parser.STATE_PENDING: 61 | clearing_state = parser.CHAR_PENDING 62 | else: 63 | assert 0, "not reached" 64 | return clearing_state 65 | 66 | def set_state(self, state): 67 | assert state in (self.STATE_CLEARED, 68 | self.STATE_PENDING, 69 | self.STATE_UNCLEARED) 70 | self.state = state 71 | self._reflect_state() 72 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rudd-O/ledgerhelpers/34fb261d7601568231d2ce5e5749419a0a34c797/tests/__init__.py -------------------------------------------------------------------------------- /tests/dogtail/addtrans.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # Dogtail test script for addtrans. 3 | 4 | import os 5 | import shlex 6 | import tempfile 7 | 8 | from dogtail import config 9 | from dogtail import tree 10 | from dogtail.procedural import type 11 | from dogtail.rawinput import keyCombo, pressKey 12 | from dogtail.utils import run 13 | 14 | 15 | os.environ['LANG'] = "en_US.UTF-8" 16 | os.environ['PYTHONPATH'] = os.path.join( 17 | os.path.dirname(__file__), 18 | os.path.pardir, 19 | os.path.pardir, 20 | 'src' 21 | ) 22 | os.environ['PATH'] = os.path.join( 23 | os.path.dirname(__file__), 24 | os.path.pardir, 25 | os.path.pardir, 26 | 'bin' 27 | ) + os.path.pathsep + os.environ['PATH'] 28 | 29 | config.config.typingDelay = 0.025 30 | 31 | t = tempfile.NamedTemporaryFile(mode="w+") 32 | t.write(""" 33 | 2015-10-05 * beer 34 | Assets:Cash -30 CHF 35 | Expenses:Drinking 30 CHF 36 | """.encode()) 37 | t.flush() 38 | t.seek(0, 0) 39 | 40 | run(shlex.join(['addtrans', '--file', t.name])) 41 | addtrans = tree.root.application('addtrans') 42 | mainwin = addtrans.window('Add transaction') 43 | 44 | try: 45 | type("wine") 46 | pressKey("Tab") 47 | type("30") 48 | pressKey("Tab") 49 | type("Expenses:Drinking") 50 | pressKey("Tab") 51 | pressKey("Tab") 52 | type("Assets:Cash") 53 | pressKey("Tab") 54 | 55 | expected = """ 56 | 2016-11-09 wine 57 | Expenses:Drinking 30 CHF 58 | Assets:Cash 59 | """ 60 | 61 | actual = mainwin.child(name='Transaction preview').children[0].text 62 | assert ( 63 | actual == expected 64 | ), ( 65 | "Transaction preview did not contain 30 CHF as expected.\n" 66 | "Expected: %r\n" 67 | "Actual: %r" % (expected, actual) 68 | ) 69 | finally: 70 | keyCombo("c") 71 | 72 | #def recurse(child, level=0): 73 | #try: 74 | #print " " * level, child.getAbsoluteSearchPath() 75 | #print " " * level, child.text 76 | #except UnicodeDecodeError: 77 | #print " " * level, "[undecodable path]" 78 | #for c in child.children: 79 | #recurse(c, level+1) 80 | 81 | #recurse(mainwin) 82 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | 4 | import ledgerhelpers as m 5 | 6 | 7 | if os.getenv('LEDGERHELPERS_TEST_DEBUG'): 8 | m.enable_debugging(True) 9 | 10 | 11 | def datapath(filename): 12 | return os.path.join(os.path.dirname(__file__), "testdata", filename) 13 | 14 | 15 | def data(filename): 16 | with codecs.open(datapath(filename), "rb", "utf-8") as f: 17 | return f.read() 18 | -------------------------------------------------------------------------------- /tests/test_ledgerhelpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import ledgerhelpers as m 3 | import ledgerhelpers.legacy as mc 4 | try: 5 | import ledgerhelpers.journal as journal 6 | except ImportError: 7 | journal = None 8 | import tests.test_base as base 9 | import tempfile 10 | import unittest 11 | from unittest import TestCase as T 12 | 13 | 14 | @unittest.skipIf(journal is None, reason="ledger-python is not available on this system") 15 | class TestJournal(T): 16 | 17 | def test_journal_with_simple_transaction(self): 18 | c = base.datapath("simple_transaction.dat") 19 | j = journal.Journal.from_file(c, None) 20 | payees = j.all_payees() 21 | self.assertListEqual(payees, ["beer"]) 22 | accts, commos = j.accounts_and_last_commodity_for_account() 23 | expaccts = ["Accounts:Cash", "Expenses:Drinking"] 24 | self.assertListEqual(accts, expaccts) 25 | self.assertEqual(commos["Expenses:Drinking"], "1.00 CHF") 26 | 27 | def test_reload_works(self): 28 | with tempfile.NamedTemporaryFile(mode="w") as f: 29 | with open(base.datapath("simple_transaction.dat")) as transaction_data: 30 | data = transaction_data.read() 31 | f.write(data) 32 | f.flush() 33 | j = journal.Journal.from_file(f.name, None) 34 | _, commos = j.accounts_and_last_commodity_for_account() 35 | self.assertEqual(commos["Expenses:Drinking"], "1.00 CHF") 36 | data = data.replace("CHF", "EUR") 37 | f.write(data) 38 | f.flush() 39 | _, commos = j.accounts_and_last_commodity_for_account() 40 | self.assertEqual(commos["Expenses:Drinking"], "1.00 EUR") 41 | 42 | def test_transactions_with_payee_match(self): 43 | c = base.datapath("simple_transaction.dat") 44 | j = journal.Journal.from_file(c, None) 45 | ts = journal.transactions_with_payee("beer", j.internal_parsing()) 46 | self.assertEqual(ts[0].payee, "beer") 47 | 48 | def test_transaction_with_zero_posting(self): 49 | c = base.datapath("zero.dat") 50 | j = journal.Journal.from_file(c, None) 51 | _, commos = j.accounts_and_last_commodity_for_account() 52 | self.assertEqual(commos["rest"], "1 USD") 53 | 54 | 55 | class TestGenerateRecord(T): 56 | 57 | def test_no_spurious_whitespace(self): 58 | title = "x" 59 | date = datetime.date(2014, 1, 1) 60 | cleared_date = None 61 | accountamounts = [ 62 | ("assets", "56 CHF"), 63 | ("expenses", ""), 64 | ] 65 | res = mc.generate_record(title, date, cleared_date, "", accountamounts) 66 | self.assertListEqual( 67 | res, 68 | """ 69 | 2014-01-01 x 70 | assets 56 CHF 71 | expenses 72 | 73 | """.splitlines()) 74 | 75 | def test_no_cleared_date_when_cleared_date_not_supplied(self): 76 | cases = [ 77 | ("2014-01-01 x", (datetime.date(2014, 1, 1), None), ""), 78 | ("2014-01-01 * x", (datetime.date(2014, 1, 1), datetime.date(2014, 1, 1)), "*"), 79 | ("2014-01-01=2015-01-01 ! x", (datetime.date(2014, 1, 1), datetime.date(2015, 1, 1)), "!"), 80 | ] 81 | accountamounts = [("assets", "56 CHF"), ("expenses", "")] 82 | for expected_line, (date, cleared), statechar in cases: 83 | res = mc.generate_record("x", date, cleared, statechar, accountamounts)[1] 84 | self.assertEqual(res, expected_line) 85 | 86 | def test_empty_record_auto_goes_last(self): 87 | accountamounts = [("expenses", ""), ("assets:cash", "56 CHF")] 88 | res = mc.generate_record("x", datetime.date(2014, 1, 1), 89 | None, "", accountamounts) 90 | self.assertListEqual( 91 | res, 92 | """ 93 | 2014-01-01 x 94 | assets:cash 56 CHF 95 | expenses 96 | 97 | """.splitlines()) 98 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import ledgerhelpers.parser as parser 3 | import tests.test_base as base 4 | from unittest import TestCase as T 5 | 6 | 7 | class TestParser(T): 8 | 9 | def test_simple_transaction(self): 10 | c = base.data("simple_transaction.dat") 11 | items = parser.lex_ledger_file_contents(c) 12 | self.assertEqual(len(items), 3) 13 | for n, tclass in enumerate([ 14 | parser.TokenWhitespace, 15 | parser.TokenTransaction, 16 | parser.TokenWhitespace, 17 | ]): 18 | self.assertIsInstance(items[n], tclass) 19 | transaction = items[1] 20 | self.assertEqual(transaction.date, datetime.date(2015, 3, 12)) 21 | self.assertEqual(transaction.clearing_date, datetime.date(2015, 3, 15)) 22 | self.assertEqual(transaction.payee, "beer") 23 | for n, (ac, am) in enumerate([ 24 | ("Accounts:Cash", "-6.00 CHF"), 25 | ("Expenses:Drinking", "6.00 CHF"), 26 | ]): 27 | self.assertEqual(transaction.postings[n].account, ac) 28 | self.assertEqual(transaction.postings[n].amount, am) 29 | 30 | def test_no_end_value(self): 31 | c = base.data("no_end_value.dat") 32 | items = parser.lex_ledger_file_contents(c) 33 | self.assertEqual(len(items), 5) 34 | for n, tclass in enumerate([ 35 | parser.TokenWhitespace, 36 | parser.TokenTransaction, 37 | parser.TokenWhitespace, 38 | parser.TokenTransaction, 39 | parser.TokenWhitespace, 40 | ]): 41 | self.assertIsInstance(items[n], tclass) 42 | for transaction in (items[1], items[3]): 43 | self.assertEqual(transaction.payee, "beer") 44 | for n, (ac, am) in enumerate([ 45 | ("Accounts:Cash", "-6.00 CHF"), 46 | ("Expenses:Drinking", ""), 47 | ]): 48 | self.assertEqual(transaction.postings[n].account, ac) 49 | self.assertEqual(transaction.postings[n].amount, am) 50 | 51 | def test_with_comments(self): 52 | c = base.data("with_comments.dat") 53 | items = parser.lex_ledger_file_contents(c) 54 | self.assertEqual(len(items), 3) 55 | for n, tclass in enumerate([ 56 | parser.TokenWhitespace, 57 | parser.TokenTransaction, 58 | parser.TokenWhitespace, 59 | ]): 60 | self.assertIsInstance(items[n], tclass) 61 | transaction = items[1] 62 | self.assertEqual(transaction.date, datetime.date(2011, 12, 25)) 63 | self.assertEqual(transaction.clearing_date, datetime.date(2011, 12, 25)) 64 | self.assertEqual(transaction.payee, "a gift!") 65 | self.assertEqual(transaction.state, parser.STATE_CLEARED) 66 | for n, (ac, am) in enumerate([ 67 | ("Assets:Metals", "1 \"silver coin\" @ $55"), 68 | ("Income:Gifts", "$ -55"), 69 | ]): 70 | self.assertEqual(transaction.postings[n].account, ac) 71 | self.assertEqual(transaction.postings[n].amount, am) 72 | 73 | def test_my_data_file(self): 74 | try: 75 | c = base.data("/home/user/.ledger") 76 | except IOError: 77 | return 78 | items = parser.lex_ledger_file_contents(c) 79 | -------------------------------------------------------------------------------- /tests/testdata/no_end_value.dat: -------------------------------------------------------------------------------- 1 | 2 | 2015-03-12=2015-03-15 * beer 3 | Accounts:Cash -6.00 CHF 4 | Expenses:Drinking 5 | 2015-03-12=2015-03-15 * beer 6 | Accounts:Cash -6.00 CHF 7 | Expenses:Drinking 8 | -------------------------------------------------------------------------------- /tests/testdata/simple_transaction.dat: -------------------------------------------------------------------------------- 1 | 2 | 2015-03-12=2015-03-15 * beer 3 | Accounts:Cash -6.00 CHF 4 | Expenses:Drinking 6.00 CHF 5 | 6 | -------------------------------------------------------------------------------- /tests/testdata/with_comments.dat: -------------------------------------------------------------------------------- 1 | 2011/12/25 * a gift! 2 | ; the price for the following purchase was assumed 3 | Assets:Metals 1 "silver coin" @ $55 4 | Income:Gifts $ -55 5 | 6 | -------------------------------------------------------------------------------- /tests/testdata/zero.dat: -------------------------------------------------------------------------------- 1 | 2022-01-01 zero 2 | ac1 1 USD 3 | ac2 -1 USD 4 | rest 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = basepython 3 | 4 | [testenv] 5 | setenv = PYTHONPATH = {toxinidir}/src 6 | # Unnecessary in a --current-env scenario. 7 | # These are already installed in the test container. 8 | # deps = 9 | # pytest 10 | # ledger 11 | commands = 12 | pytest-3 {posargs} 13 | --------------------------------------------------------------------------------