├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------