├── requirements.txt ├── examples ├── invoice-myname-MYFAVCLI001.pdf ├── clientx-hours-output.txt └── clientx-hours.txt ├── .gitignore ├── LICENSE ├── README.md ├── invoice.py └── ts.py /requirements.txt: -------------------------------------------------------------------------------- 1 | modgrammar==0.9.1 2 | python-dateutil==2.8.1 3 | pyyaml==5.1.2 4 | reportlab==3.5.32 -------------------------------------------------------------------------------- /examples/invoice-myname-MYFAVCLI001.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ses4j/ts/HEAD/examples/invoice-myname-MYFAVCLI001.pdf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | /.env 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Scott Stafford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /examples/clientx-hours-output.txt: -------------------------------------------------------------------------------- 1 | # Example hand-written timesheet. This will be parsed by ts.py and 2 | # the clientx-hours-output.txt is the cleaned-up, summed-up result. 3 | 4 | # Configuration for this timesheet, in YAML format (akin to Jekyll's "front matter") 5 | 6 | client_name: My Favorite Client 7 | footer: This goes at the bottom of my invoices. 8 | billcodes: 9 | IMPL: 10 | description: Implementation Work 11 | rate: 20 12 | ARCH: 13 | description: Architecture and Design Work 14 | rate: 12 15 | ---- 16 | 17 | == Timesheet of work for My Favorite Client == 18 | 19 | 2015-11-24 ARCH 3 11:40a-12:40p(1), 4p-5:30p(1.50), 8p-8:30p(.50) # initial plans and research 20 | 2015-11-25 ARCH 4.50 9a-10:45a(1.75), 11:30a-2:15p(2.75) # performed more work 21 | ---------- 7.50 (7.50 uninvoiced) 22 | 23 | 2015-11-30 IMPL .25 # kickoff email 24 | 2015-12-01 IMPL 6 10:45a-4:45p(6) # began work on the website 25 | 2015-12-02 IMPL 8.42 8:50a-10a(1.17), 12:15p-5:45p(5.50), 7:45p-9:30p(1.75) # built testing fraamework 26 | 2015-12-03 IMPL .68 10:09p-10:50p(.68) # respond to analysis document 27 | 2015-12-04 IMPL 3.83 .50, 1:30p-2p(.50), 3:20p-5:20p(2), 9:40a-10:30a(.83) 28 | ========== 19.18 (26.68 since invoice) # MYFAVCLI001, This is a great invoice. 29 | 30 | 31 | -------------------------------------------------------------------------------- /examples/clientx-hours.txt: -------------------------------------------------------------------------------- 1 | # Example hand-written timesheet. This will be parsed by ts.py and 2 | # the clientx-hours-output.txt is the cleaned-up, summed-up result. 3 | 4 | # Configuration for this timesheet, in YAML format (akin to Jekyll's "front matter") 5 | 6 | client_name: My Favorite Client 7 | invoice_filename_template: invoice-myname-{invoice_code}.pdf 8 | address: 9 | - Your Name 10 | - Address Line 1 11 | - Address Line 2 12 | - Email Address Here 13 | footer: 14 | - This goes at the bottom of my invoices. 15 | - So does this. 16 | billcodes: 17 | IMPL: 18 | description: Implementation Work 19 | rate: 20 20 | ARCH: 21 | description: Architecture and Design Work 22 | rate: 12 23 | ---- 24 | 25 | == Timesheet of work for My Favorite Client == 26 | 27 | 2015-11-24 ARCH 11:40a-12:40p, 4p-5:30p, 8p-8:30p # initial plans and research 28 | 2015-11-25 ARCH 9a-10:45a, 11:30a-2:15p # performed more work 29 | ----- 30 | 31 | 2015-11-30 IMPL .25 # kickoff email 32 | 2015-12-01 IMPL 10:45a-4:45p # began work on the website 33 | 2015-12-02 IMPL 8:50a-10a, 12:15p-5:45p, 7:45p-9:30p # built testing fraamework 34 | 2015-12-03 IMPL 10:09p-10:50p # respond to analysis document 35 | 2015-12-04 IMPL .50, 1:30p-2p, 3:20p-5:20p, 9:40-10:30 36 | ===== # MYFAVCLI001, This is a great invoice. 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ts 2 | 3 | **A text-based timesheet parser** 4 | *...because who has time to open Excel?* 5 | 6 | 7 | This application is intended for use by contractors to track their hours in the 8 | simplest, most programmer-friendly way possible; a human-friendly, 9 | computer-parseable text file format, one file per contract. 10 | 11 | A typical week's entry might look like this: 12 | 13 | ``` 14 | client_name: SuperCoolStartup LLC 15 | ---- 16 | 2015-11-30 .25 # kickoff email 17 | 2015-12-01 10:45a-4:45p # began work on the website 18 | ---- # first week, woo! 19 | 2015-12-02 8:50a-10a, 12:15p-5:45p, 7:45p-9:30p # built testing fraamework 20 | 2015-12-03 10:09p-10:50p # respond to analysis document 21 | 2015-12-04 .50, 1:30p-2p, 3:20p-5:20p, 9:40-10:30 22 | ==== 23 | ``` 24 | 25 | Some things to note: 26 | * All timesheets begin with Jekyll-style "front matter" to configure invoice-specific settings. 27 | * `----` tells the parser you want to summarize hours at this point. 28 | * `====` tells the parser you want to invoice at this point, chronologically. 29 | * Anything after a `#` is simply kept as a comment. 30 | 31 | `ts` will parse and canonicalize this into: 32 | 33 | ``` 34 | client_name: SuperCoolStartup LLC 35 | ---- 36 | 37 | 2015-11-30 .25 # kickoff email 38 | 2015-12-01 6 10:45a-4:45p(6) # began work on the website 39 | ---------- 6.25 (6.25 uninvoiced) # first week, woo! 40 | 2015-12-02 8.42 8:50a-10a(1.17), 12:15p-5:45p(5.50), 7:45p-9:30p(1.75) # built testing fraamework 41 | 2015-12-03 .68 10:09p-10:50p(.68) # respond to analysis document 42 | 2015-12-04 3.83 .50, 1:30p-2p(.50), 3:20p-5:20p(2), 9:40p-10:30p(.83) 43 | ========== 19.18 (26.68 since invoice) 44 | ``` 45 | 46 | ## Global Configuration 47 | 48 | User default configuration settings can go in ~/.tsconfig.yml (%USERPROFILE%\.tsconfig.yml on Windows). 49 | 50 | For instance: 51 | ``` 52 | address: 53 | - 'Scott Stafford' 54 | - '1212 Mockingbird Lane' 55 | - 'Washington, DC 20016' 56 | - '' 57 | - 'Email: scott.stafford@example.com' 58 | footer: 59 | - 'Please pay via bank transfer or check. All payments should be made in USD.' 60 | - 'Bank information for wire/direct deposit: My Bank, ABA/Routing: xxx, Acct#: yyy' 61 | - 'Make checks payable to XXX YYY' 62 | invoice_filename_template: invoice-myname-{invoice_code}.pdf 63 | 64 | # Here are some more. These are the defaults, below, but uncomment if you want to change them. 65 | # invoice_marker: ==== 66 | # invoice_on: marker 67 | # invoice_template: ========== {hours_this_week} ({hours_since_invoice} since invoice) 68 | # prefix: '' 69 | # summary_marker: '----' 70 | # summary_on: marker 71 | # verbose: 0 72 | # weekly_summary_template: '---------- {hours_this_week} ({hours_since_invoice} uninvoiced)' 73 | ``` 74 | 75 | ## Generating Invoices 76 | 77 | To generate a PDF invoice, include an invoice marker in the file where you want it 78 | and then use the `-i/--invoice` option. It will write one PDF for every `invoice_marker` 79 | (`====` by default) it finds, and include the comment in the invoice. For example: 80 | ``` 81 | ==== # MYCLIENT001, This invoice covers everything through Jan 31, 2016. 82 | ``` 83 | 84 | A file (using the `invoice_filename_template` setting) will be generated. The template supports `{invoice_code}`, which comes from the ==== comment before the first comma. 85 | 86 | Easy! 87 | 88 | ## TODO 89 | 90 | * pyinstaller http://www.pyinstaller.org/ to build executable 91 | * pdf/html/txt export? https://github.com/xhtml2pdf/xhtml2pdf? 92 | * cloud storage? 93 | -------------------------------------------------------------------------------- /invoice.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging, re, os, shutil, sys 3 | import locale 4 | 5 | from reportlab.pdfgen import canvas 6 | from reportlab.pdfgen.canvas import Canvas 7 | from reportlab.platypus import Table 8 | from reportlab.lib.pagesizes import A4 9 | from reportlab.lib.units import cm 10 | 11 | def generate_invoice(): 12 | p = canvas.Canvas("out.pdf") 13 | p.drawString(100, 100, "Hello world.") 14 | p.showPage() 15 | p.save() 16 | 17 | from reportlab.lib.units import cm 18 | # from invoice.conf import settings 19 | import importlib 20 | 21 | consultant_logo_filename = None 22 | business_details = ( 23 | 'Scott Stafford', 24 | '1212 Mockingbird Lane', 25 | 'City, ST 11111', 26 | '', 27 | 'Email: scott.stafford@example.com', 28 | ) 29 | 30 | note = ( 31 | 'PAYMENT TERMS: 30 DAYS FROM INVOICE DATE.', 32 | 'Please make all cheques payable to Your Name.', 33 | ) 34 | 35 | locale.setlocale( locale.LC_ALL, '' ) 36 | 37 | def format_currency(value, currency): 38 | return locale.currency(value, grouping=True) #"{0:C0}".format(value) 39 | 40 | def draw_header(canvas): 41 | """ Draws the invoice header """ 42 | canvas.setStrokeColorRGB(176/255., 196/255., 222/255.) 43 | # canvas.setStrokeColorRGB(0.9, 0.5, 0.2) 44 | canvas.setFillColorRGB(0.2, 0.2, 0.2) 45 | canvas.setFont('Helvetica', 16) 46 | canvas.drawString(18 * cm, -1 * cm, 'Invoice') 47 | if consultant_logo_filename: 48 | canvas.drawInlineImage(consultant_logo_filename, 1 * cm, -1 * cm, 250, 16) 49 | canvas.setLineWidth(4) 50 | canvas.line(0, -1.25 * cm, 21.7 * cm, -1.25 * cm) 51 | 52 | 53 | def draw_address(canvas, text=None): 54 | """ Draws the business address """ 55 | 56 | canvas.setFont('Helvetica', 9) 57 | textobject = canvas.beginText(13 * cm, -2.5 * cm) 58 | 59 | if text is None: 60 | text = business_details 61 | 62 | for line in text: 63 | textobject.textLine(line) 64 | canvas.drawText(textobject) 65 | 66 | 67 | def draw_footer(canvas, text=None): 68 | """ Draws the invoice footer """ 69 | note = ( 70 | 'Bank Details: Street address, Town, County, POSTCODE', 71 | 'Sort Code: 00-00-00 Account No: 00000000 (Quote invoice number).', 72 | 'Please pay via bank transfer or cheque. All payments should be made in CURRENCY.', 73 | 'Make cheques payable to Company Name Ltd.', 74 | ) 75 | if text is None: 76 | text = note 77 | textobject = canvas.beginText(1 * cm, -27 * cm) 78 | for line in text: 79 | textobject.textLine(line) 80 | canvas.drawText(textobject) 81 | 82 | 83 | # inv_module = importlib.import_module(settings.INV_MODULE) 84 | # header_func = inv_module.draw_header 85 | # address_func = inv_module.draw_address 86 | # footer_func = inv_module.draw_footer 87 | header_func = draw_header 88 | address_func = draw_address 89 | footer_func = draw_footer 90 | 91 | 92 | def draw_pdf(buffer, invoice): 93 | """ Draws the invoice """ 94 | canvas = Canvas(buffer, pagesize=A4) 95 | canvas.translate(0, 29.7 * cm) 96 | canvas.setFont('Helvetica', 10) 97 | 98 | canvas.saveState() 99 | header_func(canvas) 100 | canvas.restoreState() 101 | 102 | canvas.saveState() 103 | footer_func(canvas, invoice.footer) 104 | canvas.restoreState() 105 | 106 | canvas.saveState() 107 | address_func(canvas, invoice.address) 108 | canvas.restoreState() 109 | 110 | # Client address 111 | textobject = canvas.beginText(1.5 * cm, -2.5 * cm) 112 | for line in invoice.client_business_details: 113 | textobject.textLine(line) 114 | 115 | # if invoice.address.contact_name: 116 | # textobject.textLine(invoice.address.contact_name) 117 | # textobject.textLine(invoice.address.address_one) 118 | # if invoice.address.address_two: 119 | # textobject.textLine(invoice.address.address_two) 120 | # textobject.textLine(invoice.address.town) 121 | # if invoice.address.county: 122 | # textobject.textLine(invoice.address.county) 123 | # textobject.textLine(invoice.address.postcode) 124 | # textobject.textLine(invoice.address.country.name) 125 | canvas.drawText(textobject) 126 | 127 | # Info 128 | textobject = canvas.beginText(1.5 * cm, -6.75 * cm) 129 | textobject.textLine('Invoice ID: %s' % invoice.invoice_id) 130 | textobject.textLine('Invoice Date: %s' % invoice.invoice_date.strftime('%d %b %Y')) 131 | textobject.textLine('Client: %s' % invoice.client) 132 | 133 | for line in invoice.body_text: 134 | textobject.textLine(line) 135 | 136 | canvas.drawText(textobject) 137 | 138 | # Items 139 | data = [['Quantity', 'Description', 'Amount', 'Total'], ] 140 | for item in invoice.items: 141 | data.append([ 142 | item.quantity, 143 | item.description, 144 | format_currency(item.unit_price, invoice.currency), 145 | format_currency(item.total(), invoice.currency) 146 | ]) 147 | data.append(['', '', 'Total:', format_currency(invoice.total(), invoice.currency)]) 148 | table = Table(data, colWidths=[2 * cm, 11 * cm, 3 * cm, 3 * cm]) 149 | table.setStyle([ 150 | ('FONT', (0, 0), (-1, -1), 'Helvetica'), 151 | ('FONTSIZE', (0, 0), (-1, -1), 10), 152 | ('TEXTCOLOR', (0, 0), (-1, -1), (0.2, 0.2, 0.2)), 153 | ('GRID', (0, 0), (-1, -2), 1, (0.7, 0.7, 0.7)), 154 | ('GRID', (-2, -1), (-1, -1), 1, (0.7, 0.7, 0.7)), 155 | ('ALIGN', (-2, 0), (-1, -1), 'RIGHT'), 156 | ('BACKGROUND', (0, 0), (-1, 0), (0.8, 0.8, 0.8)), 157 | ]) 158 | tw, th, = table.wrapOn(canvas, 15 * cm, 19 * cm) 159 | table.drawOn(canvas, 1 * cm, -10 * cm - th) 160 | 161 | canvas.showPage() 162 | canvas.save() 163 | 164 | class Invoice(): 165 | def __init__(self, id, client_business_details, client_name, 166 | invoice_date=datetime.datetime.now(), 167 | currency='USD', body=None, footer=None, address=None): 168 | self.invoice_id = id 169 | self.invoice_date = invoice_date 170 | self.client = client_name 171 | self.currency = currency 172 | self.items = [] 173 | self.client_business_details = client_business_details 174 | self.footer = footer 175 | self.body_text = body 176 | self.address = address 177 | 178 | def total(self): 179 | return sum([i.total() for i in self.items]) 180 | 181 | def add_item(self, *args, **kwargs): 182 | self.items.append(Item(*args, **kwargs)) 183 | 184 | def save(self, out_filepath): 185 | if out_filepath.lower().endswith('.pdf'): 186 | draw_pdf(out_filepath, self) 187 | else: 188 | raise NotImplementedError("only .pdf") 189 | 190 | 191 | class Item(object): 192 | def __init__(self, name, qty, unit_price, description = ''): 193 | self.name = name 194 | self.description = description 195 | self.quantity = qty 196 | self.unit_price = unit_price 197 | 198 | def total(self): 199 | return self.unit_price * self.quantity 200 | 201 | class Country(): 202 | def __init__(self, name): 203 | self.name = name 204 | 205 | 206 | client_business_details = [ 207 | 'Company One', 208 | 'NYC', 209 | ] 210 | 211 | if __name__=='__main__': 212 | invoice = Invoice("VES001", client_business_details, "Company") 213 | invoice.add_item(Item('august hours', 50.25, 125.0, 'Hours for august')) 214 | draw_pdf('out.pdf', invoice) -------------------------------------------------------------------------------- /ts.py: -------------------------------------------------------------------------------- 1 | import logging, re, os, shutil, sys 2 | from datetime import date, datetime 3 | from dataclasses import dataclass 4 | from collections import defaultdict 5 | from os.path import expanduser 6 | from typing import List, Optional 7 | 8 | from dateutil.parser import parse as dateutil_parse 9 | from modgrammar import * 10 | import yaml 11 | 12 | from invoice import Invoice 13 | 14 | 15 | def get_default_settings(): 16 | settings = { 17 | 'billcode': True, 18 | 'billcodes': {}, 19 | 'billrate': 1000., 20 | 'footer': [], 21 | 'prefix': '', 22 | 'invoice_on': 'marker', 23 | 'invoice_marker': '====', 24 | 'summary_on': 'marker', 25 | 'summary_marker': '----', 26 | 'verbose': 0, 27 | 'weekly_summary_template': '---------- {hours_this_week} ({hours_since_invoice} uninvoiced)', 28 | 'invoice_template': '========== {hours_this_week} ({hours_since_invoice} since invoice)', 29 | 'invoice_filename_template': 'invoice-{invoice_code}.pdf', 30 | 'address': [] 31 | } 32 | return settings 33 | 34 | def samefile(f1, f2): 35 | ''' 36 | A simple replacement of the os.path.samefile() function not existing 37 | on the Windows platform. 38 | MAC/Unix supported in standard way :). 39 | 40 | Author: Denis Barmenkov 41 | 42 | Source: code.activestate.com/recipes/576886/ 43 | 44 | Copyright: this code is free, but if you want to use it, please 45 | keep this multiline comment along with function source. 46 | Thank you. 47 | 48 | 2009-08-19 20:13 49 | ''' 50 | try: 51 | return os.path.samefile(f1, f2) 52 | except AttributeError: 53 | f1 = os.path.abspath(f1).lower() 54 | f2 = os.path.abspath(f2).lower() 55 | return f1 == f2 56 | 57 | @dataclass 58 | class TimesheetLineItem: 59 | date: date 60 | prefix: Optional[str] = None 61 | suffix: Optional[str] = None 62 | billcode: Optional[str] = None 63 | hours: Optional[int] = None 64 | ranges: Optional[List[int]] = None 65 | 66 | # @dataclass 67 | # class TimesheetSummary: 68 | # hours: 69 | 70 | logging.basicConfig(level=logging.DEBUG) 71 | 72 | logger = logging.getLogger(__name__) 73 | 74 | grammar_whitespace_mode = 'explicit' 75 | 76 | class MyDate(Grammar): 77 | # grammar = (WORD('0-9', "-0-9/", grammar_name='date')) 78 | grammar = (WORD('2', "-0-9", fullmatch=True, grammar_name='date') 79 | | WORD('0-9', "-0-9/", grammar_name='date')) 80 | grammar_tags = ['date'] 81 | 82 | class BillCode(Grammar): 83 | """ All capital letter billing code. """ 84 | grammar = (WORD("A-Z", grammar_name='bill_code')) 85 | 86 | class Hours(Grammar): 87 | grammar = (WORD(".0-9", grammar_name='hours'), OPTIONAL("h")) 88 | 89 | class Hour(Grammar): 90 | grammar = WORD("0-9", min=1, max=2, grammar_name='hour') 91 | 92 | class Minute(Grammar): 93 | grammar = WORD("0-9", min=1, max=2, grammar_name='minute') 94 | 95 | class AMPM(Grammar): 96 | grammar = L("A") | L("P") | L("a") | L("p") 97 | 98 | class MyTime(Grammar): 99 | grammar = (G(Hour, OPTIONAL(":", Minute))), OPTIONAL(AMPM) 100 | # grammar = (WORD("0-9:", grammar_name='time'), OPTIONAL(L("A") | L("P") | L("a") | L("p"), grammar_name='ampm')) 101 | 102 | class Range(Grammar): 103 | grammar = G(MyTime, OPTIONAL(WHITESPACE), '-', OPTIONAL(WHITESPACE), 104 | OPTIONAL(MyTime), OPTIONAL('(', Hours, ')'), grammar_name='range') 105 | 106 | class RangeList(Grammar): 107 | grammar = LIST_OF(G(Range | Hours), sep=G(",", OPTIONAL(WHITESPACE)), grammar_name="ranges") 108 | 109 | class Prefix(Grammar): 110 | grammar = (ZERO_OR_MORE(L('*') | WHITESPACE), ) 111 | 112 | class Suffix(Grammar): 113 | grammar = (OPTIONAL(WHITESPACE), OPTIONAL(L('#'), REST_OF_LINE), EOF) 114 | 115 | class MyGrammar (Grammar): 116 | grammar = ( 117 | G(Prefix, MyDate, WHITESPACE, Hours, WHITESPACE, RangeList, Suffix, grammar_name="3args") | 118 | G(Prefix, MyDate, WHITESPACE, RangeList, Suffix, grammar_name="2argrange") | 119 | G(Prefix, MyDate, WHITESPACE, Hours, Suffix, grammar_name="2arghours") | 120 | G(Prefix, MyDate, WHITESPACE, BillCode, WHITESPACE, Hours, WHITESPACE, RangeList, Suffix, grammar_name="3args") | 121 | G(Prefix, MyDate, WHITESPACE, BillCode, WHITESPACE, RangeList, Suffix, grammar_name="2argrange") | 122 | G(Prefix, MyDate, WHITESPACE, BillCode, WHITESPACE, Hours, Suffix, grammar_name="2arghours") | 123 | G(Prefix, MyDate, Suffix, grammar_name="justdate") 124 | ) 125 | 126 | myparser = MyGrammar.parser() 127 | 128 | time_regex = re.compile(r'(\d{1,2})(:\d+)?([aApP])?') 129 | def parse_time(cur_date, time_str, after=None): 130 | """ Parse time 131 | 132 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '12p') 133 | datetime.datetime(2015, 6, 3, 12, 0) 134 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '12:01p') 135 | datetime.datetime(2015, 6, 3, 12, 1) 136 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '12a') 137 | datetime.datetime(2015, 6, 3, 0, 0) 138 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '1') 139 | datetime.datetime(2015, 6, 3, 13, 0) 140 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '1a') 141 | datetime.datetime(2015, 6, 3, 1, 0) 142 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '11:45p') 143 | datetime.datetime(2015, 6, 3, 23, 45) 144 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '12:45a') 145 | datetime.datetime(2015, 6, 3, 0, 45) 146 | >>> parse_time(datetime(2015, 6, 3, 0, 0), '12:45p') 147 | datetime.datetime(2015, 6, 3, 12, 45) 148 | """ 149 | 150 | m = time_regex.match(time_str) 151 | # print( "[parse_time]: " + time_str, m.groups()) 152 | if not m: 153 | return None 154 | 155 | g = m.groups() 156 | # print(g) 157 | hour = int(g[0]) 158 | minute = 0 159 | if g[1] is not None: 160 | minute = int(g[1][1:]) 161 | 162 | if g[2] is not None: 163 | if hour != 12 and g[2] in ('p','P'): 164 | hour += 12 165 | elif hour == 12 and g[2] in ('a','A'): 166 | hour -= 12 167 | else: 168 | # AM/PM not specified. 169 | time_as_am_guess = datetime(cur_date.year, cur_date.month, cur_date.day, hour=hour, minute=minute) 170 | if after is not None: 171 | if after > time_as_am_guess: 172 | hour += 12 173 | else: 174 | if hour < 7: 175 | logger.warning("Assuming time {} is PM".format(time_str)) 176 | hour += 12 177 | 178 | 179 | return datetime(cur_date.year, cur_date.month, cur_date.day, hour=hour, minute=minute) 180 | 181 | class TimesheetParseError(Exception): 182 | pass 183 | 184 | def parse(line, settings=None, prefix=None) -> Optional[TimesheetLineItem]: 185 | """ Parse grammar. 186 | >>> myparser.parse_text("5/20/2015", reset=True, eof=True) 187 | MyGrammar<'5/20/2015'> 188 | >>> parse("5/20/2015", prefix='') 189 | TimesheetLineItem(date=datetime.date(2015, 5, 20), prefix=Prefix<''>, suffix=Suffix, billcode=None, hours=None, ranges=None) 190 | >>> d = parse("6/21/2015 1.25 3:33-4:44a", prefix='') 191 | Traceback (most recent call last): 192 | ... 193 | ts.TimesheetParseError: 2015-06-21 04:44:00 < 2015-06-21 15:33:00 in 6/21/2015 1.25 3:33-4:44a 194 | >>> d = parse("5/20/2015 5 10:10 - 10:25a, 12-", prefix='') 195 | >>> d.date 196 | datetime.date(2015, 5, 20) 197 | >>> d.hours 198 | 0.25 199 | >>> len(d.ranges) 200 | 2 201 | >>> d.ranges[0]['s'] 202 | datetime.datetime(2015, 5, 20, 10, 10) 203 | >>> format_ret(d) 204 | '2015-05-20 .25 10:10a-10:25a(.25), 12p-' 205 | >>> d = parse('6/15/2015 4.25 10a-11:30(1.5), 3-5:45p(2.75)', prefix='') 206 | >>> d.ranges[1]['duration'] 207 | 2.75 208 | >>> d = parse('* 2015-06-03 1.5 10a-11:15a, 12:45p-1p, 6-6:15 # whatever yo', prefix='* ') 209 | >>> d = parse('* 7/22/2015 6.25 10:00a-11:30a(1.5), 12:30p-3:30p(3), 9:15p-11p(1.75)', prefix='* ') 210 | >>> d = parse('* 7/13/2015 3.5 .25, 1:30p-5p', prefix='* ') 211 | >>> format_ret(d) 212 | '* 2015-07-13 3.75 .25, 1:30p-5p(3.50)' 213 | """ 214 | 215 | if settings is None: 216 | settings = get_default_settings() 217 | 218 | if prefix is None: 219 | prefix = settings.get('prefix','* ') 220 | 221 | if not line.strip(): 222 | return None 223 | 224 | line = line.rstrip() 225 | origresult = myparser.parse_text(line, reset=True, eof=True) #, matchtype='longest') 226 | result = origresult.elements[0] 227 | 228 | date_g = result.get(MyDate) 229 | if date_g is None: 230 | return None 231 | 232 | cur_date = dateutil_parse(str(date_g)).date() 233 | ret = TimesheetLineItem(date=cur_date) 234 | ret.prefix = result.get(Prefix) 235 | ret.suffix = result.get(Suffix) 236 | ret.billcode = result.get(BillCode) 237 | 238 | hours_g = result.get(Hours) 239 | if hours_g is not None: 240 | ret.hours = float(str(hours_g)) 241 | 242 | ranges = result.get(RangeList) 243 | if ranges is not None: 244 | ret.ranges = [] 245 | # logger.debug(ranges.elements) 246 | 247 | for r in ranges.elements[0].elements: 248 | if r.grammar_name == 'Hours': 249 | duration = float(str(r)) 250 | ret.ranges.append( {'duration': duration} ) 251 | elif r.grammar_name == 'Range': 252 | times = r.find_all(MyTime) 253 | if len(times)==1: 254 | start = str(times[0]) 255 | end = None 256 | elif len(times)==2: 257 | start = str(times[0]) 258 | end = str(times[1]) 259 | else: 260 | raise Exception() 261 | 262 | try: 263 | parsed_start = parse_time(cur_date, start) 264 | except (ValueError, ): 265 | parsed_start = None 266 | 267 | 268 | parsed_end = None 269 | if end is not None: 270 | try: 271 | parsed_end = parse_time(cur_date, end, after=parsed_start) 272 | except (ValueError, AttributeError): 273 | pass 274 | 275 | if parsed_end is not None: 276 | if parsed_end < parsed_start: 277 | # import pdb; pdb.set_trace() 278 | raise TimesheetParseError("{} < {} in {}".format(parsed_end, parsed_start, line)) 279 | duration = (parsed_end-parsed_start).seconds/60./60. 280 | else: 281 | duration = None 282 | ret.ranges.append( {'s': parsed_start, 'e': parsed_end, 'duration': duration} ) 283 | else: 284 | pass 285 | 286 | 287 | if ret.ranges is not None: 288 | total_duration = sum([r['duration'] for r in ret.ranges if r['duration'] is not None]) 289 | if ret.hours is not None and format_hours(total_duration) != format_hours(ret.hours): 290 | logger.warning('Changing total hours from %s to %s\n Original: %s' % (ret.hours, total_duration, line)) 291 | ret.hours = total_duration 292 | 293 | if len(ret.ranges) == 1 and 's' not in ret.ranges[0]: 294 | del ret.ranges 295 | 296 | if ret.hours is not None and ret.hours > 9: 297 | logger.warning('Calculated duration={}, which is above normal\n Original: {}'.format(ret.hours, line)) 298 | 299 | if settings['verbose'] >= 2: 300 | print('= parsed={}'.format(ret)) 301 | 302 | return ret 303 | 304 | def format_hours(h): 305 | if h is None: 306 | return '-' 307 | if int(h) == h: 308 | return str(int(h)) 309 | 310 | return ("%.2f" % h).lstrip('0') 311 | 312 | def format_time(t): 313 | """ Print out succinct time. 314 | >>> format_time(datetime(2015, 1, 1, 5, 15, 0)) 315 | '5:15a' 316 | >>> format_time(datetime(2015, 1, 1, 12, 0, 0)) 317 | '12p' 318 | >>> format_time(datetime(2015, 1, 1, 0, 1, 0)) 319 | '12:01a' 320 | """ 321 | if t is None: 322 | return "" 323 | 324 | ampm = "a" 325 | if t.hour > 12: 326 | ampm = "p" 327 | hour = t.hour - 12 328 | elif t.hour == 12: 329 | ampm = "p" 330 | hour = 12 331 | elif t.hour == 0: 332 | hour = 12 333 | else: 334 | hour = t.hour 335 | 336 | if t.minute==0: 337 | s = "%d%s" % (hour, ampm) 338 | else: 339 | s = "%d:%02d%s" % (hour, t.minute, ampm) 340 | return s 341 | 342 | def format_range(r,): 343 | if 's' not in r: 344 | return '%s' % format_hours(r['duration']) 345 | else: 346 | if r['e'] is not None: 347 | return "%s-%s(%s)" % (format_time(r['s']), format_time(r['e']), format_hours(r['duration'])) 348 | else: 349 | return "%s-" % (format_time(r['s']), ) 350 | 351 | def format_ret(ret, settings=None): 352 | if settings is None: 353 | settings = get_default_settings() 354 | 355 | formatted_billcode = '' 356 | if settings['billcode']: 357 | formatted_billcode = '%5s'% (ret.billcode or '', ) 358 | 359 | if ret.ranges is None: 360 | total_duration = ret.hours 361 | output = '%10s%s %5s' % (ret.date, formatted_billcode, format_hours(total_duration)) 362 | else: 363 | parsed_ranges = ret.ranges 364 | rearranges = [format_range(r) for r in parsed_ranges] 365 | output = '%10s%s %5s %s' % (ret.date, formatted_billcode, format_hours(ret.hours), ", ".join(rearranges)) 366 | 367 | suffix = str(ret.suffix).strip() 368 | if len(suffix) > 0: 369 | suffix = " " + suffix 370 | return '%s%s%s' % (ret.prefix, output, suffix) 371 | 372 | 373 | FRONT_MATTER_TERMINUS_REGEX = re.compile('^---+$') 374 | def load_front_matter(f): 375 | """ Load jekyll-style front-matter config from top of file. 376 | """ 377 | settings = get_default_settings() 378 | 379 | def update_from_file(settings, filename): 380 | try: 381 | default_f = open(filename) 382 | except IOError: 383 | print("'{}' not found, skipping...".format(filename)) 384 | return 385 | 386 | if default_f: 387 | print("loading from '{}'...".format(filename)) 388 | default_yml_settings = yaml.safe_load(default_f) 389 | settings.update(default_yml_settings) 390 | default_f.close() 391 | 392 | update_from_file(settings, expanduser('~/.tsconfig.yml')) 393 | update_from_file(settings, 'default.yml') 394 | 395 | front_matter = [] 396 | found=False 397 | for line in f: 398 | if FRONT_MATTER_TERMINUS_REGEX.match(line): 399 | found=True 400 | break 401 | front_matter.append(line) 402 | 403 | if not found: 404 | print("Front-matter YAML is required.") 405 | sys.exit(1) 406 | 407 | fm_settings = yaml.safe_load("".join(front_matter)) 408 | settings.update(fm_settings) 409 | 410 | return settings, front_matter 411 | 412 | 413 | def process_timesheet(f, outf, verbose=0, invoice=False): 414 | global weekly_hours, invoice_hours 415 | 416 | summary_results = {} 417 | last_date = None 418 | last_iso = None 419 | 420 | invoice_has_started = False 421 | weekly_hours = 0. 422 | invoice_hours = 0. 423 | invoice_hours_per_code = defaultdict(int) 424 | 425 | def format_summary_line(): 426 | # global weekly_hours, invoice_hours, 427 | weekly_summary_template = settings['prefix'] + settings['weekly_summary_template'] 428 | return weekly_summary_template.format( 429 | hours_this_week=format_hours(weekly_hours), 430 | hours_since_invoice=format_hours(invoice_hours)) 431 | 432 | def write_summary_line(invoice=False, original_line=''): 433 | global weekly_hours, invoice_hours 434 | if invoice: 435 | template = settings['invoice_template'] 436 | else: 437 | template = settings['weekly_summary_template'] 438 | 439 | summary_line = settings['prefix'] + template.format( 440 | hours_this_week=format_hours(weekly_hours), 441 | hours_since_invoice=format_hours(invoice_hours)) 442 | 443 | original_line_split = original_line.split('#', 1) 444 | comment = '' 445 | if len(original_line_split)==2: 446 | comment = original_line_split[-1].strip() 447 | summary_line += ' # ' + comment 448 | 449 | invoice_id, invoice_description = '', '' 450 | try: 451 | invoice_id, invoice_description = comment.split(',', 1) 452 | except: 453 | pass 454 | 455 | if invoice: 456 | invoice_data = {'id': invoice_id, 'hours': invoice_hours, 'items': [], 'description': invoice_description.strip()} 457 | for k,v in invoice_hours_per_code.items(): 458 | invoice_data['items'].append({'billcode': k, 'hours': v}) 459 | invoices.append(invoice_data) 460 | 461 | if weekly_hours != 0. or invoice: 462 | if settings['verbose'] >= 1: 463 | print(summary_line) 464 | if outf: 465 | outf.write(summary_line + '\n') 466 | weekly_hours = 0. 467 | 468 | if invoice: 469 | invoice_hours = 0. 470 | invoice_hours_per_code.clear() 471 | 472 | if outf: 473 | outf.write('\n') 474 | 475 | def write_final_summary_line(): 476 | 477 | if outf: 478 | outf.write('\n') 479 | if outf: 480 | outf.write('\n') 481 | 482 | 483 | settings, raw_front_matter = load_front_matter(f) 484 | if args.verbose is not None: 485 | settings['verbose'] = args.verbose 486 | 487 | # logger.info("settings {}".format(settings)) 488 | 489 | for line in raw_front_matter: 490 | outf.write(line) 491 | 492 | # yaml.dump(settings, outf, default_flow_style=False) 493 | outf.write('----\n') 494 | 495 | invoices = [] 496 | 497 | for line in f: 498 | if settings['verbose'] >= 1: 499 | print('< {}'.format(line.rstrip())) 500 | 501 | try: 502 | if invoice_has_started: 503 | # Found an invoice marker, so rewrite it... 504 | if settings['invoice_on'] == 'marker' and line.startswith(settings['invoice_marker']): 505 | write_summary_line(invoice=True, original_line=line) 506 | if settings['verbose'] >= 1: 507 | print("> Wrote summary line".format()) 508 | continue 509 | 510 | # Throw out empty lines 511 | if line.strip() == '': 512 | continue 513 | 514 | # Just throw out old summary lines.. we'll write them again ourselves. 515 | if settings['summary_on'] == 'marker': 516 | if line.startswith(settings['summary_marker']): 517 | write_summary_line(original_line=line) 518 | continue 519 | else: 520 | if line.startswith(format_summary_line()): 521 | continue 522 | 523 | ret = parse(line, settings) 524 | if ret is None: 525 | if settings['verbose'] >= 1 and line.strip() != '': 526 | print("> Failed to parse. Writing straight.".format()) 527 | if outf: 528 | outf.write(line.rstrip() + '\n') 529 | continue 530 | 531 | if not invoice_has_started and settings['verbose'] >= 1: 532 | print("! Invoice has started!") 533 | invoice_has_started = True 534 | if last_date is not None and last_date > ret.date: 535 | logger.warning('Date {} is listed after date {}.'.format(ret.date, last_date)) 536 | if ret.date in summary_results: 537 | logger.warning('Date {} listed multiple times.'.format(ret.date)) 538 | 539 | iso = ret.date.isocalendar() 540 | if settings['summary_on'] == 'weekly': 541 | if last_iso is not None and (iso[0] != last_iso[0] or iso[1] != last_iso[1]): 542 | write_summary_line() 543 | 544 | last_date = ret.date 545 | last_iso = iso 546 | weekly_hours += ret.hours 547 | invoice_hours += ret.hours 548 | invoice_hours_per_code[str(ret.billcode or '')] += ret.hours 549 | 550 | summary_results[ret.date] = ret 551 | 552 | fixed_line = format_ret(ret, settings) 553 | if settings['verbose'] >= 1: 554 | print(">", fixed_line) 555 | if outf: 556 | outf.write(fixed_line.rstrip() + '\n') 557 | 558 | except TimesheetParseError: 559 | print("Problem parsing.") 560 | raise 561 | except ParseError: 562 | if outf: 563 | outf.write(line.rstrip() + '\n') 564 | 565 | # print 'skipped...' 566 | pass 567 | # logger.exception("failed to parse") 568 | # raise 569 | write_summary_line() 570 | if outf: 571 | outf.close() 572 | 573 | print("{} hours uninvoiced currently...".format(format_hours(invoice_hours))) 574 | 575 | if args.invoice: 576 | for i in invoices: 577 | invoice = Invoice(i['id'], [], settings['client_name'], footer=settings['footer'], body=[i['description']], address=settings['address']) 578 | for item in i['items']: 579 | if settings['billcode']: 580 | billcode_data = settings['billcodes'][item.billcode] 581 | else: 582 | billcode_data = settings['billcodes']['default'] 583 | 584 | invoice.add_item( 585 | name=billcode_data['description'], 586 | qty=round(item.hours, 2), 587 | unit_price=billcode_data['rate'], 588 | description=billcode_data['description']) 589 | 590 | invoice_filename_template = settings['invoice_filename_template'] 591 | invoice_filename = invoice_filename_template.format( 592 | invoice_code=i['id'], 593 | client_name=settings['client_name'] 594 | ) 595 | 596 | invoice.save(invoice_filename) 597 | print("Wrote invoice to {}".format(invoice_filename)) 598 | 599 | 600 | if __name__=='__main__': 601 | import argparse 602 | 603 | parser = argparse.ArgumentParser(description='Process a timesheet') 604 | parser.add_argument('file', metavar='FILE') 605 | parser.add_argument('-v', '--verbose', action='count', default=None) 606 | parser.add_argument('-i', '--invoice', action='store_true', help='Write PDF invoice.') 607 | parser.add_argument('-o', '--out', default=None, help="Defaults to overwrite -f FILE.") 608 | 609 | args = parser.parse_args() 610 | 611 | if args.out is None: 612 | args.out = args.file 613 | 614 | input_filename = args.file 615 | output_filename = args.out 616 | is_inplace = samefile(input_filename, output_filename) 617 | 618 | backup_input_filename = input_filename + '.backup' 619 | shutil.copyfile(input_filename, backup_input_filename) 620 | 621 | if not is_inplace: 622 | backup_output_filename = output_filename + '.backup' 623 | shutil.copyfile(output_filename, backup_output_filename) 624 | 625 | copy_to_on_completion = None 626 | 627 | if is_inplace: 628 | real_output_filename = output_filename 629 | output_filename = output_filename + '.temp_outfile' 630 | 631 | with open(input_filename) as f, open(output_filename, 'w') as outf: 632 | try: 633 | process_timesheet(f=f, outf=outf, verbose=args.verbose, invoice=args.invoice) 634 | success = True 635 | except Exception as exc: 636 | logger.exception("Crash while processing timesheet.") 637 | success = False 638 | 639 | if success: 640 | if is_inplace: 641 | shutil.copyfile(output_filename, real_output_filename) 642 | os.unlink(output_filename) 643 | print("Success!") 644 | else: 645 | print("Crash while processing timesheet. The input failed to process (but is unharmed).") 646 | 647 | 648 | --------------------------------------------------------------------------------