├── .gitignore ├── .python-version ├── CHANGELOG ├── LICENSE ├── Makefile ├── README.rst ├── TODO ├── img └── description.png ├── requirements.in ├── setup.cfg ├── setup.py └── timeflow ├── __init__.py ├── cli.py ├── main.py ├── settings.py ├── stats.py ├── tests ├── fake_log.txt └── tests.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### OSX ### 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | 31 | ### Python ### 32 | # Byte-compiled / optimized / DLL files 33 | __pycache__/ 34 | *.py[cod] 35 | 36 | # C extensions 37 | *.so 38 | 39 | # Distribution / packaging 40 | .Python 41 | env/ 42 | build/ 43 | develop-eggs/ 44 | dist/ 45 | downloads/ 46 | eggs/ 47 | .eggs/ 48 | lib/ 49 | lib64/ 50 | parts/ 51 | sdist/ 52 | var/ 53 | *.egg-info/ 54 | .installed.cfg 55 | *.egg 56 | 57 | # PyInstaller 58 | # Usually these files are written by a python script from a template 59 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 60 | *.manifest 61 | *.spec 62 | 63 | # Installer logs 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | 67 | # Unit test / coverage reports 68 | htmlcov/ 69 | .tox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *,cover 76 | 77 | # Translations 78 | *.mo 79 | *.pot 80 | 81 | # Django stuff: 82 | *.log 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # PyBuilder 88 | target/ 89 | 90 | ### Misc ### 91 | tags 92 | 93 | # genereated by pip-tools from `requirements.in` 94 | requirements.txt 95 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.7.0 2 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | [0.2.6] 2 | 3 | -- Add `--filter-projects` and `--exclude-projects` options 4 | 5 | [0.2.5] 6 | 7 | -- Add settings file in `~/.config/timeflow/settings.ini` 8 | -- Add `--email` command to send reports via email 9 | -- Uses settings file for smtp settings 10 | 11 | [0.2.4] 12 | 13 | -- "Empty" version due to some mishap with publishing to PYPI 14 | 15 | [0.2.3] 16 | 17 | -- Add `--report-as-gtimelog` to `stats` command 18 | -- Separated `stats` command logic into `stats.py` and utilities into `utils.py` 19 | 20 | [0.2.2] 21 | 22 | -- Allow to have $EDITOR with arguments [#1] 23 | 24 | [0.2.1] 25 | 26 | -- Fix typo in log file name 27 | -- Add `stats --this-month` option 28 | 29 | [0.2] 30 | 31 | -- refactored into more sane file configuration 32 | -- no need to prematurely divide project into some not known parts, 33 | now there is 1 file for argument parser and 1 file for time logging logic 34 | -- use pytest 35 | -- show how much time have passed since the work started 36 | -- `stats --this-week` option, to show current week time stats 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Justas Trimailovas 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: env install 2 | dev: env pip-tools pip install 3 | 4 | .PHONY: env 5 | env: 6 | pyvenv env 7 | 8 | .PHONY: pip-tools 9 | pip-tools: 10 | env/bin/pip install --upgrade pip 11 | env/bin/pip install pip-tools 12 | 13 | .PHONY: pip 14 | pip: pip-compile 15 | env/bin/pip-sync requirements.txt 16 | 17 | .PHONY: pip-compile 18 | pip-compile: 19 | env/bin/pip-compile requirements.in 20 | 21 | .PHONY: test 22 | test: 23 | env/bin/py.test timeflow/tests/tests.py 24 | 25 | .PHONY: coverage 26 | coverage: 27 | env/bin/py.test --cov=timeflow --cov-report=html timeflow/tests/tests.py 28 | 29 | .PHONY: install 30 | install: 31 | env/bin/pip install -r requirements.txt 32 | env/bin/pip install --editable . 33 | 34 | .PHONY: uninstall 35 | uninstall: 36 | env/bin/pip uninstall timeflow 37 | 38 | .PHONY: tags 39 | tags: 40 | ctags -R 41 | 42 | .PHONY: clean 43 | clean: remove_pyc 44 | rm -rf env timeflow.egg-info tags 45 | 46 | .PHONY: remove_pyc 47 | remove_pyc: 48 | find . -name "*.pyc" -delete 49 | find . -name "__pycache__" -delete 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | timeflow 2 | ======== 3 | simple CLI time logger, inspired by `gtimelog `_ 4 | 5 | .. image:: img/description.png 6 | 7 | Description 8 | ----------- 9 | ``timeflow`` is a simple CLI time logger, used for logging your activities and 10 | featuring simple statistics and reporting capabilities 11 | 12 | ``timeflow`` can be called using either ``tf`` or ``timeflow`` commands 13 | 14 | Install 15 | ------- 16 | 17 | ``pip3 install timeflow`` 18 | 19 | Written in ``python3``. Best user experience with ``python3``. 20 | 21 | Tutorial 22 | ----------------- 23 | :: 24 | 25 | to start working (message content is not important) 26 | >>> tf log "Arrived." 27 | 28 | to save a timestamp and your log message, 29 | when finished doing a task write 30 | >>> tf log "Timeflow: create README.rst" 31 | 32 | here 'Timeflow' is a 'project' you were working on and 'create README.rst' 33 | is a log of what you were exactly doing this time. Both project and log 34 | must be separated by a colon and space (``: ``). 35 | 36 | some tasks are not (payable) work, mark them with two asterisks (**) 37 | >>> tf log "Slack: chatting in the office ** " 38 | 39 | you can have 'projects' without any explanations 40 | >>> tf log "Lunch ** " 41 | >>> tf log "Daily Scrum" 42 | 43 | if you made a mistake, or missed to log of your activities 44 | you can edit like this 45 | >>> tf edit 46 | 47 | this way timeflow will try to run your editor set in $EDITOR 48 | or you can try 49 | >>> tf edit -e vim 50 | 51 | to open log file in vim 52 | 53 | to check how much you've worked today 54 | >>> tf stats 55 | 56 | or to make a report 57 | >>> tf stats --report 58 | 59 | you can pass date ranges for stats command, e.g. 60 | >>> tf stats --from 2015-01-01 --to 2015-01-31 61 | >>> tf stats --from 2015-01-01 --to 2015-01-31 --report 62 | 63 | Commands & options 64 | ------------------ 65 | ``log`` 66 | ``log LOG_TEXT`` - create new log entry to timeflow's log file. 67 | 68 | ``edit`` 69 | opens timeflow's log file, by default trying to open an editor used in ``$EDITOR`` environment variable. 70 | 71 | ``-e EDITOR`` - passes editor to be used in opening log file. 72 | 73 | ``stats`` 74 | shows today's work and slack time. 75 | 76 | ``-y, --yesterday`` - shows yesterday's work and slack time. 77 | 78 | ``-d DATE, --day DATE`` - shows arbitrary day's work and slack time. 79 | 80 | ``--week WEEK_NUMBER`` - shows arbitrary week's work and slack time. 81 | 82 | ``--this-week`` - shows this week's work and slack time. 83 | 84 | ``--last-week`` - shows last week's work and slack time. 85 | 86 | ``--month MONTH_NUMBER`` - shows arbitrary month's work and slack time. 87 | 88 | ``--this-month`` - shows this month's work and slack time. 89 | 90 | ``--last-month`` - shows last month's work and slack time. 91 | 92 | ``-f DATE, --from DATE`` - shows work and slack time, from DATE until today, if ``--to`` option is not used. 93 | 94 | ``-t DATE, --to DATE`` - shows work and slack time, up to DATE. Must be used with ``--from`` option. 95 | 96 | ``--report`` - shows report for today, or some other time range if specified using available options. 97 | 98 | ``--report-as-gtimelog`` - same as ``--report``, but the output is like in `gtimelog `_ 99 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | # REFACTORING 2 | 3 | - Use `click` 4 | - Use `tox` 5 | - 100% test coverage 6 | 7 | # MISC 8 | 9 | - `--version, -V` option 10 | - Set editor from user settings 11 | - Better `--month`, `--week` value parsing and help 12 | -------------------------------------------------------------------------------- /img/description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trimailov/timeflow/c77d448456eb55cb45ff2a594280d793abb4c2fc/img/description.png -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | ipdb 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | 5 | # Utility function to read the README file. 6 | # Used for the long_description. It's nice, because now 1) we have a top level 7 | # README file and 2) it's easier to type in the README file than to put a raw 8 | # string in below ... 9 | def read(fname): 10 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 11 | 12 | 13 | setup( 14 | name='timeflow', 15 | packages=['timeflow'], 16 | version='0.2.6', 17 | description='Small CLI time logger', 18 | 19 | author='Justas Trimailovas', 20 | author_email='j.trimailovas@gmail.com', 21 | 22 | url='https://github.com/trimailov/timeflow', 23 | license='MIT', 24 | keywords=['timelogger', 'logging', 'timetracker', 'tracker'], 25 | 26 | long_description=read('README.rst'), 27 | 28 | entry_points=''' 29 | [console_scripts] 30 | timeflow=timeflow.main:main 31 | tf=timeflow.main:main 32 | ''', 33 | ) 34 | -------------------------------------------------------------------------------- /timeflow/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution 2 | 3 | 4 | __version__ = get_distribution("timeflow").version 5 | -------------------------------------------------------------------------------- /timeflow/cli.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import os 3 | import sys 4 | import subprocess 5 | 6 | from argparse import ArgumentParser 7 | 8 | from timeflow import stats as statistics 9 | from timeflow import utils 10 | 11 | 12 | def log(args): 13 | utils.write_to_log_file(args.message) 14 | 15 | 16 | def _call_editor(editor, filename): 17 | editor = editor.split() 18 | subprocess.call(editor + [utils.LOG_FILE]) 19 | 20 | 21 | def edit(args): 22 | if args.editor: 23 | _call_editor(args.editor, utils.LOG_FILE) 24 | else: 25 | subprocess.call(['echo', 'Trying to open $EDITOR']) 26 | if os.environ.get('EDITOR'): 27 | _call_editor(os.environ.get('EDITOR'), utils.LOG_FILE) 28 | else: 29 | subprocess.call([ 30 | "echo", 31 | "Set your default editor in EDITOR environment variable or \n" 32 | "call edit command with -e option and pass your editor:\n" 33 | "timeflow edit -e vim", 34 | ]) 35 | 36 | 37 | def stats(args): 38 | today = False 39 | date_from = date_to = None 40 | email_time_range = None 41 | literal_time_range = '' 42 | filter_projects = [] 43 | exclude_projects = [] 44 | if args.yesterday: 45 | yesterday_obj = dt.datetime.now() - dt.timedelta(days=1) 46 | date_from = date_to = yesterday_obj.strftime(utils.DATE_FORMAT) 47 | email_time_range = "day" 48 | elif args.day: 49 | date_from = date_to = args.day 50 | email_time_range = "day" 51 | elif args.week: 52 | date_from, date_to = utils.get_week_range(args.week) 53 | literal_time_range = "this week" 54 | email_time_range = "week" 55 | elif args.this_week: 56 | date_from, date_to = utils.get_this_week() 57 | literal_time_range = "this week" 58 | email_time_range = "week" 59 | elif args.last_week: 60 | date_from, date_to = utils.get_last_week() 61 | literal_time_range = "this week" 62 | email_time_range = "week" 63 | elif args.month: 64 | date_from, date_to = utils.get_month_range(args.month) 65 | literal_time_range = "this month" 66 | email_time_range = "month" 67 | elif args.this_month: 68 | date_from, date_to = utils.get_this_month() 69 | literal_time_range = "this month" 70 | email_time_range = "month" 71 | elif args.last_month: 72 | date_from, date_to = utils.get_last_month() 73 | literal_time_range = "this month" 74 | email_time_range = "month" 75 | elif args._from and not args.to: 76 | date_from = args._from 77 | date_to = dt.datetime.now().strftime(utils.DATE_FORMAT) 78 | elif args._from and args.to: 79 | date_from = args._from 80 | date_to = args.to 81 | else: 82 | # default action is to show today's stats 83 | date_from = date_to = dt.datetime.now().strftime(utils.DATE_FORMAT) 84 | email_time_range = "day" 85 | today = True 86 | 87 | if args.filter_projects: 88 | filter_projects = [str(item) for item in args.filter_projects.split(',')] 89 | if args.exclude_projects: 90 | exclude_projects = [str(item) for item in args.exclude_projects.split(',')] 91 | 92 | if args.report or args.report_as_gtimelog: 93 | work_report, slack_report = statistics.calculate_report( 94 | utils.read_log_file_lines(), 95 | date_from, 96 | date_to, 97 | filter_projects=filter_projects, 98 | exclude_projects=exclude_projects, 99 | ) 100 | if args.report: 101 | output = statistics.create_full_report(work_report, slack_report) 102 | elif args.report_as_gtimelog: 103 | output = statistics.create_report_as_gtimelog( 104 | work_report, 105 | literal_time_range=literal_time_range, 106 | ) 107 | 108 | print(output) 109 | 110 | if args.email: 111 | statistics.email_report(date_from, date_to, output, 112 | email_time_range=email_time_range) 113 | 114 | # do not print current working time if it's a report 115 | if not any((args.report, args.report_as_gtimelog)): 116 | work_time, slack_time, today_work_time = statistics.calculate_stats( 117 | utils.read_log_file_lines(), date_from, date_to, today=today 118 | ) 119 | print(statistics.get_total_stats_times(work_time, slack_time, today_work_time)) 120 | 121 | 122 | def create_parser(): 123 | parser = ArgumentParser() 124 | subparser = parser.add_subparsers() 125 | 126 | # `log` command 127 | log_parser = subparser.add_parser( 128 | "log", 129 | help="Log your time and explanation for it", 130 | ) 131 | log_parser.add_argument( 132 | "message", 133 | help="The message which explains your spent time", 134 | ) 135 | log_parser.set_defaults(func=log) 136 | 137 | # `edit` command 138 | edit_parser = subparser.add_parser( 139 | "edit", 140 | help="Open editor to fix/edit the time log", 141 | ) 142 | edit_parser.add_argument("-e", "--editor", help="Use some editor") 143 | edit_parser.set_defaults(func=edit) 144 | 145 | # `stats` command 146 | stats_parser = subparser.add_parser( 147 | "stats", 148 | help="Show how much time was spent working or slacking" 149 | ) 150 | stats_parser.add_argument( 151 | "--today", 152 | action="store_true", 153 | help="Show today's work times (default)" 154 | ) 155 | stats_parser.add_argument( 156 | "-y", "--yesterday", 157 | action="store_true", 158 | help="Show yesterday's work times" 159 | ) 160 | stats_parser.add_argument( 161 | "-d", "--day", 162 | help="Show specific day's work times" 163 | ) 164 | stats_parser.add_argument( 165 | "--week", 166 | help="Show specific week's work times" 167 | ) 168 | stats_parser.add_argument( 169 | "--this-week", 170 | action="store_true", 171 | help="Show current week's work times" 172 | ) 173 | stats_parser.add_argument( 174 | "--last-week", 175 | action="store_true", 176 | help="Show last week's work times" 177 | ) 178 | stats_parser.add_argument( 179 | "--month", 180 | help="Show specific month's work times" 181 | ) 182 | stats_parser.add_argument( 183 | "--this-month", 184 | action="store_true", 185 | help="Show current month's work times" 186 | ) 187 | stats_parser.add_argument( 188 | "--last-month", 189 | action="store_true", 190 | help="Show last month's work times" 191 | ) 192 | stats_parser.add_argument( 193 | "-f", "--from", 194 | help="Show work times from specific date", 195 | dest="_from" 196 | ) 197 | stats_parser.add_argument( 198 | "-t", "--to", 199 | help="Show work times from to specific date" 200 | ) 201 | stats_parser.add_argument( 202 | "-r", "--report", 203 | action="store_true", 204 | help="Show stats in report form" 205 | ) 206 | stats_parser.add_argument( 207 | "--report-as-gtimelog", 208 | action="store_true", 209 | help="Show stats in gtimelog report form" 210 | ) 211 | stats_parser.add_argument( 212 | "--email", 213 | action="store_true", 214 | help="Send generated report to activity email" 215 | ) 216 | stats_parser.add_argument( 217 | "--filter-projects", 218 | nargs="?", 219 | help="Filter list of projects included in report" 220 | ) 221 | stats_parser.add_argument( 222 | "--exclude-projects", 223 | nargs="?", 224 | help="Exclude list of projects from report" 225 | ) 226 | stats_parser.set_defaults(func=stats) 227 | 228 | # pass every argument to parser, except the program name 229 | return parser 230 | 231 | 232 | def cli(): 233 | parser = create_parser() 234 | args = parser.parse_args(sys.argv[1:]) 235 | # if nothing is passed - print help 236 | if hasattr(args, "func"): 237 | args.func(args) 238 | else: 239 | parser.print_help() 240 | -------------------------------------------------------------------------------- /timeflow/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from timeflow import cli 4 | from timeflow.settings import Settings 5 | 6 | 7 | def main(): 8 | settings = Settings() 9 | config_file = settings.get_config_file() 10 | 11 | # create config file directory if it does not exist 12 | if not os.path.exists(os.path.dirname(config_file)): 13 | os.makedirs(os.path.dirname(config_file)) 14 | 15 | # create settings config file if it does not exists and save settings there 16 | if not os.path.exists(config_file): 17 | open(config_file, 'a').close() 18 | settings.save() 19 | print('Settings file at {} was created!'.format(config_file)) 20 | cli.cli() 21 | -------------------------------------------------------------------------------- /timeflow/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from configparser import ConfigParser 4 | 5 | 6 | class Settings(): 7 | name = "Jon Doe" 8 | activity_email = "activity@example.com" 9 | email_address = "jondoe@example.com" 10 | email_user = "jondoe" 11 | email_password = "mypassword" 12 | smtp_server = "smtp.myserver.com" 13 | smtp_port = 25 14 | 15 | def config(self): 16 | config = ConfigParser() 17 | config["timeflow"] = { 18 | "name": self.name, 19 | "activity_email": self.activity_email, 20 | "email_address": self.email_address, 21 | "email_user": self.email_user, 22 | "email_password": self.email_password, 23 | "smtp_server": self.smtp_server, 24 | "smtp_port": self.smtp_port, 25 | } 26 | return config 27 | 28 | def get_config_file(self): 29 | return os.path.join(os.path.expanduser('~'), '.config', 'timeflow', 'settings.ini') 30 | 31 | def load(self): 32 | config_file = self.get_config_file() 33 | config = self.config() 34 | config.read(config_file) 35 | self.name = config['timeflow']['name'] 36 | self.activity_email = config['timeflow']['activity_email'] 37 | self.email_address = config['timeflow']['email_address'] 38 | self.email_user = config['timeflow']['email_user'] 39 | self.email_password = config['timeflow']['email_password'] 40 | self.smtp_server = config['timeflow']['smtp_server'] 41 | self.smtp_port = config['timeflow']['smtp_port'] 42 | 43 | def save(self): 44 | config_file = self.get_config_file() 45 | config = self.config() 46 | with open(config_file, 'w') as f: 47 | config.write(f) 48 | -------------------------------------------------------------------------------- /timeflow/stats.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import smtplib 3 | 4 | from collections import defaultdict 5 | from collections import OrderedDict 6 | 7 | from timeflow.settings import Settings 8 | from timeflow.utils import DATE_FORMAT 9 | from timeflow.utils import DATETIME_FORMAT 10 | from timeflow.utils import calc_time_diff 11 | from timeflow.utils import date_begins 12 | from timeflow.utils import date_ends 13 | from timeflow.utils import format_duration_long 14 | from timeflow.utils import format_duration_short 15 | from timeflow.utils import get_time 16 | from timeflow.utils import parse_lines 17 | from timeflow.utils import strip_log 18 | 19 | 20 | def get_total_stats_times(work_time, slack_time, today_work_time): 21 | """ 22 | Returns string output for totals times spent working and slacking 23 | """ 24 | output = 'Work: {}\n'.format(format_duration_short(sum(work_time))) 25 | output += 'Slack: {}'.format(format_duration_short(sum(slack_time))) 26 | 27 | if today_work_time: 28 | today_hours, today_minutes = get_time(today_work_time) 29 | output += '\n\nToday working for: {}'.format( 30 | format_duration_short(today_work_time) 31 | ) 32 | return output 33 | 34 | 35 | def create_report(report_dict): 36 | """ 37 | Returns string output for stats report 38 | """ 39 | output = "" 40 | 41 | report_dict = OrderedDict(sorted(report_dict.items())) 42 | for project in report_dict: 43 | project_output = "{}:\n".format(project) 44 | project_report = report_dict[project] 45 | total_seconds = 0 46 | for log in project_report: 47 | log_seconds = project_report[log] 48 | total_seconds += log_seconds 49 | 50 | # if log is empty - just state the project name 51 | if not log: 52 | log = project 53 | 54 | project_output += " {time}: {log}\n".format( 55 | time=format_duration_long(log_seconds), 56 | log=log 57 | ) 58 | project_output += " Total: {time}\n".format( 59 | time=format_duration_long(total_seconds), 60 | ) 61 | output += project_output 62 | output += '\n' 63 | 64 | # remove trailing newlines as they may add up in the pipeline 65 | return output.strip('\n') 66 | 67 | 68 | def create_full_report(work_report_dict, slack_report_dict): 69 | """ 70 | Returns report for both - work and slack 71 | """ 72 | output = "" 73 | work_report = create_report(work_report_dict) 74 | slack_report = create_report(slack_report_dict) 75 | output += "{:-^67s}\n".format(" WORK ") 76 | output += work_report 77 | output += "\n" # I want empty line between work and slack report 78 | output += "{:-^67s}\n".format(" SLACK ") 79 | output += slack_report 80 | return output 81 | 82 | 83 | def create_report_as_gtimelog(report_dict, literal_time_range=''): 84 | """ 85 | Returns string output for report which is generated as in gtimelog 86 | """ 87 | output = "" 88 | project_totals_output = "" 89 | output += "{}{}\n".format(" " * 64, "time") 90 | 91 | report_dict = OrderedDict(sorted(report_dict.items())) 92 | total_seconds = 0 93 | for project in report_dict: 94 | total_project_seconds = 0 95 | project_report = report_dict[project] 96 | for log in project_report: 97 | entry = "{}: {}".format(project, log) 98 | seconds = project_report[log] 99 | time_string = format_duration_short(seconds) 100 | output += "{:62s} {}\n".format(entry, time_string) 101 | total_project_seconds += seconds 102 | project_totals_output += "{:62s} {}\n".format(project, format_duration_short(total_project_seconds)) 103 | total_seconds += total_project_seconds 104 | 105 | output += "\n" 106 | output += "Total work done{}{}: {}\n\n".format( 107 | ' ' if literal_time_range else '', # add space if time range exists 108 | literal_time_range, 109 | format_duration_short(total_seconds) 110 | ) 111 | output += "By category:\n\n" 112 | output += project_totals_output 113 | 114 | return output 115 | 116 | 117 | def calculate_stats(lines, date_from, date_to, today=False): 118 | work_time = [] 119 | slack_time = [] 120 | today_work_time = None 121 | 122 | line_begins = date_begins(lines, date_from) 123 | line_ends = date_ends(lines, date_to) 124 | 125 | date_not_found = (line_begins is None or line_ends < line_begins) 126 | if date_not_found: 127 | return work_time, slack_time, today_work_time 128 | 129 | data = parse_lines() 130 | 131 | for i, line in enumerate(data[line_begins:line_ends + 1]): 132 | # if we got to the last line - stop 133 | if line_begins + i + 1 > line_ends: 134 | break 135 | 136 | next_line = data[line_begins + i + 1] 137 | 138 | line_date = line.date 139 | next_line_date = next_line.date 140 | 141 | # if it's day switch, skip this cycle 142 | if line_date != next_line_date: 143 | continue 144 | 145 | if next_line.is_slack: 146 | slack_time.append(calc_time_diff(line, next_line)) 147 | else: 148 | work_time.append(calc_time_diff(line, next_line)) 149 | 150 | if today: 151 | today_start_time = dt.datetime.strptime( 152 | "{} {}".format(data[line_begins].date, data[line_begins].time), 153 | DATETIME_FORMAT 154 | ) 155 | today_work_time = (dt.datetime.now() - today_start_time).seconds 156 | 157 | return work_time, slack_time, today_work_time 158 | 159 | 160 | def calculate_report(lines, date_from, date_to, 161 | filter_projects=[], 162 | exclude_projects=[]): 163 | """Creates and returns report dictionaries 164 | 165 | Report dicts have form like this: 166 | {: {: }, 167 | {: }} 168 | """ 169 | # XXX: need to check that same project is not in both: filters and excludes 170 | work_dict = defaultdict(lambda: defaultdict(dict)) 171 | slack_dict = defaultdict(lambda: defaultdict(dict)) 172 | 173 | line_begins = date_begins(lines, date_from) 174 | line_ends = date_ends(lines, date_to) 175 | 176 | date_not_found = (line_begins is None or line_ends < line_begins) 177 | if date_not_found: 178 | return work_dict, slack_dict 179 | 180 | data = parse_lines() 181 | 182 | for i, line in enumerate(data[line_begins:line_ends + 1]): 183 | # if we got to the last line - stop 184 | if line_begins + i + 1 > line_ends: 185 | break 186 | 187 | next_line = data[line_begins + i + 1] 188 | 189 | line_date = line.date 190 | next_line_date = next_line.date 191 | 192 | # if it's day switch, skip this cycle 193 | if line_date != next_line_date: 194 | continue 195 | 196 | time_diff = calc_time_diff(line, next_line) 197 | 198 | project = strip_log(next_line.project) 199 | 200 | if project_should_be_in_report(project, filter_projects, exclude_projects): 201 | log = strip_log(next_line.log) 202 | if next_line.is_slack: 203 | # if log message is identical add time_diff 204 | # to total time of the log 205 | if slack_dict[project][log]: 206 | total_time = slack_dict[project][log] 207 | total_time += time_diff 208 | slack_dict[project][log] = total_time 209 | else: 210 | slack_dict[project][log] = time_diff 211 | else: 212 | if work_dict[project][log]: 213 | total_time = work_dict[project][log] 214 | total_time += time_diff 215 | work_dict[project][log] = total_time 216 | else: 217 | work_dict[project][log] = time_diff 218 | 219 | return work_dict, slack_dict 220 | 221 | 222 | def project_should_be_in_report(project, filters, excludes): 223 | if project in filters: 224 | return True 225 | elif project in excludes: 226 | return False 227 | elif filters == []: 228 | return True 229 | elif excludes == []: 230 | return False 231 | 232 | 233 | def get_daily_report_subject(day, person): 234 | """ 235 | Returns subject string for daily report email 236 | 237 | `day:datetime.date` - date of the day we are reporting for 238 | `person:str` - reporting person's name, e.g. 'Jon Doe' 239 | """ 240 | # it's possible to use strftime('%a'), but it's locale sensitive, 241 | # and I do not want this 242 | weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] 243 | calendar_time = "{weekday}, week {week:02}".format( 244 | weekday=weekday_names[day.isocalendar()[2]], 245 | week=day.isocalendar()[1], 246 | ) 247 | subject = "{day} report for {person} ({calendar_time})".format( 248 | day=day.strftime(DATE_FORMAT), 249 | person=person, 250 | calendar_time=calendar_time 251 | ) 252 | return subject 253 | 254 | 255 | def get_weekly_report_subject(week_day, person): 256 | """ 257 | Returns subject string for weekly report email 258 | 259 | `week_day:datetime.date` - any date for the week we are reporting for 260 | `person:str` - reporting person's name, e.g. 'Jon Doe' 261 | """ 262 | calendar_time = "week {:02}".format(week_day.isocalendar()[1]) 263 | subject = "Weekly report for {person} ({calendar_time})".format( 264 | person=person, 265 | calendar_time=calendar_time 266 | ) 267 | return subject 268 | 269 | 270 | def get_monthly_report_subject(month_day, person): 271 | """ 272 | Returns subject string for monthly report email 273 | 274 | `month_day:datetime.date` - any date for the month we are reporting for 275 | `person:str` - reporting person's name, e.g. 'Jon Doe' 276 | """ 277 | calendar_time = "{year}/{month:02}".format( 278 | year=month_day.year, 279 | month=month_day.month 280 | ) 281 | subject = "Monthly report for {person} ({calendar_time})".format( 282 | person=person, 283 | calendar_time=calendar_time 284 | ) 285 | return subject 286 | 287 | 288 | def get_custom_range_report_subject(date_from, date_to, person): 289 | subject = "Custom date range report for {person} ({_from:%Y-%m-%d} - {to:%Y-%m-%d})".format( 290 | person=person, 291 | _from=date_from, 292 | to=date_to, 293 | ) 294 | return subject 295 | 296 | 297 | def email_report(date_from, date_to, report, email_time_range=None): 298 | settings = Settings() 299 | settings.load() 300 | 301 | sender = settings.email_address 302 | receivers = [settings.activity_email] 303 | 304 | date_from_time_range = dt.datetime.strptime(date_from, DATE_FORMAT) 305 | subject = '' 306 | if email_time_range == 'day': 307 | subject = get_daily_report_subject(date_from_time_range, settings.name) 308 | elif email_time_range == 'week': 309 | subject = get_weekly_report_subject(date_from_time_range, settings.name) 310 | elif email_time_range == 'month': 311 | subject = get_monthly_report_subject(date_from_time_range, settings.name) 312 | else: 313 | # convert date strings to datetime objects 314 | _date_from = dt.datetime.strptime(date_from, DATE_FORMAT) 315 | _date_to = dt.datetime.strptime(date_to, DATE_FORMAT) 316 | subject = get_custom_range_report_subject(_date_from, _date_to, settings.name) 317 | full_subject = "[Activity] {}".format(subject) 318 | 319 | message = ( 320 | "From: {}\n" 321 | "To: {}\n" 322 | "Subject: {}\n\n" 323 | "{}" 324 | ).format(sender, ", ".join(receivers), full_subject, report) 325 | 326 | try: 327 | conn = smtplib.SMTP(settings.smtp_server, settings.smtp_port) 328 | conn.ehlo() 329 | conn.starttls() 330 | conn.login(settings.email_user, settings.email_password) 331 | conn.sendmail(sender, receivers, message) 332 | print("Successfully sent email") 333 | except smtplib.SMTPException: 334 | print("Error: unable to send email") 335 | -------------------------------------------------------------------------------- /timeflow/tests/fake_log.txt: -------------------------------------------------------------------------------- 1 | 2014-12-24 08:00: Arrived. 2 | 2014-12-24 09:15: Christmas: wrapping presents 3 | 2014-12-24 10:00: Breakfast ** 4 | 2014-12-24 10:25: Slack: do nothing ** 5 | 2014-12-24 12:00: Pytest: read the docs 6 | 7 | 2014-12-31 08:00: Arrived. 8 | 2014-12-31 09:15: New-year: prepairing for party 9 | 2014-12-31 10:00: Breakfast ** 10 | 2014-12-31 10:25: Slack: do nothing ** 11 | 2014-12-31 12:00: Books: read them 12 | 13 | 2015-01-01 08:00: Arrived. 14 | 2015-01-01 09:15: Timeflow: start project 15 | 2015-01-01 10:00: Breakfast ** 16 | 2015-01-01 10:25: Slack: watch YouTube** 17 | 2015-01-01 12:00: Django: read documentation 18 | 19 | 2015-01-02 08:25: Arrived. 20 | 2015-01-02 09:15: Timeflow: create README file 21 | 2015-01-02 10:00: Work: finish task #115 22 | 2015-01-02 10:25: Slack: break ** 23 | 2015-01-02 12:00: Work: working on task #42 24 | 2015-01-02 13:05: Lunch ** 25 | -------------------------------------------------------------------------------- /timeflow/tests/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import pytest 5 | 6 | import timeflow.utils 7 | from timeflow import cli 8 | 9 | FAKE_TIME = datetime.datetime(2015, 1, 1, 23, 59, 59) 10 | 11 | 12 | @pytest.fixture 13 | def patch_datetime_now(monkeypatch): 14 | 15 | class mydatetime(datetime.datetime): 16 | @classmethod 17 | def now(cls): 18 | return FAKE_TIME 19 | 20 | monkeypatch.setattr(datetime, 'datetime', mydatetime) 21 | 22 | 23 | def test_patch_datetime(patch_datetime_now): 24 | assert datetime.datetime.now() == FAKE_TIME 25 | 26 | 27 | def test_log(patch_datetime_now, tmpdir, capsys): 28 | tmp_path = tmpdir.join("test_log.txt").strpath 29 | timeflow.utils.LOG_FILE = tmp_path 30 | 31 | # run log command 32 | parser = cli.create_parser() 33 | args = parser.parse_args(['log', 'message']) 34 | args.func(args) 35 | 36 | with open(tmp_path, 'r') as f: 37 | lines = f.readlines() 38 | assert len(lines) == 1 39 | assert lines[0] == '2015-01-01 23:59: message\n' 40 | 41 | 42 | def test_edit(patch_datetime_now, tmpdir, capsys): 43 | test_dir = os.path.dirname(os.path.realpath(__file__)) 44 | 45 | # overwrite log file setting, to define file to be used in tests 46 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 47 | 48 | # run edit command 49 | parser = cli.create_parser() 50 | args = parser.parse_args(['edit']) 51 | args.func(args) 52 | 53 | 54 | def test_stats_now(patch_datetime_now, capsys): 55 | test_dir = os.path.dirname(os.path.realpath(__file__)) 56 | 57 | # overwrite log file setting, to define file to be used in tests 58 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 59 | 60 | # run stats command 61 | parser = cli.create_parser() 62 | args = parser.parse_args(['stats']) 63 | args.func(args) 64 | 65 | # extract STDOUT, as stats command prints to it 66 | out, err = capsys.readouterr() 67 | result = ("Work: 2 hours 50 min\n" 68 | "Slack: 1 hour 10 min\n" 69 | "\n" 70 | "Today working for: 15 hours 59 min\n") 71 | assert out == result 72 | 73 | 74 | def test_stats_yesterday(patch_datetime_now, capsys): 75 | test_dir = os.path.dirname(os.path.realpath(__file__)) 76 | 77 | # overwrite log file setting, to define file to be used in tests 78 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 79 | 80 | # run stats command 81 | parser = cli.create_parser() 82 | args = parser.parse_args(['stats', '--yesterday']) 83 | args.func(args) 84 | 85 | # extract STDOUT, as stats command prints to it 86 | out, err = capsys.readouterr() 87 | result = ("Work: 2 hours 50 min\n" 88 | "Slack: 1 hour 10 min\n") 89 | assert out == result 90 | 91 | 92 | def test_stats_day(patch_datetime_now, capsys): 93 | test_dir = os.path.dirname(os.path.realpath(__file__)) 94 | 95 | # overwrite log file setting, to define file to be used in tests 96 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 97 | 98 | # run stats command 99 | parser = cli.create_parser() 100 | args = parser.parse_args(['stats', '--day', '2015-01-01']) 101 | args.func(args) 102 | 103 | # extract STDOUT, as stats command prints to it 104 | out, err = capsys.readouterr() 105 | result = ("Work: 2 hours 50 min\n" 106 | "Slack: 1 hour 10 min\n") 107 | assert out == result 108 | 109 | 110 | def test_stats_this_week(patch_datetime_now, capsys): 111 | test_dir = os.path.dirname(os.path.realpath(__file__)) 112 | 113 | # overwrite log file setting, to define file to be used in tests 114 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 115 | 116 | # run stats command 117 | parser = cli.create_parser() 118 | args = parser.parse_args(['stats', '--this-week']) 119 | args.func(args) 120 | 121 | # extract STDOUT, as stats command prints to it 122 | out, err = capsys.readouterr() 123 | result = ("Work: 8 hours 50 min\n" 124 | "Slack: 3 hours 50 min\n") 125 | assert out == result 126 | 127 | 128 | def test_stats_last_week(patch_datetime_now, capsys): 129 | test_dir = os.path.dirname(os.path.realpath(__file__)) 130 | 131 | # overwrite log file setting, to define file to be used in tests 132 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 133 | 134 | # run stats command 135 | parser = cli.create_parser() 136 | args = parser.parse_args(['stats', '--last-week']) 137 | args.func(args) 138 | 139 | # extract STDOUT, as stats command prints to it 140 | out, err = capsys.readouterr() 141 | result = ("Work: 2 hours 50 min\n" 142 | "Slack: 1 hour 10 min\n") 143 | assert out == result 144 | 145 | 146 | def test_stats_week(patch_datetime_now, capsys): 147 | test_dir = os.path.dirname(os.path.realpath(__file__)) 148 | 149 | # overwrite log file setting, to define file to be used in tests 150 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 151 | 152 | # run stats command 153 | parser = cli.create_parser() 154 | args = parser.parse_args(['stats', '--week', '2015-01-01']) 155 | args.func(args) 156 | 157 | # extract STDOUT, as stats command prints to it 158 | out, err = capsys.readouterr() 159 | result = ("Work: 8 hours 50 min\n" 160 | "Slack: 3 hours 50 min\n") 161 | assert out == result 162 | 163 | 164 | def test_stats_last_month(patch_datetime_now, capsys): 165 | test_dir = os.path.dirname(os.path.realpath(__file__)) 166 | 167 | # overwrite log file setting, to define file to be used in tests 168 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 169 | 170 | # run stats command 171 | parser = cli.create_parser() 172 | args = parser.parse_args(['stats', '--last-month']) 173 | args.func(args) 174 | 175 | # extract STDOUT, as stats command prints to it 176 | out, err = capsys.readouterr() 177 | result = ("Work: 5 hours 40 min\n" 178 | "Slack: 2 hours 20 min\n") 179 | assert out == result 180 | 181 | 182 | def test_stats_this_month(patch_datetime_now, capsys): 183 | test_dir = os.path.dirname(os.path.realpath(__file__)) 184 | 185 | # overwrite log file setting, to define file to be used in tests 186 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 187 | 188 | # run stats command 189 | parser = cli.create_parser() 190 | args = parser.parse_args(['stats', '--this-month']) 191 | args.func(args) 192 | 193 | # extract STDOUT, as stats command prints to it 194 | out, err = capsys.readouterr() 195 | result = ("Work: 2 hours 50 min\n" 196 | "Slack: 1 hour 10 min\n") 197 | assert out == result 198 | 199 | 200 | def test_stats_month(patch_datetime_now, capsys): 201 | test_dir = os.path.dirname(os.path.realpath(__file__)) 202 | 203 | # overwrite log file setting, to define file to be used in tests 204 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 205 | 206 | # run stats command 207 | parser = cli.create_parser() 208 | args = parser.parse_args(['stats', '--month', '1']) 209 | args.func(args) 210 | 211 | # extract STDOUT, as stats command prints to it 212 | out, err = capsys.readouterr() 213 | result = ("Work: 6 hours\n" 214 | "Slack: 2 hours 40 min\n") 215 | assert out == result 216 | 217 | 218 | def test_stats_from(patch_datetime_now, capsys): 219 | test_dir = os.path.dirname(os.path.realpath(__file__)) 220 | 221 | # overwrite log file setting, to define file to be used in tests 222 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 223 | 224 | # run stats command 225 | parser = cli.create_parser() 226 | args = parser.parse_args(['stats', '--from', '2014-12-28']) 227 | args.func(args) 228 | 229 | # extract STDOUT, as stats command prints to it 230 | out, err = capsys.readouterr() 231 | result = ("Work: 5 hours 40 min\n" 232 | "Slack: 2 hours 20 min\n") 233 | assert out == result 234 | 235 | 236 | def test_stats_from_to(patch_datetime_now, capsys): 237 | test_dir = os.path.dirname(os.path.realpath(__file__)) 238 | 239 | # overwrite log file setting, to define file to be used in tests 240 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 241 | 242 | # run stats command 243 | parser = cli.create_parser() 244 | args = parser.parse_args(['stats', '--from', '2014-12-24', 245 | '--to', '2015-01-01']) 246 | args.func(args) 247 | 248 | # extract STDOUT, as stats command prints to it 249 | out, err = capsys.readouterr() 250 | result = ("Work: 8 hours 30 min\n" 251 | "Slack: 3 hours 30 min\n") 252 | assert out == result 253 | 254 | 255 | def test_stats_now_report(patch_datetime_now, capsys): 256 | test_dir = os.path.dirname(os.path.realpath(__file__)) 257 | 258 | # overwrite log file setting, to define file to be used in tests 259 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 260 | 261 | # run stats command 262 | parser = cli.create_parser() 263 | args = parser.parse_args(['stats', '--report']) 264 | args.func(args) 265 | 266 | # extract STDOUT, as stats command prints to it 267 | out, err = capsys.readouterr() 268 | result = ( 269 | "------------------------------ WORK -------------------------------\n" 270 | "Django:\n" 271 | " 1 hour 35 min: read documentation\n" 272 | " Total: 1 hour 35 min\n" 273 | "\n" 274 | "Timeflow:\n" 275 | " 1 hour 15 min: start project\n" 276 | " Total: 1 hour 15 min\n" 277 | "------------------------------ SLACK ------------------------------\n" 278 | "Breakfast:\n" 279 | " 0 hours 45 min: Breakfast\n" 280 | " Total: 0 hours 45 min\n" 281 | "\n" 282 | "Slack:\n" 283 | " 0 hours 25 min: watch YouTube\n" 284 | " Total: 0 hours 25 min\n" 285 | ) 286 | assert out == result 287 | 288 | 289 | def test_stats_now_report_as_gtimelog(patch_datetime_now, capsys): 290 | test_dir = os.path.dirname(os.path.realpath(__file__)) 291 | 292 | # overwrite log file setting, to define file to be used in tests 293 | timeflow.utils.LOG_FILE = test_dir + '/fake_log.txt' 294 | 295 | # run stats command 296 | parser = cli.create_parser() 297 | args = parser.parse_args(['stats', '--report-as-gtimelog']) 298 | args.func(args) 299 | 300 | # extract STDOUT, as stats command prints to it 301 | out, err = capsys.readouterr() 302 | result = ( 303 | " time\n" 304 | "Django: read documentation 1 hour 35 min\n" 305 | "Timeflow: start project 1 hour 15 min" 306 | "\n" 307 | "\n" 308 | "Total work done: 2 hours 50 min" 309 | "\n" 310 | "\n" 311 | "By category:" 312 | "\n" 313 | "\n" 314 | "Django 1 hour 35 min\n" 315 | "Timeflow 1 hour 15 min\n\n" 316 | ) 317 | assert out == result 318 | -------------------------------------------------------------------------------- /timeflow/utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import datetime as dt 3 | import os 4 | import re 5 | import sys 6 | 7 | # SETTINGS 8 | LOG_FILE = os.path.expanduser('~') + '/.timeflow' 9 | DATETIME_FORMAT = "%Y-%m-%d %H:%M" 10 | DATE_FORMAT = "%Y-%m-%d" 11 | # length of date string 12 | DATE_LEN = 10 13 | # length of datetime string 14 | DATETIME_LEN = 16 15 | 16 | 17 | def write_to_log_file(message): 18 | """ 19 | Writes message to the `LOG_FILE` 20 | 21 | `message`: String 22 | """ 23 | log_message = form_log_message(message) 24 | if not os.path.exists(os.path.dirname(LOG_FILE)): 25 | os.makedirs(os.path.dirname(LOG_FILE)) 26 | with open(LOG_FILE, 'a') as fp: 27 | fp.write(log_message) 28 | 29 | 30 | def form_log_message(message): 31 | """ 32 | Joins current time with the log message 33 | 34 | `message`: String 35 | """ 36 | time_str = dt.datetime.now().strftime(DATETIME_FORMAT) 37 | log_message = ': '.join((time_str, message)) 38 | 39 | # we want easily seeable separation between the days in the log file 40 | if is_another_day(): 41 | log_message = '\n' + log_message 42 | return log_message + '\n' 43 | 44 | 45 | def is_another_day(): 46 | """ 47 | Checks if new message is written in the next day, than the last log entry. 48 | """ 49 | try: 50 | f = open(LOG_FILE, 'r') 51 | last_line = f.readlines()[-1] 52 | except (IOError, IndexError): 53 | return False 54 | 55 | last_log_date = last_line[:DATE_LEN] 56 | 57 | # if message date is other day than last log entry return True, else False 58 | if dt.datetime.now().strftime(DATE_FORMAT) != last_log_date: 59 | return True 60 | else: 61 | return False 62 | 63 | 64 | def find_date_line(lines, date_to_find, reverse=False): 65 | """ 66 | Returns index of line, which matches `date_to_find` 67 | """ 68 | len_lines = len(lines) - 1 69 | if reverse: 70 | lines = reversed(lines) 71 | for i, line in enumerate(lines): 72 | date_obj = dt.datetime.strptime(line[:DATE_LEN], DATE_FORMAT) 73 | date_to_find_obj = dt.datetime.strptime(date_to_find, DATE_FORMAT) 74 | 75 | if reverse and date_obj <= date_to_find_obj: 76 | return len_lines - i 77 | elif not reverse and date_obj >= date_to_find_obj: 78 | return i 79 | 80 | 81 | def date_begins(lines, date_to_find): 82 | "Returns first line out of lines, with date_to_find" 83 | return find_date_line(lines, date_to_find) 84 | 85 | 86 | def date_ends(lines, date_to_find): 87 | "Returns last line out of lines, with date_to_find" 88 | return find_date_line(lines, date_to_find, reverse=True) 89 | 90 | 91 | def get_time(seconds): 92 | hours = seconds // 3600 93 | minutes = seconds % 3600 // 60 94 | return hours, minutes 95 | 96 | 97 | def format_duration_short(seconds): 98 | """ 99 | Formats seconds into hour and minute string 100 | 101 | Does not return hour or minute substring if the value is zero 102 | """ 103 | h, m = get_time(seconds) 104 | if h and m: 105 | return '%d hour%s %d min' % (h, h != 1 and "s" or "", m) 106 | elif h: 107 | return '%d hour%s' % (h, h != 1 and "s" or "") 108 | else: 109 | return '%d min' % m 110 | 111 | 112 | def format_duration_long(seconds): 113 | """ 114 | Formats seconds into hour and minute string 115 | 116 | Always returns full string, even if hours or minutes may be zero 117 | """ 118 | h, m = get_time(seconds) 119 | return '%d hour%s %d min' % (h, h != 1 and "s" or "", m) 120 | 121 | 122 | def get_this_week(): 123 | now = dt.datetime.now() 124 | 125 | weekday = now.isocalendar()[2] - 1 126 | this_monday = now - dt.timedelta(days=weekday) 127 | this_sunday = this_monday + dt.timedelta(days=6) 128 | 129 | date_from = this_monday.strftime(DATE_FORMAT) 130 | date_to = this_sunday.strftime(DATE_FORMAT) 131 | return date_from, date_to 132 | 133 | 134 | def get_last_week(): 135 | week_ago = dt.datetime.now() - dt.timedelta(weeks=1) 136 | 137 | weekday = week_ago.isocalendar()[2] - 1 138 | last_monday = week_ago - dt.timedelta(days=weekday) 139 | last_sunday = last_monday + dt.timedelta(days=6) 140 | 141 | date_from = last_monday.strftime(DATE_FORMAT) 142 | date_to = last_sunday.strftime(DATE_FORMAT) 143 | return date_from, date_to 144 | 145 | 146 | def get_week_range(date): 147 | date = dt.datetime.strptime(date, DATE_FORMAT) 148 | 149 | weekday = date.isocalendar()[2] - 1 150 | monday = date - dt.timedelta(days=weekday) 151 | sunday = monday + dt.timedelta(days=6) 152 | 153 | date_from = monday.strftime(DATE_FORMAT) 154 | date_to = sunday.strftime(DATE_FORMAT) 155 | return date_from, date_to 156 | 157 | 158 | def parse_month_arg(arg): 159 | def is_int(arg): 160 | try: 161 | int(arg) 162 | return True 163 | except ValueError: 164 | return False 165 | 166 | if is_int(arg): 167 | # if it's only integer - it's only month number 168 | month = int(arg) 169 | if month < 1 or month > 12: 170 | sys.exit('Month must be in range from 1 to 12') 171 | return dt.datetime.now().year, month 172 | 173 | # otherwise argument must be in form 'YYYY-MM' 174 | year, month = arg.split('-') 175 | if is_int(year) and is_int(month): 176 | month = int(month) 177 | if month < 1 or month > 12: 178 | sys.exit('Month must be in range from 1 to 12') 179 | return int(year), month 180 | else: 181 | sys.exit('Argument in form of YYYY-MM is expected, e.g. 2015-9') 182 | 183 | 184 | def get_month_range(arg): 185 | year, month = parse_month_arg(arg) 186 | days_in_month = calendar.monthrange(year, month)[1] 187 | 188 | date_from = '{}-{:02}-01'.format(year, month) 189 | date_to = '{}-{:02}-{:02}'.format(year, month, days_in_month) 190 | 191 | return date_from, date_to 192 | 193 | 194 | def get_this_month(): 195 | now = dt.datetime.now() 196 | 197 | date_from = now.replace(day=1).strftime(DATE_FORMAT) 198 | date_to = now.strftime(DATE_FORMAT) 199 | 200 | return date_from, date_to 201 | 202 | 203 | def get_last_month(): 204 | current_month = dt.datetime.now().replace(day=1) 205 | last_month = current_month - dt.timedelta(days=1) 206 | arg = "{}-{}".format(last_month.year, last_month.month) 207 | return get_month_range(arg) 208 | 209 | 210 | class Line(): 211 | def __init__(self, date, time, project, log, is_slack): 212 | self.date = date 213 | self.time = time 214 | self.project = project 215 | self.log = log 216 | self.is_slack = is_slack 217 | 218 | 219 | def clean_line(time, project, log): 220 | "Cleans line data from unnecessary chars" 221 | # time has extra colon at the end, so we remove it 222 | time = time[:-1] 223 | 224 | # project and log can have new line char at the end, remove it 225 | if project and project[-1] == '\n': 226 | project = project[:-1] 227 | 228 | if log and log[-1] == '\n': 229 | log = log[:-1] 230 | 231 | return time, project, log 232 | 233 | 234 | def parse_message(message): 235 | "Parses message as log can be empty" 236 | parsed_message = re.split(r': ', message, maxsplit=1) 237 | 238 | # if parsed message has only project stated, then log is empty 239 | if len(parsed_message) == 1: 240 | if type(parsed_message) == list: 241 | project = parsed_message[0] 242 | else: 243 | project = parsed_message 244 | log = '' 245 | else: 246 | project, log = parsed_message 247 | 248 | return project, log 249 | 250 | 251 | def find_slack(project, log): 252 | if project.endswith("**") or log.endswith("**"): 253 | return True 254 | return False 255 | 256 | 257 | def strip_log(string): 258 | "Strips string from slack marks and leading/trailing spaces" 259 | if string.endswith("**"): 260 | string = string[:-2] 261 | return string.strip() 262 | 263 | 264 | def parse_line(line): 265 | """Parses log line into logical units: time, project and message 266 | 267 | Log line looks like this: 268 | [date]_[time]:_[project]:_[log message] 269 | """ 270 | # get date time and the rest of a message 271 | date, time, message = re.split(r' ', line, maxsplit=2) 272 | 273 | project, log = parse_message(message) 274 | time, project, log = clean_line(time, project, log) 275 | is_slack = find_slack(project, log) 276 | 277 | return Line(date, time, project, log, is_slack) 278 | 279 | 280 | def parse_lines(): 281 | """Returns a list of objects representing log file""" 282 | lines = read_log_file_lines() 283 | data = [] 284 | for line in lines: 285 | data.append(parse_line(line)) 286 | return data 287 | 288 | 289 | def calc_time_diff(line, next_line): 290 | line_time = dt.datetime.strptime( 291 | "{} {}".format(line.date, line.time), 292 | DATETIME_FORMAT 293 | ) 294 | next_line_time = dt.datetime.strptime( 295 | "{} {}".format(next_line.date, next_line.time), 296 | DATETIME_FORMAT 297 | ) 298 | return (next_line_time - line_time).seconds 299 | 300 | 301 | def read_log_file_lines(): 302 | with open(LOG_FILE, 'r') as fp: 303 | return [line for line in fp.readlines() if line != '\n'] 304 | --------------------------------------------------------------------------------