├── test ├── __init__.py ├── unit │ ├── __init__.py │ ├── report │ │ ├── __init__.py │ │ └── test_formatter.py │ ├── test_report.py │ ├── test_parse_date.py │ └── test_entry.py └── integration │ ├── utt_example_plugin │ ├── utt │ │ ├── __init__.py │ │ └── plugins │ │ │ ├── __init__.py │ │ │ ├── foo_command.py │ │ │ └── report_view.py │ └── setup.py │ ├── data │ ├── report │ │ └── dayname │ ├── stretch │ │ ├── stdout │ │ ├── before.log │ │ └── after.log │ ├── utt-no-current-activity.log │ ├── hello │ │ └── utt.log │ ├── utt-upper-case.log │ ├── add │ │ └── utt.log │ ├── utt-overnight.log │ ├── utt-report-project-per-day-csv.csv │ ├── utt-report-per-day-csv.csv │ ├── utt-1.log │ ├── utt-overnight-range.stdout │ ├── utt-no-current-activity.stdout │ ├── utt-overnight-2.stdout │ ├── utt-report-project.log │ ├── utt-report-per-task-csv.csv │ ├── utt-report-project-per-day.stdout │ ├── utt-hello-only-today.stdout │ ├── utt-range.stdout │ ├── utt-overnight.stdout │ ├── utt-report-month.stdout │ ├── utt-report-truncate-current-activity.stdout │ ├── utt-report-week-current.stdout │ ├── utt-report-project.stdout │ ├── utt-report-per-day.stdout │ ├── utt-example-plugin-report.stdout │ ├── utt-upper-case.stdout │ ├── utt-1.stdout │ ├── utt-report-details.stdout │ └── utt-report-comments.stdout │ ├── Dockerfile.template │ └── Makefile ├── utt ├── __init__.py ├── version.txt ├── plugins │ ├── __init__.py │ ├── 0_hello.py │ ├── 0_edit.py │ ├── 0_default_report_view.py │ ├── 0_add.py │ ├── 0_config.py │ ├── 0_stretch.py │ └── 0_report.py ├── report │ ├── __init__.py │ ├── activities │ │ ├── __init__.py │ │ ├── view.py │ │ └── model.py │ ├── details │ │ ├── __init__.py │ │ ├── model.py │ │ └── view.py │ ├── per_day │ │ ├── __init__.py │ │ ├── model.py │ │ └── view.py │ ├── projects │ │ ├── __init__.py │ │ ├── view.py │ │ └── model.py │ ├── summary │ │ ├── __init__.py │ │ ├── model.py │ │ └── view.py │ ├── formatter.py │ ├── csv_view.py │ └── common.py ├── components │ ├── __init__.py │ ├── report_model │ │ ├── __init__.py │ │ └── model.py │ ├── output.py │ ├── commands.py │ ├── report_view.py │ ├── now.py │ ├── config.py │ ├── config_filename.py │ ├── data_dirname.py │ ├── data_filename.py │ ├── config_dirname.py │ ├── default_config.py │ ├── entry_lines.py │ ├── entry_parser.py │ ├── parse_args.py │ ├── entries.py │ ├── add_entry.py │ ├── activities.py │ └── report_args.py ├── data_structures │ ├── __init__.py │ ├── entry.py │ ├── name.py │ └── activity.py ├── api │ ├── __init__.py │ └── _v1 │ │ ├── __init__.py │ │ └── _private.py ├── __version__.py ├── command.py ├── constants.py └── __main__.py ├── .github ├── FUNDING.yml └── workflows │ ├── pull_request.yml │ └── publish.yml ├── .flake8 ├── .gitignore ├── scripts ├── update_version_txt.py ├── update_version_in_pyproject.py └── publish.py ├── pyproject.toml ├── docs ├── CONTRIBUTING.md ├── PLUGINS.md └── DEVELOPMENT.md ├── Makefile ├── CHANGELOG.md ├── README.md ├── LICENSE └── poetry.lock /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/version.txt: -------------------------------------------------------------------------------- 1 | 0 -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/report/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/report/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/data_structures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/report/activities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/report/details/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/report/per_day/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/report/projects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/report/summary/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: larose 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /test/integration/utt_example_plugin/utt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/api/__init__.py: -------------------------------------------------------------------------------- 1 | from . import _v1 # noqa: F401 2 | -------------------------------------------------------------------------------- /test/integration/utt_example_plugin/utt/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utt/components/report_model/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .model import ReportModel 3 | -------------------------------------------------------------------------------- /utt/components/output.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | 4 | class Output(io.TextIOWrapper): 5 | pass 6 | -------------------------------------------------------------------------------- /test/integration/data/report/dayname: -------------------------------------------------------------------------------- 1 | ----------------------- Thursday, Feb 12, 2015 (week 7) ------------------------ 2 | -------------------------------------------------------------------------------- /test/integration/data/stretch/stdout: -------------------------------------------------------------------------------- 1 | stretched 2014-01-01 09:00 programming 2 | → 2014-01-01 10:00 programming 3 | -------------------------------------------------------------------------------- /utt/components/commands.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from ..command import Command 4 | 5 | Commands = typing.List[Command] 6 | -------------------------------------------------------------------------------- /test/integration/data/utt-no-current-activity.log: -------------------------------------------------------------------------------- 1 | 2018-08-19 08:00 hello 2 | 2018-08-19 09:00 a 3 | 4 | 2018-08-20 08:00 hello 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | .mypy_cache/ 4 | Dockerfile.generated 5 | dist 6 | test/integration/utt-*.whl 7 | utt/version.txt 8 | -------------------------------------------------------------------------------- /test/integration/data/hello/utt.log: -------------------------------------------------------------------------------- 1 | 2014-01-01 08:00 hello 2 | 2014-01-01 09:00 hello 3 | 4 | 2014-01-02 08:00 hello 5 | 2014-01-02 09:00 hello 6 | -------------------------------------------------------------------------------- /test/integration/data/stretch/before.log: -------------------------------------------------------------------------------- 1 | 2013-01-01 08:00 hello 2 | 2013-01-01 09:00 programming 3 | 4 | 2014-01-01 08:00 hello 5 | 2014-01-01 09:00 programming 6 | -------------------------------------------------------------------------------- /test/integration/data/utt-upper-case.log: -------------------------------------------------------------------------------- 1 | 2014-03-14 07:00 hello 2 | 2014-03-14 08:00 C: 1 3 | 2014-03-14 09:00 A: 1 4 | 2014-03-14 10:00 c: 1 5 | 2014-03-14 10:30 a: 1 6 | -------------------------------------------------------------------------------- /test/integration/utt_example_plugin/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="utt_foo", 5 | version="1.0", 6 | packages=["utt.plugins"], 7 | ) 8 | -------------------------------------------------------------------------------- /test/integration/data/add/utt.log: -------------------------------------------------------------------------------- 1 | 2014-01-01 08:00 spaces 2 | 2014-01-01 09:00 utt: programming 3 | 4 | 2014-01-02 08:00 utt: programming 5 | 2014-01-02 09:00 utt: programming 6 | -------------------------------------------------------------------------------- /test/integration/data/stretch/after.log: -------------------------------------------------------------------------------- 1 | 2013-01-01 08:00 hello 2 | 2013-01-01 09:00 programming 3 | 4 | 2014-01-01 08:00 hello 5 | 2014-01-01 09:00 programming 6 | 2014-01-01 10:00 programming 7 | -------------------------------------------------------------------------------- /test/integration/data/utt-overnight.log: -------------------------------------------------------------------------------- 1 | 2014-03-14 08:00 hello 2 | 2014-03-14 09:00 hard work 3 | 4 | 2014-03-17 09:00 overnight work 5 | 2014-03-17 10:15 hard work 6 | 7 | 2014-03-18 09:00 check in *** 8 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-project-per-day-csv.csv: -------------------------------------------------------------------------------- 1 | Date,Hours,Duration,Projects,Tasks 2 | 2018-08-20,0.2,0h12,project_1,"task_1, task_2" 3 | 2018-08-21,0.3,0h18,project_1,"task_1, task_2, task_3" 4 | -------------------------------------------------------------------------------- /utt/__version__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | version_txt_filename = Path(os.path.dirname(os.path.abspath(__file__))) / "version.txt" 5 | VERSION = open(version_txt_filename).readline().strip() 6 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-per-day-csv.csv: -------------------------------------------------------------------------------- 1 | Date,Hours,Duration,Projects,Tasks 2 | 2018-08-20,0.4,0h24,"project_1, project_2","task_1, task_2" 3 | 2018-08-21,0.6,0h36,"project_1, project_2","task_1, task_2, task_3" 4 | -------------------------------------------------------------------------------- /utt/report/details/model.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from utt.data_structures.activity import Activity 4 | 5 | 6 | class DetailsModel: 7 | def __init__(self, activities: List[Activity]): 8 | self.activities = activities 9 | -------------------------------------------------------------------------------- /test/integration/Dockerfile.template: -------------------------------------------------------------------------------- 1 | ENTRYPOINT ["make"] 2 | 3 | WORKDIR /utt 4 | 5 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 6 | bash-completion \ 7 | make 8 | 9 | COPY . . 10 | -------------------------------------------------------------------------------- /utt/components/report_view.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from ..components.output import Output 4 | 5 | 6 | class ReportView(ABC): 7 | @abstractmethod 8 | def render(self, output: Output) -> None: 9 | raise NotImplementedError() 10 | -------------------------------------------------------------------------------- /utt/command.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import typing 3 | from dataclasses import dataclass 4 | from typing import Callable 5 | 6 | 7 | @dataclass 8 | class Command: 9 | name: str 10 | description: str 11 | handler_class: typing.Type 12 | add_args: Callable[[argparse.ArgumentParser], None] 13 | -------------------------------------------------------------------------------- /utt/components/now.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | 4 | 5 | class Now(datetime.datetime): 6 | pass 7 | 8 | 9 | def now(args: argparse.Namespace) -> Now: 10 | if args.now: 11 | return Now.fromtimestamp(args.now.timestamp()) 12 | 13 | dt = datetime.datetime.now() 14 | return Now.fromtimestamp(dt.timestamp()) 15 | -------------------------------------------------------------------------------- /utt/components/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | from .config_filename import ConfigFilename 4 | from .default_config import DefaultConfig 5 | 6 | 7 | def config(config_filename: ConfigFilename, default_config: DefaultConfig) -> configparser.ConfigParser: 8 | conf = default_config() 9 | conf.read(config_filename) 10 | return conf 11 | -------------------------------------------------------------------------------- /utt/components/config_filename.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ..constants import CONFIG_FILENAME 4 | from .config_dirname import ConfigDirname 5 | 6 | 7 | class ConfigFilename(str): 8 | pass 9 | 10 | 11 | def config_filename(config_dirname: ConfigDirname) -> ConfigFilename: 12 | return ConfigFilename(os.path.join(config_dirname, CONFIG_FILENAME)) 13 | -------------------------------------------------------------------------------- /utt/constants.py: -------------------------------------------------------------------------------- 1 | CONFIG_FILENAME = "utt.cfg" 2 | DATA_CONFIG_DEFAULT_DIRNAME = "~/.config" 3 | DATA_CONFIG_ENV_VAR_NAME = "XDG_DATA_CONFIG" 4 | DATA_CONFIG_SUB_DIRNAME = "utt" 5 | DATA_HOME_DEFAULT_DIRNAME = "~/.local/share" 6 | DATA_HOME_ENV_VAR_NAME = "XDG_DATA_HOME" 7 | DATA_HOME_SUB_DIRNAME = "utt" 8 | ENTRY_FILENAME = "utt.log" 9 | HELLO_ENTRY_NAME = "hello" 10 | -------------------------------------------------------------------------------- /utt/components/data_dirname.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ..constants import DATA_HOME_DEFAULT_DIRNAME, DATA_HOME_ENV_VAR_NAME, DATA_HOME_SUB_DIRNAME 4 | 5 | 6 | class DataDirname(str): 7 | pass 8 | 9 | 10 | def data_dirname() -> DataDirname: 11 | base_data_dir_name = os.getenv(DATA_HOME_ENV_VAR_NAME, os.path.expanduser(DATA_HOME_DEFAULT_DIRNAME)) 12 | 13 | return DataDirname(os.path.join(base_data_dir_name, DATA_HOME_SUB_DIRNAME)) 14 | -------------------------------------------------------------------------------- /utt/components/data_filename.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | from ..constants import ENTRY_FILENAME 5 | from .data_dirname import DataDirname 6 | 7 | 8 | class DataFilename(str): 9 | pass 10 | 11 | 12 | def data_filename(args: argparse.Namespace, data_dirname: DataDirname) -> DataFilename: 13 | if args.data_filename: 14 | return args.data_filename 15 | 16 | return DataFilename(os.path.join(data_dirname, ENTRY_FILENAME)) 17 | -------------------------------------------------------------------------------- /utt/components/config_dirname.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ..constants import DATA_CONFIG_DEFAULT_DIRNAME, DATA_CONFIG_ENV_VAR_NAME, DATA_CONFIG_SUB_DIRNAME 4 | 5 | 6 | class ConfigDirname(str): 7 | pass 8 | 9 | 10 | def config_dirname() -> ConfigDirname: 11 | base_dir_name = os.getenv(DATA_CONFIG_ENV_VAR_NAME, os.path.expanduser(DATA_CONFIG_DEFAULT_DIRNAME)) 12 | 13 | return ConfigDirname(os.path.join(base_dir_name, DATA_CONFIG_SUB_DIRNAME)) 14 | -------------------------------------------------------------------------------- /test/integration/data/utt-1.log: -------------------------------------------------------------------------------- 1 | 2014-03-14 08:00 hello 2 | 2014-03-14 09:00 hard work 3 | 4 | 2014-03-17 09:00 hello 5 | 2014-03-17 10:15 hard work 6 | 7 | 2014-03-19 09:00 hello 8 | 2014-03-19 12:00 asd: A-526 9 | 2014-03-19 13:00 lunch** 10 | 2014-03-19 14:00 hard work 11 | 2014-03-19 14:15 qwer: b-73 12 | 2014-03-19 14:30 asd: A-526 13 | 2014-03-19 14:45 qwer: C-123 14 | 2014-03-19 15:00 qwer: a-9 15 | 2014-03-19 16:00 black out *** 16 | 2014-03-19 16:30 A: z-8 17 | -------------------------------------------------------------------------------- /scripts/update_version_txt.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | 5 | def main(): 6 | version_filename = sys.argv[1] 7 | 8 | version = subprocess.check_output( 9 | ["poetry", "version"], stderr=sys.stderr 10 | ).decode().split()[1] 11 | 12 | print(f"Writing '{version}' to {version_filename}") 13 | with open(version_filename, "w") as version_txt: 14 | version_txt.write(version) 15 | 16 | 17 | if __name__ == "__main__": 18 | main() 19 | -------------------------------------------------------------------------------- /test/integration/data/utt-overnight-range.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------ Friday, Mar 14, 2014 (week 11) to Tuesday, Mar 18, 2014 (week 12) ------- 3 | 4 | Total: 74h15 5 | Working: 74h15 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (74h15) : hard work, overnight work 11 | 12 | ---------------------------------- Activities ---------------------------------- 13 | 14 | (2h15) : hard work 15 | (72h00) : overnight work 16 | 17 | -------------------------------------------------------------------------------- /utt/report/formatter.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | 4 | def format_duration(duration: timedelta) -> str: 5 | total_minutes, _ = divmod(duration.total_seconds(), 60) 6 | total_hours, remainder_minutes = divmod(total_minutes, 60) 7 | formatted_duration = "{hours:.0f}h{minutes:02.0f}".format(hours=total_hours, minutes=remainder_minutes) 8 | return formatted_duration 9 | 10 | 11 | def title(text: str) -> str: 12 | return "{:-^80}".format(" " + text + " ") 13 | -------------------------------------------------------------------------------- /test/integration/data/utt-no-current-activity.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------------------------ Monday, Aug 20, 2018 (week 34) ------------------------ 3 | 4 | Total: 0h00 5 | Working: 0h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | 11 | ---------------------------------- Activities ---------------------------------- 12 | 13 | 14 | 15 | ----------------------------------- Details ------------------------------------ 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/integration/utt_example_plugin/utt/plugins/foo_command.py: -------------------------------------------------------------------------------- 1 | from utt.api import _v1 2 | 3 | 4 | class FooHandler: 5 | def __init__(self, now: _v1.Now, output: _v1.Output): 6 | self._now = now 7 | self._output = output 8 | 9 | def __call__(self): 10 | print(f"Now: {self._now}", file=self._output) 11 | 12 | 13 | foo_command = _v1.Command(name="foo", description="Foo", handler_class=FooHandler, add_args=lambda p: None) 14 | 15 | 16 | _v1.register_command(foo_command) 17 | -------------------------------------------------------------------------------- /utt/components/default_config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | DEFAULTS = {} 4 | 5 | 6 | class DefaultConfig: 7 | def __init__(self): 8 | pass 9 | 10 | def __call__(self) -> configparser.ConfigParser: 11 | config = configparser.ConfigParser() 12 | 13 | for section, options in DEFAULTS.items(): 14 | config.add_section(section) 15 | 16 | for option, value in options.items(): 17 | config.set(section, option, value) 18 | 19 | return config 20 | -------------------------------------------------------------------------------- /test/integration/data/utt-overnight-2.stdout: -------------------------------------------------------------------------------- 1 | 2 | ----------------------- Tuesday, Mar 18, 2014 (week 12) ------------------------ 3 | 4 | Total: 0h00 5 | Working: 0h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | 11 | ---------------------------------- Activities ---------------------------------- 12 | 13 | 14 | 15 | ----------------------------------- Details ------------------------------------ 16 | 17 | (9h00) 00:00-09:00 check in *** 18 | 19 | -------------------------------------------------------------------------------- /utt/report/projects/view.py: -------------------------------------------------------------------------------- 1 | from ...components.output import Output 2 | from .. import formatter 3 | from ..common import print_dicts 4 | from .model import ProjectsModel 5 | 6 | 7 | class ProjectsView: 8 | def __init__(self, model: ProjectsModel): 9 | self._model = model 10 | 11 | def render(self, output: Output) -> None: 12 | print(file=output) 13 | print(formatter.title("Projects"), file=output) 14 | print(file=output) 15 | 16 | print_dicts(self._model.projects, output) 17 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-project.log: -------------------------------------------------------------------------------- 1 | 2018-08-20 08:00 hello 2 | 2018-08-20 08:06 project_1: task_1 3 | 2018-08-20 08:12 project_2: task_1 4 | 2018-08-20 08:18 project_1: task_2 5 | 2018-08-20 08:24 project_2: task_2 6 | 7 | 2018-08-21 08:00 hello 8 | 2018-08-21 08:06 project_1: task_1 9 | 2018-08-21 08:12 project_2: task_1 10 | 2018-08-21 08:18 project_1: task_2 11 | 2018-08-21 08:24 project_2: task_2 12 | 2018-08-21 08:30 project_1: task_3 # creating tests for task_3 13 | 2018-08-21 08:36 project_2: task_3 # refactoring project_2 UI 14 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-per-task-csv.csv: -------------------------------------------------------------------------------- 1 | Date,Projects,Tasks,Duration,Type,Comment 2 | 2018-08-20,project_1,task_1,0.1,WORK, 3 | 2018-08-20,project_2,task_1,0.1,WORK, 4 | 2018-08-20,project_1,task_2,0.1,WORK, 5 | 2018-08-20,project_2,task_2,0.1,WORK, 6 | 2018-08-21,project_1,task_1,0.1,WORK, 7 | 2018-08-21,project_2,task_1,0.1,WORK, 8 | 2018-08-21,project_1,task_2,0.1,WORK, 9 | 2018-08-21,project_2,task_2,0.1,WORK, 10 | 2018-08-21,project_1,task_3,0.1,WORK,creating tests for task_3 11 | 2018-08-21,project_2,task_3,0.1,WORK,refactoring project_2 UI 12 | -------------------------------------------------------------------------------- /utt/components/entry_lines.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from .data_filename import DataFilename 4 | 5 | 6 | class EntryLines: 7 | def __init__(self, data_filename: DataFilename): 8 | self._data_filename = data_filename 9 | 10 | def __call__(self) -> List[Tuple[int, str]]: 11 | try: 12 | return self._get_lines() 13 | except IOError: 14 | return [] 15 | 16 | def _get_lines(self) -> List[Tuple[int, str]]: 17 | with open(self._data_filename) as entry_file: 18 | return list(enumerate(entry_file, 1)) 19 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-and-test: 10 | runs-on: ubuntu-22.04 11 | strategy: 12 | matrix: 13 | python-version: ["3.10", "3.11", "3.12", "3.13"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Bootstrap 21 | run: make ci.bootstrap 22 | - name: Test 23 | run: make test 24 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-project-per-day.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------ Monday, Aug 20, 2018 (week 34) to Tuesday, Aug 21, 2018 (week 34) ------- 3 | 4 | Total: 0h30 5 | Working: 0h30 6 | Break: 0h00 7 | 8 | ----------------------------------- Per Day ------------------------------------ 9 | 10 | 2018-08-20: 0.2h (0h12) - project_1 - task_1, task_2 11 | 2018-08-21: 0.3h (0h18) - project_1 - task_1, task_2, task_3 12 | 13 | ---------------------------------- Activities ---------------------------------- 14 | 15 | (0h12) project_1: task_1 16 | (0h12) project_1: task_2 17 | (0h06) project_1: task_3 18 | 19 | -------------------------------------------------------------------------------- /utt/report/activities/view.py: -------------------------------------------------------------------------------- 1 | from ...components.output import Output 2 | from .. import formatter 3 | from ..common import print_dicts 4 | from .model import ActivitiesModel 5 | 6 | 7 | class ActivitiesView: 8 | def __init__(self, model: ActivitiesModel): 9 | self._model = model 10 | 11 | def render(self, output: Output) -> None: 12 | print(file=output) 13 | print(formatter.title("Activities"), file=output) 14 | print(file=output) 15 | 16 | print_dicts(self._model.names_work, output) 17 | 18 | print(file=output) 19 | 20 | print_dicts(self._model.names_break, output) 21 | -------------------------------------------------------------------------------- /test/integration/data/utt-hello-only-today.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------------------------ Monday, Aug 20, 2018 (week 34) ------------------------ 3 | 4 | Total: 12h00 (0h00 + 12h00) 5 | Working: 12h00 (0h00 + 12h00) 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (12h00) : -- Current Activity -- 11 | 12 | ---------------------------------- Activities ---------------------------------- 13 | 14 | (12h00) : -- Current Activity -- 15 | 16 | 17 | ----------------------------------- Details ------------------------------------ 18 | 19 | (12h00) 08:00-20:00 -- Current Activity -- 20 | 21 | -------------------------------------------------------------------------------- /test/integration/data/utt-range.stdout: -------------------------------------------------------------------------------- 1 | 2 | ---- Saturday, Mar 15, 2014 (week 11) to Wednesday, Mar 19, 2014 (week 12) ----- 3 | 4 | Total: 7h45 5 | Working: 6h45 6 | Break: 1h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (2h15) : hard work 11 | (0h30) A : z-8 12 | (3h15) asd : A-526 13 | (0h45) qwer: a-9, b-73, C-123 14 | 15 | ---------------------------------- Activities ---------------------------------- 16 | 17 | (2h15) : hard work 18 | (0h30) A : z-8 19 | (3h15) asd : A-526 20 | (0h15) qwer: a-9 21 | (0h15) qwer: b-73 22 | (0h15) qwer: C-123 23 | 24 | (1h00) : lunch** 25 | -------------------------------------------------------------------------------- /test/integration/data/utt-overnight.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------------------------ Friday, Mar 14, 2014 (week 11) ------------------------ 3 | 4 | Total: 15h59 5 | Working: 15h59 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (15h59) : hard work, overnight work 11 | 12 | ---------------------------------- Activities ---------------------------------- 13 | 14 | (1h00) : hard work 15 | (14h59) : overnight work 16 | 17 | 18 | ----------------------------------- Details ------------------------------------ 19 | 20 | (1h00) 08:00-09:00 hard work 21 | (14h59) 09:00-23:59 overnight work 22 | 23 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-month.stdout: -------------------------------------------------------------------------------- 1 | 2 | ----- Wednesday, Aug 01, 2018 (week 31) to Friday, Aug 31, 2018 (week 35) ------ 3 | 4 | Total: 1h00 5 | Working: 1h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (0h30) project_1: task_1, task_2, task_3 11 | (0h30) project_2: task_1, task_2, task_3 12 | 13 | ---------------------------------- Activities ---------------------------------- 14 | 15 | (0h12) project_1: task_1 16 | (0h12) project_1: task_2 17 | (0h06) project_1: task_3 18 | (0h12) project_2: task_1 19 | (0h12) project_2: task_2 20 | (0h06) project_2: task_3 21 | 22 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-truncate-current-activity.stdout: -------------------------------------------------------------------------------- 1 | 2 | ---------------------- Wednesday, Dec 30, 2020 (week 53) ----------------------- 3 | 4 | Total: 1h00 (0h00 + 1h00) 5 | Working: 1h00 (0h00 + 1h00) 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (1h00) : -- Current Activity -- 11 | 12 | ---------------------------------- Activities ---------------------------------- 13 | 14 | (1h00) : -- Current Activity -- 15 | 16 | 17 | ----------------------------------- Details ------------------------------------ 18 | 19 | (1h00) 00:00-01:00 -- Current Activity -- 20 | 21 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-week-current.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------- Monday, Aug 20, 2018 (week 34) to Sunday, Aug 26, 2018 (week 34) ------- 3 | 4 | Total: 1h00 5 | Working: 1h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (0h30) project_1: task_1, task_2, task_3 11 | (0h30) project_2: task_1, task_2, task_3 12 | 13 | ---------------------------------- Activities ---------------------------------- 14 | 15 | (0h12) project_1: task_1 16 | (0h12) project_1: task_2 17 | (0h06) project_1: task_3 18 | (0h12) project_2: task_1 19 | (0h12) project_2: task_2 20 | (0h06) project_2: task_3 21 | 22 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-project.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------------------------ Monday, Aug 20, 2018 (week 34) ------------------------ 3 | 4 | Total: 0h12 5 | Working: 0h12 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (0h12) project_1: task_1, task_2 11 | 12 | ---------------------------------- Activities ---------------------------------- 13 | 14 | (0h06) project_1: task_1 15 | (0h06) project_1: task_2 16 | 17 | 18 | ----------------------------------- Details ------------------------------------ 19 | 20 | (0h06) 08:00-08:06 project_1: task_1 21 | (0h06) 08:12-08:18 project_1: task_2 22 | 23 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-per-day.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------ Monday, Aug 20, 2018 (week 34) to Tuesday, Aug 21, 2018 (week 34) ------- 3 | 4 | Total: 1h00 5 | Working: 1h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Per Day ------------------------------------ 9 | 10 | 2018-08-20: 0.4h (0h24) - project_1, project_2 - task_1, task_2 11 | 2018-08-21: 0.6h (0h36) - project_1, project_2 - task_1, task_2, task_3 12 | 13 | ---------------------------------- Activities ---------------------------------- 14 | 15 | (0h12) project_1: task_1 16 | (0h12) project_1: task_2 17 | (0h06) project_1: task_3 18 | (0h12) project_2: task_1 19 | (0h12) project_2: task_2 20 | (0h06) project_2: task_3 21 | 22 | -------------------------------------------------------------------------------- /test/integration/data/utt-example-plugin-report.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------------------------ Friday, Jan 03, 2020 (week 1) ------------------------- 3 | 4 | Total: 1h30 (1h00 + 0h30) 5 | Working: 1h30 (1h00 + 0h30) 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (1h30) : -- Current Activity --, work 11 | 12 | ---------------------------------- Activities ---------------------------------- 13 | 14 | (0h30) : -- Current Activity -- 15 | (1h00) : work 16 | 17 | 18 | ----------------------------------- Details ------------------------------------ 19 | 20 | (1h00) 09:00-10:00 work 21 | (0h30) 10:00-10:30 -- Current Activity -- 22 | 23 | Number of activities: 2 24 | -------------------------------------------------------------------------------- /test/integration/data/utt-upper-case.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------------------------ Friday, Mar 14, 2014 (week 11) ------------------------ 3 | 4 | Total: 3h30 5 | Working: 3h30 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (1h00) A: 1 11 | (0h30) a: 1 12 | (1h00) C: 1 13 | (1h00) c: 1 14 | 15 | ---------------------------------- Activities ---------------------------------- 16 | 17 | (1h00) A: 1 18 | (0h30) a: 1 19 | (1h00) C: 1 20 | (1h00) c: 1 21 | 22 | 23 | ----------------------------------- Details ------------------------------------ 24 | 25 | (1h00) 07:00-08:00 C: 1 26 | (1h00) 08:00-09:00 A: 1 27 | (1h00) 09:00-10:00 c: 1 28 | (0h30) 10:00-10:30 a: 1 29 | 30 | -------------------------------------------------------------------------------- /utt/report/csv_view.py: -------------------------------------------------------------------------------- 1 | from utt.report.details.view import DetailsView 2 | from utt.report.per_day.view import PerDayView 3 | 4 | from ..components.output import Output 5 | from ..components.report_args import CSVSection 6 | from ..components.report_model import ReportModel 7 | 8 | 9 | class CSVReportView: 10 | def __init__(self, report: ReportModel): 11 | self._report = report 12 | 13 | def render(self, output: Output) -> None: 14 | section = self._report.args.csv_section 15 | 16 | if section == CSVSection.per_day: 17 | PerDayView(self._report.per_day_model).csv(output) 18 | if section == CSVSection.per_task: 19 | DetailsView(self._report.details_model).csv(output) 20 | -------------------------------------------------------------------------------- /utt/data_structures/entry.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | 5 | class Entry: 6 | def __init__( 7 | self, 8 | entry_datetime: datetime, 9 | name: str, 10 | is_current_entry: bool, 11 | comment: Optional[str], 12 | ): 13 | self.datetime = entry_datetime 14 | self.name = name 15 | self.is_current_entry = is_current_entry 16 | self.comment = comment 17 | 18 | def __str__(self): 19 | str_components = [self.datetime.strftime("%Y-%m-%d %H:%M%z"), self.name] 20 | 21 | if self.comment: 22 | str_components.append("".join([" # ", self.comment])) 23 | 24 | return " ".join(str_components) 25 | -------------------------------------------------------------------------------- /utt/data_structures/name.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class Name: 5 | NAME_REGEX = re.compile(r"(?P[^\s:]+):\s(?P.*)") 6 | 7 | def __init__(self, name: str): 8 | self.name = name 9 | match = Name.NAME_REGEX.match(name) 10 | if match is None: 11 | self.task = name 12 | self.project = "" 13 | return 14 | 15 | groupdict = match.groupdict() 16 | self.project = groupdict["project"] 17 | self.task = groupdict["task"] 18 | 19 | def __lt__(self, other): 20 | return self.name < other.name 21 | 22 | def __eq__(self, other): 23 | return self.name == other.name 24 | 25 | def __str__(self): 26 | return self.name 27 | -------------------------------------------------------------------------------- /test/unit/report/test_formatter.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import timedelta 3 | 4 | from utt.report.formatter import format_duration 5 | 6 | 7 | class TestFormatter(unittest.TestCase): 8 | def test_formatter_less_than_a_minute(self): 9 | self.assertEqual(format_duration(timedelta(seconds=10)), "0h00") 10 | 11 | def test_formatter_less_than_a_hour(self): 12 | self.assertEqual(format_duration(timedelta(minutes=8, seconds=45)), "0h08") 13 | 14 | def test_formatter_less_than_a_day(self): 15 | self.assertEqual(format_duration(timedelta(hours=8, minutes=20)), "8h20") 16 | 17 | def test_formatter_more_than_a_day(self): 18 | self.assertEqual(format_duration(timedelta(days=1, hours=2, minutes=20)), "26h20") 19 | -------------------------------------------------------------------------------- /utt/plugins/0_hello.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from ..api import _v1 4 | 5 | 6 | class HelloHandler: 7 | def __init__( 8 | self, 9 | args: argparse.Namespace, 10 | now: _v1.Now, 11 | add_entry: _v1._private.AddEntry, 12 | ): 13 | self._args = args 14 | self._now = now 15 | self._add_entry = add_entry 16 | 17 | def __call__(self): 18 | self._add_entry(_v1.Entry(self._now, _v1.HELLO_ENTRY_NAME, False, comment=None)) 19 | 20 | 21 | hello_command = _v1.Command( 22 | "hello", 23 | "Say '{hello_entry_name}' when you arrive in the morning...".format(hello_entry_name=_v1.HELLO_ENTRY_NAME), 24 | HelloHandler, 25 | lambda p: None, 26 | ) 27 | 28 | _v1.register_command(hello_command) 29 | -------------------------------------------------------------------------------- /utt/plugins/0_edit.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import subprocess 4 | 5 | from ..api import _v1 6 | 7 | 8 | class EditHandler: 9 | def __init__(self, args: argparse.Namespace, data_filename: _v1._private.DataFilename): 10 | self._args = args 11 | self._data_filename = data_filename 12 | 13 | def __call__(self): 14 | _run_editor(_editor(), self._data_filename) 15 | 16 | 17 | edit_command = _v1.Command("edit", "Edit task log using your system's default editor", EditHandler, lambda p: None) 18 | 19 | _v1.register_command(edit_command) 20 | 21 | 22 | def _editor(): 23 | return os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi") 24 | 25 | 26 | def _run_editor(editor, data_filename): 27 | return subprocess.call('%s "%s"' % (editor, data_filename), shell=True) 28 | -------------------------------------------------------------------------------- /utt/api/_v1/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from ...command import Command 4 | from ...components.activities import Activities 5 | from ...components.entries import Entries # Injectable 6 | from ...components.now import Now # Injectable 7 | from ...components.output import Output # Injectable 8 | from ...components.report_model import ReportModel 9 | from ...components.report_view import ReportView # Injectable 10 | from ...constants import HELLO_ENTRY_NAME 11 | from ...data_structures.activity import Activity 12 | from ...data_structures.entry import Entry 13 | from ...data_structures.name import Name 14 | from ...report.activities.view import ActivitiesView 15 | from ...report.details.view import DetailsView 16 | from ...report.per_day.view import PerDayView 17 | from ...report.projects.view import ProjectsView 18 | from ...report.summary.view import SummaryView 19 | from ._private import register_command, register_component 20 | -------------------------------------------------------------------------------- /utt/plugins/0_default_report_view.py: -------------------------------------------------------------------------------- 1 | from utt.api import _v1 2 | 3 | 4 | class DefaultReportView(_v1.ReportView): 5 | def __init__(self, report: _v1.ReportModel): 6 | self._report = report 7 | 8 | def render(self, output: _v1.Output) -> None: 9 | _v1.SummaryView(self._report.summary_model).render(output) 10 | 11 | if self._report.args.show_per_day: 12 | _v1.PerDayView(self._report.per_day_model).render(output) 13 | else: 14 | _v1.ProjectsView(self._report.projects_model).render(output) 15 | 16 | _v1.ActivitiesView(self._report.activities_model).render(output) 17 | 18 | if (self._report.args.range.start == self._report.args.range.end) or self._report.args.show_details: 19 | _v1.DetailsView(self._report.details_model, show_comments=self._report.args.show_comments).render(output) 20 | 21 | 22 | _v1.register_component(_v1.ReportView, DefaultReportView) 23 | -------------------------------------------------------------------------------- /utt/plugins/0_add.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from ..api import _v1 4 | 5 | 6 | class AddHandler: 7 | def __init__( 8 | self, 9 | args: argparse.Namespace, 10 | data_filename: _v1._private.DataFilename, 11 | now: _v1.Now, 12 | add_entry: _v1._private.AddEntry, 13 | ): 14 | self._args = args 15 | self._data_filename = data_filename 16 | self._now = now 17 | self._add_entry = add_entry 18 | 19 | def __call__(self): 20 | self._add_entry(_v1.Entry(self._now, self._args.name, False, comment=self._args.comment)) 21 | 22 | 23 | def add_args(parser: argparse.ArgumentParser): 24 | parser.add_argument("name", help="completed task description") 25 | parser.add_argument("-c", "--comment", help="comment/annotation for task entry") 26 | 27 | 28 | add_command = _v1.Command("add", "Add a completed task", AddHandler, add_args) 29 | 30 | _v1.register_command(add_command) 31 | -------------------------------------------------------------------------------- /utt/components/report_model/model.py: -------------------------------------------------------------------------------- 1 | from ...report.activities.model import ActivitiesModel 2 | from ...report.details.model import DetailsModel 3 | from ...report.per_day.model import PerDayModel 4 | from ...report.projects.model import ProjectsModel 5 | from ...report.summary.model import SummaryModel 6 | from ..activities import Activities 7 | from ..report_args import ReportArgs 8 | 9 | 10 | def report(report_args: ReportArgs, filtered_activities: Activities): 11 | return ReportModel(activities=filtered_activities, args=report_args) 12 | 13 | 14 | class ReportModel: 15 | def __init__(self, activities: Activities, args: ReportArgs): 16 | self.args = args 17 | self.summary_model = SummaryModel(activities, args.range) 18 | self.projects_model = ProjectsModel(activities) 19 | self.per_day_model = PerDayModel(activities) 20 | self.activities_model = ActivitiesModel(activities) 21 | self.details_model = DetailsModel(activities) 22 | -------------------------------------------------------------------------------- /utt/report/summary/model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | 4 | from ...components.report_args import DateRange 5 | from ...data_structures.activity import Activity 6 | from ..common import filter_activities_by_type 7 | 8 | 9 | class SummaryModel: 10 | def __init__(self, activities: List[Activity], report_range: DateRange): 11 | self.report_range = report_range 12 | 13 | working_activities = filter_activities_by_type(activities, Activity.Type.WORK) 14 | break_activities = filter_activities_by_type(activities, Activity.Type.BREAK) 15 | 16 | self.working_time = duration(working_activities) 17 | self.break_time = duration(break_activities) 18 | self.total_time = self.working_time + self.break_time 19 | 20 | self.last_activity = activities[-1] if activities else None 21 | 22 | 23 | def duration(activities: List[Activity]) -> datetime.timedelta: 24 | return sum((act.duration for act in activities), datetime.timedelta()) 25 | -------------------------------------------------------------------------------- /utt/components/entry_parser.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from typing import Optional 4 | 5 | from ..data_structures.entry import Entry 6 | 7 | DATE_REGEX = r"(?P\d{4}-\d{1,2}-\d{1,2}\s+\d{1,2}:\d{1,2})" 8 | NAME_REGEX = r"\s+(?P[^\s].*?)" 9 | COMMENT_REGEX = r"\s{2}#\s(?P.*$)?" 10 | 11 | ENTRY_REGEX = re.compile("".join([DATE_REGEX, NAME_REGEX, r"($|", COMMENT_REGEX, ")"])) 12 | 13 | 14 | class EntryParser: 15 | def parse(self, string: str) -> Optional[Entry]: 16 | match = ENTRY_REGEX.match(string) 17 | 18 | if match is None: 19 | return None 20 | 21 | groupdict = match.groupdict() 22 | 23 | if "date" not in groupdict or "name" not in groupdict: 24 | return None 25 | 26 | date_str = groupdict["date"] 27 | date = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M") 28 | 29 | name = groupdict["name"] 30 | comment = groupdict.get("comment") 31 | return Entry(date, name, False, comment=comment) 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry>=0.12"] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.black] 6 | line-length = 120 7 | 8 | [tool.isort] 9 | profile = "black" 10 | line_length = 120 11 | 12 | [tool.pyright] 13 | typeCheckingMode = "standard" 14 | 15 | [tool.poetry] 16 | authors = ["Mathieu Larose "] 17 | description = "A simple command-line time tracker" 18 | homepage = "https://github.com/larose/utt" 19 | license = "GPL-3.0-only" 20 | maintainers = ["Mathieu Larose "] 21 | name = "utt" 22 | version = "0" # Do not change. Updated by a script. 23 | 24 | [tool.poetry.dependencies] 25 | argcomplete = "^3.2.1" 26 | cargo = "^0.3" 27 | python = "^3.10" 28 | 29 | [tool.poetry.scripts] 30 | utt = "utt.__main__:main" 31 | 32 | [tool.poetry.group.dev.dependencies] 33 | black = "^25.9.0" 34 | flake8 = "^7.0.0" 35 | isort = "^5.13.2" 36 | pyright = "^1.1.407" 37 | requests = "^2.32.5" 38 | setuptools = "^80.9.0" 39 | -------------------------------------------------------------------------------- /test/integration/data/utt-1.stdout: -------------------------------------------------------------------------------- 1 | 2 | ---------------------- Wednesday, Mar 19, 2014 (week 12) ----------------------- 3 | 4 | Total: 8h30 (6h30 + 2h00) 5 | Working: 7h30 (5h30 + 2h00) 6 | Break: 1h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (3h00) : -- Current Activity --, hard work 11 | (0h30) A : z-8 12 | (3h15) asd : A-526 13 | (0h45) qwer: a-9, b-73, C-123 14 | 15 | ---------------------------------- Activities ---------------------------------- 16 | 17 | (2h00) : -- Current Activity -- 18 | (1h00) : hard work 19 | (0h30) A : z-8 20 | (3h15) asd : A-526 21 | (0h15) qwer: a-9 22 | (0h15) qwer: b-73 23 | (0h15) qwer: C-123 24 | 25 | (1h00) : lunch** 26 | 27 | ----------------------------------- Details ------------------------------------ 28 | 29 | (3h00) 09:00-12:00 asd: A-526 30 | (1h00) 12:00-13:00 lunch** 31 | (1h00) 13:00-14:00 hard work 32 | (0h15) 14:00-14:15 qwer: b-73 33 | (0h15) 14:15-14:30 asd: A-526 34 | (0h15) 14:30-14:45 qwer: C-123 35 | (0h15) 14:45-15:00 qwer: a-9 36 | (1h00) 15:00-16:00 black out *** 37 | (0h30) 16:00-16:30 A: z-8 38 | (2h00) 16:30-18:30 -- Current Activity -- 39 | 40 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-details.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------ Monday, Aug 20, 2018 (week 34) to Tuesday, Aug 21, 2018 (week 34) ------- 3 | 4 | Total: 1h00 5 | Working: 1h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (0h30) project_1: task_1, task_2, task_3 11 | (0h30) project_2: task_1, task_2, task_3 12 | 13 | ---------------------------------- Activities ---------------------------------- 14 | 15 | (0h12) project_1: task_1 16 | (0h12) project_1: task_2 17 | (0h06) project_1: task_3 18 | (0h12) project_2: task_1 19 | (0h12) project_2: task_2 20 | (0h06) project_2: task_3 21 | 22 | 23 | ----------------------------------- Details ------------------------------------ 24 | 25 | 2018-08-20: 26 | 27 | (0h06) 08:00-08:06 project_1: task_1 28 | (0h06) 08:06-08:12 project_2: task_1 29 | (0h06) 08:12-08:18 project_1: task_2 30 | (0h06) 08:18-08:24 project_2: task_2 31 | 32 | 2018-08-21: 33 | 34 | (0h06) 08:00-08:06 project_1: task_1 35 | (0h06) 08:06-08:12 project_2: task_1 36 | (0h06) 08:12-08:18 project_1: task_2 37 | (0h06) 08:18-08:24 project_2: task_2 38 | (0h06) 08:24-08:30 project_1: task_3 39 | (0h06) 08:30-08:36 project_2: task_3 40 | 41 | -------------------------------------------------------------------------------- /utt/plugins/0_config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import configparser 3 | import sys 4 | 5 | from ..api import _v1 6 | 7 | 8 | class ConfigHandler: 9 | def __init__( 10 | self, 11 | args: argparse.Namespace, 12 | config: configparser.ConfigParser, 13 | default_config: _v1._private.DefaultConfig, 14 | config_filename: _v1._private.ConfigFilename, 15 | ): 16 | self._args = args 17 | self._config = config 18 | self._default_config = default_config 19 | self._config_filename = config_filename 20 | 21 | def __call__(self): 22 | if self._args.filename: 23 | print(self._config_filename) 24 | return 25 | 26 | if self._args.default: 27 | self._default_config().write(sys.stdout) 28 | return 29 | 30 | self._config.write(sys.stdout) 31 | 32 | 33 | def add_args(parser: argparse.ArgumentParser): 34 | parser.add_argument("--default", action="store_true", default=False) 35 | parser.add_argument("--filename", action="store_true", default=False) 36 | 37 | 38 | config_command = _v1.Command("config", "Show config", ConfigHandler, add_args) 39 | 40 | _v1.register_command(config_command) 41 | -------------------------------------------------------------------------------- /utt/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib 3 | import pkgutil 4 | import sys 5 | 6 | import utt.plugins 7 | from utt.api import _v1 8 | from utt.components.commands import Commands 9 | 10 | 11 | def iter_namespace(ns_pkg): 12 | # Specifying the second argument (prefix) to iter_modules makes the 13 | # returned name an absolute name instead of a relative one. This allows 14 | # import_module to work without having to do additional modification to 15 | # the name. 16 | # 17 | # Source: https://packaging.python.org/guides/creating-and-discovering-plugins/ 18 | return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".") 19 | 20 | 21 | def load_plugins(): 22 | for _, name, _ in iter_namespace(utt.plugins): 23 | importlib.import_module(name) 24 | 25 | 26 | def main(): 27 | if len(sys.argv) == 1: 28 | sys.argv.append("--help") 29 | 30 | load_plugins() 31 | 32 | command_name = _v1._private.container[argparse.Namespace].command 33 | 34 | commands: Commands = _v1._private.container[Commands] 35 | for command in commands: 36 | if command.name == command_name: 37 | _v1._private.container[command.handler_class]() 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /utt/plugins/0_stretch.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from ..api import _v1 4 | from ..components.add_entry import AddEntry # Private API 5 | 6 | 7 | class StretchHandler: 8 | def __init__( 9 | self, 10 | args: argparse.Namespace, 11 | now: _v1.Now, 12 | add_entry: AddEntry, 13 | entries: _v1.Entries, 14 | output: _v1.Output, 15 | ): 16 | self._args = args 17 | self._now = now 18 | self._add_entry = add_entry 19 | self._entries = entries 20 | self._output = output 21 | 22 | def __call__(self): 23 | if not self._entries: 24 | raise Exception("No entry to stretch") 25 | latest_entry = self._entries[-1] 26 | new_entry = _v1.Entry(self._now, latest_entry.name, False, comment=latest_entry.comment) 27 | self._add_entry(new_entry) 28 | print("stretched " + str(latest_entry), file=self._output) 29 | print(" → " + str(new_entry), file=self._output) 30 | 31 | 32 | stretch_command = _v1.Command( 33 | name="stretch", 34 | description="Stretch the latest task to the current time", 35 | handler_class=StretchHandler, 36 | add_args=lambda p: None, 37 | ) 38 | 39 | 40 | _v1.register_command(stretch_command) 41 | -------------------------------------------------------------------------------- /test/integration/data/utt-report-comments.stdout: -------------------------------------------------------------------------------- 1 | 2 | ------ Monday, Aug 20, 2018 (week 34) to Tuesday, Aug 21, 2018 (week 34) ------- 3 | 4 | Total: 1h00 5 | Working: 1h00 6 | Break: 0h00 7 | 8 | ----------------------------------- Projects ----------------------------------- 9 | 10 | (0h30) project_1: task_1, task_2, task_3 11 | (0h30) project_2: task_1, task_2, task_3 12 | 13 | ---------------------------------- Activities ---------------------------------- 14 | 15 | (0h12) project_1: task_1 16 | (0h12) project_1: task_2 17 | (0h06) project_1: task_3 18 | (0h12) project_2: task_1 19 | (0h12) project_2: task_2 20 | (0h06) project_2: task_3 21 | 22 | 23 | ----------------------------------- Details ------------------------------------ 24 | 25 | 2018-08-20: 26 | 27 | (0h06) 08:00-08:06 project_1: task_1 28 | (0h06) 08:06-08:12 project_2: task_1 29 | (0h06) 08:12-08:18 project_1: task_2 30 | (0h06) 08:18-08:24 project_2: task_2 31 | 32 | 2018-08-21: 33 | 34 | (0h06) 08:00-08:06 project_1: task_1 35 | (0h06) 08:06-08:12 project_2: task_1 36 | (0h06) 08:12-08:18 project_1: task_2 37 | (0h06) 08:18-08:24 project_2: task_2 38 | (0h06) 08:24-08:30 project_1: task_3 # creating tests for task_3 39 | (0h06) 08:30-08:36 project_2: task_3 # refactoring project_2 UI 40 | 41 | -------------------------------------------------------------------------------- /utt/components/parse_args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | import sys 4 | 5 | import argcomplete 6 | 7 | from ..__version__ import VERSION 8 | from .commands import Commands 9 | 10 | 11 | def parse_args(commands: Commands) -> argparse.Namespace: 12 | parser = argparse.ArgumentParser( 13 | formatter_class=argparse.RawTextHelpFormatter, 14 | description="Ultimate Time Tracker (utt) is a simple command-line time" 15 | " tracking application written in Python.", 16 | ) 17 | 18 | parser.add_argument("--data", dest="data_filename") 19 | 20 | parser.add_argument("--now", dest="now", type=parse_datetime) 21 | 22 | parser.add_argument( 23 | "--version", 24 | action="version", 25 | version="\n".join(["utt {version}".format(version=VERSION), "Python " + sys.version]), 26 | ) 27 | 28 | subparsers = parser.add_subparsers(dest="command") 29 | 30 | for command in commands: 31 | sub_parser = subparsers.add_parser(command.name, description=command.description) 32 | command.add_args(sub_parser) 33 | 34 | argcomplete.autocomplete(parser, append_space=False) 35 | return parser.parse_args() 36 | 37 | 38 | def parse_datetime(datetimestring): 39 | return datetime.datetime.strptime(datetimestring, "%Y-%m-%d %H:%M") 40 | -------------------------------------------------------------------------------- /utt/report/activities/model.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from datetime import timedelta 3 | from typing import Dict, List 4 | 5 | from ...data_structures.activity import Activity 6 | from .. import formatter 7 | from ..common import filter_activities_by_type 8 | 9 | 10 | class ActivitiesModel: 11 | def __init__(self, activities: List[Activity]): 12 | self.names_work = _groupby_name(filter_activities_by_type(activities, Activity.Type.WORK)) 13 | self.names_break = _groupby_name(filter_activities_by_type(activities, Activity.Type.BREAK)) 14 | 15 | 16 | def _groupby_name(activities: List[Activity]) -> List[Dict]: 17 | def key(act): 18 | return act.name.name 19 | 20 | result = [] 21 | sorted_activities = sorted(activities, key=key) 22 | for _, _activities in itertools.groupby(sorted_activities, key): 23 | activities = list(_activities) 24 | project = activities[0].name.project 25 | result.append( 26 | { 27 | "project": project, 28 | "duration": formatter.format_duration(sum((act.duration for act in activities), timedelta())), 29 | "name": ", ".join(sorted(set(act.name.task for act in activities))), 30 | } 31 | ) 32 | 33 | return sorted(result, key=lambda act: (act["project"].lower(), act["name"].lower())) 34 | -------------------------------------------------------------------------------- /utt/report/projects/model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | from typing import Dict, List 4 | 5 | from ...data_structures.activity import Activity 6 | from .. import formatter 7 | from ..common import filter_activities_by_type 8 | 9 | 10 | class ProjectsModel: 11 | def __init__(self, activities: List[Activity]): 12 | self.projects = groupby_project(filter_activities_by_type(activities, Activity.Type.WORK)) 13 | 14 | 15 | def groupby_project(activities: List[Activity]) -> List[Dict]: 16 | def key(act): 17 | return act.name.project 18 | 19 | result = [] 20 | sorted_activities = sorted(activities, key=key) 21 | 22 | for project, _activities in itertools.groupby(sorted_activities, key): 23 | activities = list(_activities) 24 | result.append( 25 | { 26 | "duration": formatter.format_duration(sum((act.duration for act in activities), datetime.timedelta())), 27 | "project": project, 28 | "name": ", ".join( 29 | sorted( 30 | set(act.name.task for act in activities), 31 | key=lambda task: task.lower(), 32 | ) 33 | ), 34 | } 35 | ) 36 | 37 | return sorted(result, key=lambda result: result["project"].lower()) 38 | -------------------------------------------------------------------------------- /utt/components/entries.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, List, Optional, Tuple 2 | 3 | from ..data_structures.entry import Entry 4 | from .entry_lines import EntryLines 5 | from .entry_parser import EntryParser 6 | 7 | Entries = List[Entry] 8 | 9 | 10 | def entries(entry_lines: EntryLines, entry_parser: EntryParser) -> Entries: 11 | return list(_parse_log(entry_lines(), entry_parser)) 12 | 13 | 14 | def _parse_log(lines: List[Tuple[int, str]], entry_parser: EntryParser) -> Generator[Entry, None, None]: 15 | previous_entry = None 16 | for line_number, line in lines: 17 | parsed_line = _parse_line(previous_entry, line_number, line.strip(), entry_parser) 18 | 19 | if parsed_line is not None: 20 | previous_entry, entry = parsed_line 21 | yield entry 22 | 23 | 24 | def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, entry_parser: EntryParser): 25 | # Ignore empty lines 26 | if not line: 27 | return None 28 | 29 | new_entry = entry_parser.parse(line) 30 | if new_entry is None: 31 | raise SyntaxError("Invalid syntax at line %d: %s" % (line_number, line)) 32 | 33 | if previous_entry is not None and previous_entry.datetime > new_entry.datetime: 34 | raise Exception("Error line %d. Not in chronological order: %s > %s" % (line_number, previous_entry, new_entry)) 35 | 36 | previous_entry = new_entry 37 | return previous_entry, new_entry 38 | -------------------------------------------------------------------------------- /scripts/update_version_in_pyproject.py: -------------------------------------------------------------------------------- 1 | # License: MIT 2 | # Source: https://github.com/larose/cargo/blob/a8d46de04b38a0847ba7ec60591aed3a4d6eecf5/scripts/update_version_in_pyproject.py 3 | import re 4 | import subprocess 5 | import sys 6 | import typing 7 | 8 | UNRELEASED_VERSION_NAME = "(unreleased)" 9 | 10 | 11 | def get_first_line(changelog_filename: str) -> str: 12 | with open(changelog_filename) as changelog: 13 | return changelog.readline().rstrip("\n") 14 | 15 | 16 | def get_version(changelog_filename: str) -> typing.Optional[str]: 17 | first_line = get_first_line(changelog_filename) 18 | print(f"Changelog first line: {first_line}") 19 | return parse_version_from_line(first_line) 20 | 21 | 22 | def parse_version_from_line(line: str) -> str: 23 | if line == f"## {UNRELEASED_VERSION_NAME}": 24 | return UNRELEASED_VERSION_NAME 25 | 26 | # Example: ## 1.0 (2020-08-25) 27 | match = re.match(r"## (?P[^\s(]+) \(\d{4}-\d{2}-\d{2}\)", line) 28 | if match is None: 29 | raise Exception(f"Invalid line: {line}") 30 | 31 | return match.group("version") 32 | 33 | 34 | def main(): 35 | changelog_filename = sys.argv[1] 36 | print(f"Changelog filename: {changelog_filename}") 37 | 38 | version = get_version(changelog_filename) 39 | print(f"Version: {version}") 40 | 41 | if version is None or version == UNRELEASED_VERSION_NAME: 42 | version = "0" 43 | 44 | print(subprocess.check_output(["poetry", "version", version]).decode()) 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /test/unit/test_report.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from utt.report.common import timedelta_to_billable 5 | 6 | TEST_CASES = [ 7 | (dict(minutes=0), " 0.0"), 8 | (dict(minutes=1), " 0.0"), 9 | (dict(minutes=2), " 0.0"), 10 | (dict(minutes=3), " 0.1"), 11 | (dict(minutes=4), " 0.1"), 12 | (dict(minutes=5), " 0.1"), 13 | (dict(minutes=6), " 0.1"), 14 | (dict(minutes=7), " 0.1"), 15 | (dict(minutes=8), " 0.1"), 16 | (dict(minutes=9), " 0.2"), 17 | (dict(minutes=14), " 0.2"), 18 | (dict(minutes=15), " 0.3"), 19 | (dict(minutes=30), " 0.5"), 20 | (dict(minutes=56), " 0.9"), 21 | (dict(minutes=57), " 1.0"), 22 | (dict(minutes=60), " 1.0"), 23 | (dict(minutes=62), " 1.0"), 24 | (dict(minutes=63), " 1.1"), 25 | (dict(minutes=66), " 1.1"), 26 | # NOTE, utt doesn't really deal with seconds, but this is how the 27 | # rounding would work if it did. 28 | (dict(seconds=1), " 0.0"), 29 | (dict(seconds=179), " 0.0"), 30 | (dict(seconds=180), " 0.1"), 31 | (dict(seconds=181), " 0.1"), 32 | (dict(seconds=359), " 0.1"), 33 | (dict(seconds=360), " 0.1"), 34 | (dict(seconds=361), " 0.1"), 35 | ] 36 | 37 | 38 | class TestTimedeltaToBillable(unittest.TestCase): 39 | def test_timedelta_to_billable(self): 40 | """Ensure that _timedelta_to_billable gives intended outcome. 41 | 42 | Hours are divided in 10, and we round up to the next "6 minute unit". 43 | """ 44 | for delta, billable in TEST_CASES: 45 | with self.subTest(delta=delta, billable=billable): 46 | self.assertEqual(timedelta_to_billable(datetime.timedelta(**delta)), billable) 47 | -------------------------------------------------------------------------------- /utt/report/per_day/model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | from typing import Dict, List 4 | 5 | from utt.data_structures.activity import Activity 6 | from utt.report import formatter 7 | from utt.report.common import filter_activities_by_type 8 | 9 | 10 | class PerDayModel: 11 | def __init__(self, activities: List[Activity]): 12 | self.dates = _groupby_date(filter_activities_by_type(activities, Activity.Type.WORK)) 13 | 14 | 15 | def _groupby_date(activities: List[Activity]) -> List[Dict]: 16 | def key(act): 17 | """Key on date.""" 18 | return act.start.date() 19 | 20 | result = [] 21 | sorted_activities = sorted(activities, key=key) 22 | 23 | for date, _activities in itertools.groupby(sorted_activities, key): 24 | activities = list(_activities) 25 | duration = sum((act.duration for act in activities), datetime.timedelta()) 26 | result.append( 27 | { 28 | "duration": formatter.format_duration(duration), 29 | "hours": duration, 30 | "date": date, 31 | "projects": ", ".join( 32 | sorted( 33 | set(act.name.project for act in activities), 34 | key=lambda project: project.lower(), 35 | ) 36 | ), 37 | "tasks": ", ".join( 38 | sorted( 39 | set(act.name.task for act in activities), 40 | key=lambda task: task.lower(), 41 | ) 42 | ), 43 | } 44 | ) 45 | 46 | return sorted(result, key=lambda result: result["date"]) 47 | -------------------------------------------------------------------------------- /utt/report/per_day/view.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from utt.components.output import Output 4 | from utt.report import formatter 5 | from utt.report.per_day.model import PerDayModel 6 | 7 | from ..common import timedelta_to_billable 8 | 9 | 10 | class PerDayView: 11 | def __init__(self, model: PerDayModel): 12 | self._model = model 13 | 14 | def render(self, output: Output) -> None: 15 | print(file=output) 16 | print(formatter.title("Per Day"), file=output) 17 | print(file=output) 18 | 19 | fmt = "{date}: {hours}h {duration:>7} - {projects} - {tasks}" 20 | for date_activities in self._model.dates: 21 | date_render = fmt.format( 22 | date=date_activities["date"].isoformat(), 23 | hours=timedelta_to_billable(date_activities["hours"]), 24 | duration="({duration})".format(duration=date_activities["duration"]), 25 | projects=date_activities["projects"], 26 | tasks=date_activities["tasks"], 27 | ) 28 | print(date_render, file=output) 29 | 30 | def csv(self, output: Output) -> None: 31 | if not self._model.dates: 32 | print(" -- No activities for this time range --", file=output) 33 | return 34 | 35 | fieldnames = ["date", "hours", "duration", "projects", "tasks"] 36 | writer = csv.DictWriter(output, fieldnames=fieldnames) 37 | 38 | # Write header 39 | writer.writerow({fn: fn.capitalize() for fn in fieldnames}) 40 | 41 | for date_activities in self._model.dates: 42 | date_activities["hours"] = timedelta_to_billable(date_activities["hours"]).strip() 43 | writer.writerow(date_activities) 44 | -------------------------------------------------------------------------------- /test/integration/utt_example_plugin/utt/plugins/report_view.py: -------------------------------------------------------------------------------- 1 | from utt.api import _v1 2 | 3 | 4 | class MySubModel: 5 | def __init__(self, activities: _v1.Activities): 6 | self._activities = activities 7 | 8 | @property 9 | def activity_count(self): 10 | return len(self._activities) 11 | 12 | 13 | _v1.register_component(MySubModel, MySubModel) 14 | 15 | 16 | class MySubView: 17 | def __init__(self, my_sub_model: MySubModel): 18 | self._my_sub_model = my_sub_model 19 | 20 | def render(self, output: _v1.Output) -> None: 21 | print(f"Number of activities: {self._my_sub_model.activity_count}", file=output) 22 | 23 | 24 | class MyReportView(_v1.ReportView): 25 | def __init__(self, report_model: _v1.ReportModel, my_sub_model: MySubModel): 26 | self._report_model = report_model 27 | self._my_sub_model = my_sub_model 28 | 29 | def render(self, output: _v1.Output) -> None: 30 | _v1.SummaryView(self._report_model.summary_model).render(output) 31 | 32 | if self._report_model.args.show_per_day: 33 | _v1.PerDayView(self._report_model.per_day_model).render(output) 34 | else: 35 | _v1.ProjectsView(self._report_model.projects_model).render(output) 36 | 37 | _v1.ActivitiesView(self._report_model.activities_model).render(output) 38 | 39 | if ( 40 | self._report_model.args.range.start == self._report_model.args.range.end 41 | ) or self._report_model.args.show_details: 42 | _v1.DetailsView( 43 | self._report_model.details_model, show_comments=self._report_model.args.show_comments 44 | ).render(output) 45 | 46 | MySubView(self._my_sub_model).render(output) 47 | 48 | 49 | _v1.register_component(_v1.ReportView, MyReportView) 50 | -------------------------------------------------------------------------------- /utt/report/common.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | from typing import Dict, List 4 | 5 | from ..components.output import Output 6 | from ..data_structures.activity import Activity 7 | 8 | 9 | def print_dicts(dcts: List[Dict], output: Output) -> None: 10 | format_string = "({duration}) {project:<{projects_max_length}}: {name}" 11 | 12 | projects = (dct["project"] for dct in dcts) 13 | projects_max_length = max(itertools.chain([0], (len(project) for project in projects))) 14 | context = {"projects_max_length": projects_max_length} 15 | for dct in dcts: 16 | print(format_string.format(**dict(context, **dct)), file=output) 17 | 18 | 19 | def filter_activities_by_type(activities: List[Activity], activity_type: int) -> List[Activity]: 20 | return list(filter(lambda act: act.type == activity_type, activities)) 21 | 22 | 23 | def timedelta_to_billable(time_delta: datetime.timedelta) -> str: 24 | """Ad hoc method for rounding a decimal number of hours to "billable" 25 | 26 | Round to the nearest 6 minutes / 0.1 hours. This means that 2, 27 | 8, 14 minutes should get rounded down and 3, 9, 15 minutes 28 | should get rounded up. 29 | 30 | Note that Python's standard rounding function round() uses 31 | what's referred to as "banker's rounding". We fix it by adding 32 | 0.000001 (1e-6), or less than 4 milliseconds. 33 | 34 | Alternative would be to use the Decimal module, for instance as 35 | suggested here: https://stackoverflow.com/a/33019948/3061818 36 | """ 37 | hours = time_delta.total_seconds() / (60 * 60) 38 | # Round to nearest 6 minutes (0.1h), rounding up 3, 9, 15 mins (etc.) 39 | hours += 0.000001 # Hack to avoid 'banker's rounding. 40 | hours = round(hours * 10) / 10 41 | return "{hours:4.1f}".format(hours=hours) 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-and-test: 10 | runs-on: ubuntu-22.04 11 | strategy: 12 | matrix: 13 | python-version: ["3.10", "3.11", "3.12", "3.13"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Bootstrap 21 | run: make ci.bootstrap 22 | - name: Update version in pyproject.toml 23 | run: make ci.update-version-in-pyproject 24 | - name: Test 25 | run: make test 26 | - name: Upload dist directory 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: dist-${{ matrix.python-version }} 30 | path: dist 31 | 32 | publish: 33 | runs-on: ubuntu-22.04 34 | needs: build-and-test 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Setup python 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: 3.12 41 | - name: Bootstrap 42 | run: make ci.bootstrap 43 | - name: Configure poetry 44 | run: make ci.configure-poetry 45 | env: 46 | PYPI_API_TOKEN: ${{ secrets.pypi_api_token }} 47 | TEST_PYPI_API_TOKEN: ${{ secrets.test_pypi_api_token }} 48 | - name: Update version in pyproject.toml 49 | run: make ci.update-version-in-pyproject 50 | - name: Download dist directory 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: dist-3.12 54 | path: dist 55 | # - name: Publish (Test PyPI) 56 | # run: make ci.publish.test-pypi 57 | - name: Publish 58 | run: make ci.publish.pypi 59 | -------------------------------------------------------------------------------- /test/unit/test_parse_date.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from utt.components.report_args import parse_date 5 | 6 | VALID_ENTRIES = [ 7 | ("monday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 9), True), 8 | ("tuesday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 10), True), 9 | ("wednesday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 11), True), 10 | ("thursday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 5), True), 11 | ("friday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 6), True), 12 | ("saturday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 7), True), 13 | ("sunday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 8), True), 14 | ("2015-2-8", datetime.date(2015, 2, 11), datetime.date(2015, 2, 8), True), 15 | ("monday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 16), False), 16 | ("tuesday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 17), False), 17 | ("wednesday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 11), False), 18 | ("thursday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 12), False), 19 | ("friday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 13), False), 20 | ("saturday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 14), False), 21 | ("sunday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 15), False), 22 | ] 23 | 24 | 25 | class ParseDate(unittest.TestCase): 26 | def test_parse_date(self): 27 | for test_case in VALID_ENTRIES: 28 | report_date, today, expected_report_date, is_past = test_case 29 | with self.subTest(report_date=report_date, today=today, is_past=is_past): 30 | actual_report_date = parse_date(today, report_date, is_past) 31 | self.assertEqual(actual_report_date, expected_report_date) 32 | -------------------------------------------------------------------------------- /utt/components/add_entry.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | 4 | from .data_filename import DataFilename 5 | from .entries import Entries 6 | 7 | 8 | class AddEntry: 9 | def __init__(self, data_filename: DataFilename, entries: Entries): 10 | self._data_filename = data_filename 11 | self._entries = entries 12 | 13 | def __call__(self, new_entry): 14 | _create_directories_for_file(self._data_filename) 15 | insert_new_line_before = _insert_new_line(self._entries, new_entry) 16 | _append_line_to_file( 17 | self._data_filename, 18 | str(new_entry), 19 | insert_new_line_before=insert_new_line_before, 20 | ) 21 | 22 | 23 | def _append_line_to_file(filename, line, insert_new_line_before): 24 | try: 25 | with open(filename, "rb+") as file: 26 | file.seek(-1, os.SEEK_END) 27 | last_char = file.read(1) 28 | prepend_new_line = last_char != b"\n" 29 | except OSError as os_err: 30 | if os_err.errno not in [errno.EINVAL, errno.ENOENT]: 31 | raise 32 | prepend_new_line = False 33 | 34 | with open(filename, "a") as file: 35 | if prepend_new_line: 36 | file.write("\n") 37 | if insert_new_line_before: 38 | file.write("\n") 39 | file.write(line) 40 | file.write("\n") 41 | 42 | 43 | def _create_directories_for_file(filename): 44 | try: 45 | os.makedirs(os.path.dirname(filename)) 46 | except OSError as err: 47 | # If the exception is errno.EEXIST, we ignore it 48 | if err.errno != errno.EEXIST: 49 | raise 50 | 51 | 52 | def _insert_new_line(entries, new_entry): 53 | if not entries: 54 | return False 55 | 56 | last_entry = entries[-1] 57 | return last_entry.datetime.date() != new_entry.datetime.date() 58 | -------------------------------------------------------------------------------- /utt/data_structures/activity.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | from .name import Name 6 | 7 | 8 | class Activity: 9 | class Type: 10 | WORK = 0 11 | BREAK = 1 12 | IGNORED = 2 13 | 14 | @staticmethod 15 | def name(type: int) -> str: 16 | return { 17 | Activity.Type.WORK: "WORK", 18 | Activity.Type.BREAK: "BREAK", 19 | Activity.Type.IGNORED: "IGNORED", 20 | }[type] 21 | 22 | def __init__( 23 | self, 24 | name: str, 25 | start: datetime, 26 | end: datetime, 27 | is_current_activity: bool, 28 | comment: Optional[str], 29 | ): 30 | self.name = Name(name) 31 | self.start = start 32 | self.end = end 33 | self.duration = self.end - self.start 34 | self.type = Activity._type_from_name(name) 35 | self.is_current_activity = is_current_activity 36 | self.comment = comment 37 | 38 | @staticmethod 39 | def _type_from_name(name): 40 | if name[-3:] == "***": 41 | return Activity.Type.IGNORED 42 | if name[-2:] == "**": 43 | return Activity.Type.BREAK 44 | 45 | return Activity.Type.WORK 46 | 47 | def clip(self, start=None, end=None): 48 | """Return a new Activity with the start and end time clipped to the 49 | given range. 50 | 51 | Parameters 52 | ---------- 53 | start : datetime.datetime 54 | Start time to clip to (inclusive). 55 | end : datetime.datetime 56 | End time to clip to (inclusive). 57 | 58 | Returns 59 | ------- 60 | new_activity : Activity 61 | """ 62 | new_activity = copy.copy(self) 63 | if start is not None: 64 | new_activity.start = min(new_activity.end, max(new_activity.start, start)) 65 | if end is not None: 66 | new_activity.end = max(new_activity.start, min(new_activity.end, end)) 67 | new_activity.duration = new_activity.end - new_activity.start 68 | return new_activity 69 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to utt 2 | 3 | First off, thanks for taking the time to contribute! 4 | 5 | Below are the answers to the most frequently asked questions. 6 | 7 | 8 | ## How can I report an issue? 9 | 10 | Open an issue at https://github.com/larose/utt/issues with as much 11 | information as possible. 12 | 13 | 14 | ## How can I request a new feature? 15 | 16 | Open an issue at https://github.com/larose/utt/issues and describe the 17 | feature you would like. 18 | 19 | Note that we try to keep utt lean and your feature request may be 20 | declined. In this case, you can extend utt with a 21 | [plugin](PLUGINS.md). We will be happy to list it in the plugins 22 | section. 23 | 24 | 25 | ## How can I contribute a bug fix? 26 | 27 | See [DEVELOPMENT.md](DEVELOPMENT.md) how to set up your environment, fix the 28 | bug and then create a pull request with your bug fix. Your code change 29 | must contain tests to 1) prove that it fixes the bug and 2) prevent 30 | the same regression in the future. 31 | 32 | 33 | ## How can I contribute a new feature? 34 | 35 | We try to keep utt as lean as possible. Before developing a new 36 | feature, it's best to first contact Mathieu Larose 37 | <> as he will guide you whether to 38 | implement it as a core feature or as a 39 | [plugin](#how-can-i-create-a-plugin). 40 | 41 | If you are implementing a new feature within utt, first see 42 | [DEVELOPMENT.md](DEVELOPMENT.md) how to set up your environment, then 43 | implement your new feature and finally open a new pull request. 44 | 45 | Your code change must contain tests. 46 | 47 | 48 | ## How can I create a plugin? 49 | 50 | See [PLUGINS.md](PLUGINS.md) how to create a plugin. 51 | 52 | Write to Mathieu Larose <> if you would 53 | like your plugin to be listed in the [../README.md](../README.md). 54 | 55 | 56 | ## How can I contribute a non-trivial code change that is not a feature request? 57 | 58 | Before doing a refactoring that involves many changes, it's best to 59 | first contact Mathieu Larose <> to discuss 60 | the architecture. 61 | 62 | Then see [DEVELOPMENT.md](DEVELOPMENT.md) how to set up your environment, then 63 | do your refactoring and finally open a new pull request. 64 | 65 | Please add tests if this is relevant. 66 | -------------------------------------------------------------------------------- /docs/PLUGINS.md: -------------------------------------------------------------------------------- 1 | # How to write a utt plugin 2 | 3 | utt plugins allow you to register new components and commands to 4 | utt. 5 | 6 | A utt plugin is simply a [namespace 7 | package](https://packaging.python.org/guides/packaging-namespace-packages/) 8 | in the `utt.plugins`. 9 | 10 | You can find an [example plugin](../test/integration/utt_example_plugin) 11 | in the tests. 12 | 13 | ## How to add a new command 14 | 15 | [foo_command.py](../test/integration/utt_example_plugin/utt/plugins/foo_command.py) 16 | 17 | ``` 18 | from utt.api import _v1 19 | 20 | 21 | class FooHandler: 22 | def __init__(self, now: _v1.Now, output: _v1.Output): 23 | self._now = now 24 | self._output = output 25 | 26 | def __call__(self): 27 | print(f"Now: {self._now}", file=self._output) 28 | 29 | 30 | foo_command = _v1.Command(name="foo", description="Foo", handler_class=FooHandler, add_args=lambda p: None) 31 | 32 | 33 | _v1.register_command(foo_command) 34 | ``` 35 | 36 | This plugins first imports utt's api: 37 | 38 | ``` 39 | from utt.api import _v1 40 | ``` 41 | 42 | Note that `_v1` is the latest utt's api version. It's prefixed with 43 | `_` to indicate that the version (`v1`) is not stable yet. `_` will be 44 | removed once it is be stable. 45 | 46 | Then the plugin declares a command handler and a command: 47 | 48 | ``` 49 | class FooHandler: 50 | ... 51 | 52 | foo_command = _v1.Command(name="foo", description="Foo", handler_class=FooHandler, add_args=lambda p: None) 53 | ... 54 | ``` 55 | 56 | The handler can receive arguments that are injected by utt: 57 | 58 | ``` 59 | class FooHandler: 60 | def __init__(self, now: _v1.Now, output: _v1.Output): 61 | ... 62 | ``` 63 | 64 | Read [`../utt/api/_v1/__init__.py`](../utt/api/_v1/__init__.py) to see 65 | the list of available types that can be injected. 66 | 67 | Finally, the plugin registers the new command to utt: 68 | 69 | ``` 70 | _v1.register_command(foo_command) 71 | ``` 72 | 73 | ## How to override the report view 74 | 75 | See 76 | [report_view.py](../test/integration/utt_example_plugin/utt/plugins/report_view.py) 77 | 78 | 79 | ## Best practices 80 | 81 | All symbols exported in 82 | [`../utt/api/_v1/__init__.py`](../utt/api/_v1/__init__.py) are part of 83 | the public api and are safe to use. (However, note that `_v1` is still 84 | in development and we may introduce breaking changes until it becomes 85 | stable.) 86 | 87 | Therefore, anything not exported in 88 | [`../utt/api/_v1/__init__.py`](../utt/api/_v1/__init__.py) is not part 89 | of utt's api and should not be imported in your plugin. 90 | -------------------------------------------------------------------------------- /utt/api/_v1/_private.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from configparser import ConfigParser 4 | from typing import Any, Type 5 | 6 | import cargo 7 | 8 | from ...command import Command 9 | from ...components.activities import Activities, activities 10 | from ...components.add_entry import AddEntry 11 | from ...components.commands import Commands 12 | from ...components.config import config 13 | from ...components.config_dirname import ConfigDirname, config_dirname 14 | from ...components.config_filename import ConfigFilename, config_filename 15 | from ...components.data_dirname import DataDirname, data_dirname 16 | from ...components.data_filename import DataFilename, data_filename 17 | from ...components.default_config import DefaultConfig 18 | from ...components.entries import Entries, entries 19 | from ...components.entry_lines import EntryLines 20 | from ...components.entry_parser import EntryParser 21 | from ...components.now import Now, now 22 | from ...components.output import Output 23 | from ...components.parse_args import parse_args 24 | from ...components.report_args import ReportArgs, csv_section_name_to_csv_section, report_args # noqa 25 | from ...components.report_model import ReportModel 26 | from ...components.report_model.model import report 27 | from ...report.csv_view import CSVReportView 28 | 29 | 30 | def create_container(): 31 | _container = cargo.containers.Standard() 32 | 33 | _container[Activities] = activities 34 | _container[AddEntry] = AddEntry 35 | _container[argparse.Namespace] = parse_args 36 | _container[Commands] = [] 37 | _container[ConfigParser] = config 38 | _container[ConfigDirname] = config_dirname 39 | _container[ConfigFilename] = config_filename 40 | _container[DataDirname] = data_dirname 41 | _container[DataFilename] = data_filename 42 | _container[DefaultConfig] = DefaultConfig 43 | _container[Entries] = entries 44 | _container[EntryParser] = EntryParser 45 | _container[EntryLines] = EntryLines 46 | _container[Now] = now 47 | _container[Output] = sys.stdout 48 | _container[ReportArgs] = report_args 49 | _container[ReportModel] = report 50 | _container[CSVReportView] = CSVReportView 51 | 52 | return _container 53 | 54 | 55 | def register_command(command: Command): 56 | commands[command.name] = command 57 | container[Commands].append(command) 58 | container[command.handler_class] = command.handler_class 59 | 60 | 61 | def register_component(interface: Type, constructor: Any): 62 | container[interface] = constructor 63 | 64 | 65 | commands = {} 66 | container = create_container() 67 | -------------------------------------------------------------------------------- /docs/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | **Table of Contents** 4 | 5 | - [System dependencies](#system-dependencies) 6 | - [Python dependencies](#python-dependencies) 7 | - [Formatting code](#formatting-code) 8 | - [Executing `utt` from source](#executing-utt-from-source) 9 | - [Tests](#tests) 10 | - [Unit tests](#unit-tests) 11 | - [Integration Tests](#integration-tests) 12 | 13 | 14 | 15 | ## System dependencies 16 | 17 | You will need the following tools on your system to work on utt: 18 | 19 | - [Python](https://www.python.org/) 20 | - [Make](https://www.gnu.org/software/make/) 21 | - [Poetry](https://python-poetry.org/) 22 | - [Docker](https://www.docker.com/) 23 | 24 | 25 | ## Python dependencies 26 | 27 | Once the dependencies above have been installed on your system, you 28 | can install utt's Python dependencies with this command: 29 | 30 | `$ make bootstrap` 31 | 32 | 33 | ## Formatting code 34 | 35 | All code must be properly formatted to be accepted in utt. You can 36 | format the code with this command: 37 | 38 | `$ make format` 39 | 40 | 41 | ## Executing `utt` from source 42 | 43 | To run utt from local source: 44 | 45 | `$ poetry run utt` 46 | 47 | 48 | ## Tests 49 | 50 | This section is very important as most code changes need tests. 51 | 52 | You can run all tests with this command: 53 | 54 | `$ make test` 55 | 56 | 57 | ### Unit tests 58 | 59 | Unit tests are in-memory tests (i.e. no I/O) that runs very fast. They 60 | are located in [../test/unit](../test/unit). 61 | 62 | To run them: 63 | 64 | `$ make test.unit` 65 | 66 | 67 | #### Integration Tests 68 | 69 | Integration tests test the entire system. They 70 | 71 | 1. build utt 72 | 2. build and start a docker container 73 | 3. install utt in the container 74 | 4. run tests in the container 75 | 76 | 77 | Integration tests test the entire system. They 1) build utt, 2) build 78 | and start a docker container, 3) install utt in the container, and 4) 79 | run tests in the container. 80 | 81 | Integration tests are located in 82 | [../test/integration](../test/integration) 83 | 84 | 85 | To run them: 86 | 87 | `$ make test.integration` 88 | 89 | The tests are listed in this [Makefile](../test/integration/Makefile) 90 | 91 | You can also run a specific test. For example, to run the `hello` 92 | test: 93 | 94 | `$ make test.integration INTEGRATION_CMD=hello` 95 | 96 | You can also spawn a shell in the container: 97 | 98 | `$ make test.integration INTEGRATION_CMD=shell` 99 | 100 | And, then you can run a specific test: 101 | 102 | `$ make hello` 103 | 104 | Note that the previous command must be run inside the container. 105 | -------------------------------------------------------------------------------- /utt/report/summary/view.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional 3 | 4 | from ...components.output import Output 5 | from ...data_structures.activity import Activity 6 | from .. import formatter 7 | from .model import SummaryModel 8 | 9 | 10 | class SummaryView: 11 | def __init__(self, model: SummaryModel): 12 | self._model = model 13 | 14 | def render(self, output: Output) -> None: 15 | print(file=output) 16 | date_str = format_date(self._model.report_range.start) 17 | if self._model.report_range.start != self._model.report_range.end: 18 | date_str = " ".join([date_str, "to", format_date(self._model.report_range.end)]) 19 | print(formatter.title(date_str), file=output) 20 | 21 | print(file=output) 22 | 23 | current_activity_duration = None 24 | current_activity_type = None 25 | 26 | if self._model.last_activity and self._model.last_activity.is_current_activity: 27 | current_activity_duration = self._model.last_activity.duration 28 | current_activity_type = self._model.last_activity.type 29 | 30 | _print_time(self._model, self._model.total_time, " Total", current_activity_duration, output) 31 | _print_time( 32 | self._model, 33 | self._model.working_time, 34 | "Working", 35 | current_activity_duration if current_activity_type == Activity.Type.WORK else None, 36 | output, 37 | ) 38 | _print_time( 39 | self._model, 40 | self._model.break_time, 41 | " Break", 42 | current_activity_duration if current_activity_type == Activity.Type.BREAK else None, 43 | output, 44 | ) 45 | 46 | 47 | def _print_time( 48 | model: SummaryModel, 49 | duration: datetime.timedelta, 50 | activity_name: str, 51 | current_activity_duration: Optional[datetime.timedelta], 52 | output: Output, 53 | ) -> None: 54 | print( 55 | "%s: %s" 56 | % ( 57 | activity_name, 58 | formatter.format_duration(duration), 59 | ), 60 | end="", 61 | file=output, 62 | ) 63 | 64 | if current_activity_duration: 65 | print( 66 | " (%s + %s)" 67 | % ( 68 | formatter.format_duration(duration - current_activity_duration), 69 | formatter.format_duration(current_activity_duration), 70 | ), 71 | end="", 72 | file=output, 73 | ) 74 | 75 | print(file=output) 76 | 77 | 78 | def format_date(date: datetime.date) -> str: 79 | return date.strftime("%A, %b %d, %Y (week {week})".format(week=date.isocalendar()[1])) 80 | -------------------------------------------------------------------------------- /scripts/publish.py: -------------------------------------------------------------------------------- 1 | # License: MIT 2 | # Source: https://github.com/larose/cargo/blob/a8d46de04b38a0847ba7ec60591aed3a4d6eecf5/scripts/publish.py 3 | from __future__ import annotations 4 | 5 | import subprocess 6 | import sys 7 | import typing 8 | 9 | import requests 10 | 11 | 12 | class PackageInfo(typing.NamedTuple): 13 | name: str 14 | version: str 15 | 16 | @staticmethod 17 | def from_poetry_version_output(poetry_version_output: str) -> PackageInfo: 18 | name, version = poetry_version_output.rsplit(" ", 1) 19 | return PackageInfo(name=name, version=version) 20 | 21 | 22 | def publish(repo_name: str): 23 | print(f"Publishing...") 24 | try: 25 | 26 | subprocess.check_call( 27 | ["poetry", "publish", "-vvv", "-n", "-r", repo_name], 28 | stdout=sys.stdout, 29 | stderr=sys.stderr, 30 | ) 31 | except subprocess.CalledProcessError: 32 | sys.exit(1) 33 | 34 | 35 | def tag(package_version): 36 | tag_name = f"v{package_version}" 37 | 38 | subprocess.check_call( 39 | ["git", "tag", tag_name], stdout=sys.stdout, stderr=sys.stderr 40 | ) 41 | 42 | subprocess.check_call( 43 | ["git", "push", "origin", tag_name], stdout=sys.stdout, stderr=sys.stderr 44 | ) 45 | 46 | 47 | def check_version_already_published(repository_url, package_name, version): 48 | package_metadata_url = f"{repository_url}/{package_name}/{version}/json" 49 | package_metadata_response = requests.get(package_metadata_url) 50 | 51 | print(f"{package_metadata_url} returned {package_metadata_response.status_code}") 52 | 53 | return package_metadata_response.status_code == 200 54 | 55 | 56 | def main(): 57 | repo_name = sys.argv[1] 58 | repo_json_api_url = sys.argv[2] 59 | 60 | print(f"Repository name: {repo_name}") 61 | print(f"Repository JSON API URL: {repo_json_api_url}") 62 | 63 | poetry_version_output = ( 64 | subprocess.check_output(["poetry", "version", "-n"]).decode().strip() 65 | ) 66 | package_info = PackageInfo.from_poetry_version_output(poetry_version_output) 67 | 68 | print(f"Package name: {package_info.name}") 69 | print(f"Package version: {package_info.version}") 70 | 71 | if package_info.version == "0": 72 | print("No version to publish") 73 | return 74 | 75 | version_already_published = check_version_already_published( 76 | repo_json_api_url, package_info.name, package_info.version 77 | ) 78 | if version_already_published: 79 | print(f"Version has already been published") 80 | return 81 | 82 | publish(repo_name) 83 | tag(package_info.version) 84 | 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CHANGELOG_FILENAME=CHANGELOG.md 2 | INTEGRATION_DIR=test/integration 3 | GENERATED_DOCKERFILE=$(INTEGRATION_DIR)/Dockerfile.generated 4 | TEMPLATE_DOCKERFILE=$(INTEGRATION_DIR)/Dockerfile.template 5 | UNIT_DIR=test/unit 6 | TEST_DOCKER_IMAGE=utt-integration 7 | SOURCE_DIRS=utt test 8 | PYPI_REPO_NAME=pypi 9 | PYPI_JSON_API_URL=https://pypi.org/pypi 10 | PYPI_LEGACY_API_URL=https://upload.pypi.org/legacy/ 11 | TEST_PYPI_REPO_NAME=test-pypi 12 | TEST_PYPI_JSON_API_URL=https://test.pypi.org/pypi 13 | TEST_PYPI_LEGACY_API_URL=https://test.pypi.org/legacy/ 14 | 15 | .PHONY: build 16 | build: 17 | poetry build 18 | 19 | .PHONY: bootstrap 20 | bootstrap: bootstrap.install 21 | 22 | .PHONY: bootstrap.install 23 | bootstrap.install: 24 | poetry install 25 | 26 | .PHONY: ci.bootstrap 27 | ci.bootstrap: 28 | pip install poetry 29 | make bootstrap 30 | 31 | .PHONY: ci.configure-poetry 32 | ci.configure-poetry: 33 | poetry config repositories.$(PYPI_REPO_NAME) $(PYPI_LEGACY_API_URL) 34 | @poetry config pypi-token.$(PYPI_REPO_NAME) $(PYPI_API_TOKEN) 35 | poetry config repositories.$(TEST_PYPI_REPO_NAME) $(TEST_PYPI_LEGACY_API_URL) 36 | @poetry config pypi-token.$(TEST_PYPI_REPO_NAME) $(TEST_PYPI_API_TOKEN) 37 | poetry config --list 38 | 39 | .PHONY: ci.publish.pypi 40 | ci.publish.pypi: 41 | python3 scripts/publish.py $(PYPI_REPO_NAME) $(PYPI_JSON_API_URL) 42 | 43 | .PHONY: ci.publish.test-pypi 44 | ci.publish.test-pypi: 45 | python3 scripts/publish.py $(TEST_PYPI_REPO_NAME) $(TEST_PYPI_JSON_API_URL) 46 | 47 | .PHONY: ci.update-version-in-pyproject 48 | ci.update-version-in-pyproject: 49 | python3 scripts/update_version_in_pyproject.py $(CHANGELOG_FILENAME) 50 | python3 scripts/update_version_txt.py utt/version.txt 51 | 52 | .PHONY: clean 53 | clean: 54 | rm -rf dist 55 | rm -f $(INTEGRATION_DIR)/*.whl 56 | 57 | .PHONY: format 58 | format: 59 | poetry run black $(SOURCE_DIRS) 60 | poetry run isort $(SOURCE_DIRS) 61 | 62 | .PHONY: test 63 | test: test.format test.integration test.types test.unit 64 | 65 | .PHONY: test.format 66 | test.format: 67 | poetry run flake8 $(SOURCE_DIRS) 68 | poetry run isort --check-only --diff --ignore-whitespace --quiet $(SOURCE_DIRS) 69 | poetry run black --check --diff $(SOURCE_DIRS) 70 | 71 | .PHONY: test.integration 72 | test.integration: clean build 73 | cp dist/utt-*-py3-none-any.whl $(INTEGRATION_DIR) 74 | 75 | python3 -c 'import sys; print(f"FROM python:{sys.version_info.major}.{sys.version_info.minor}-slim-bullseye")' > $(GENERATED_DOCKERFILE) 76 | cat $(TEMPLATE_DOCKERFILE) >> $(GENERATED_DOCKERFILE) 77 | docker build --tag $(TEST_DOCKER_IMAGE) --file $(GENERATED_DOCKERFILE) $(INTEGRATION_DIR) 78 | docker run --rm $(TEST_DOCKER_IMAGE) $(INTEGRATION_CMD) 79 | 80 | .PHONY: test.types 81 | test.types: 82 | poetry run pyright 83 | 84 | .PHONY: test.unit 85 | test.unit: 86 | poetry run python -m unittest discover -v 87 | -------------------------------------------------------------------------------- /test/unit/test_entry.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from utt.components.entry_parser import EntryParser 5 | 6 | VALID_ENTRIES = [ 7 | { 8 | "name": "2014-03-23 4:15 An activity", 9 | "expected_datetime": datetime.datetime(2014, 3, 23, 4, 15), 10 | "expected_name": "An activity", 11 | "expected_comment": None, 12 | }, 13 | { 14 | "name": "2014-1-23 09:17 lunch**", 15 | "expected_datetime": datetime.datetime(2014, 1, 23, 9, 17), 16 | "expected_name": "lunch**", 17 | "expected_comment": None, 18 | }, 19 | { 20 | "name": "2014-07-23 10:30 +work", 21 | "expected_datetime": datetime.datetime(2014, 7, 23, 10, 30), 22 | "expected_name": "+work", 23 | "expected_comment": None, 24 | }, 25 | { 26 | "name": "2014-11-23 10:30 -work", 27 | "expected_datetime": datetime.datetime(2014, 11, 23, 10, 30), 28 | "expected_name": "-work", 29 | "expected_comment": None, 30 | }, 31 | { 32 | "name": "2014-03-23 4:15 a-project: a_task", 33 | "expected_datetime": datetime.datetime(2014, 3, 23, 4, 15), 34 | "expected_name": "a-project: a_task", 35 | "expected_comment": None, 36 | }, 37 | { 38 | "name": "2014-03-23 4:15 a-project: a_task and something", 39 | "expected_datetime": datetime.datetime(2014, 3, 23, 4, 15), 40 | "expected_name": "a-project: a_task and something", 41 | "expected_comment": None, 42 | }, 43 | { 44 | "name": "2014-03-23 4:15 a-project: a_task and something # a comment", 45 | "expected_datetime": datetime.datetime(2014, 3, 23, 4, 15), 46 | "expected_name": "a-project: a_task and something", 47 | "expected_comment": "a comment", 48 | }, 49 | ] 50 | 51 | INVALID_ENTRIES = [ 52 | ("",), 53 | ("2014-",), 54 | ("2014-1-1",), 55 | ("9:15",), 56 | ("2015-1-1 9:15",), 57 | ("2014-03-23 An activity",), 58 | ] 59 | 60 | 61 | class ValidEntry(unittest.TestCase): 62 | def test_valid_entries(self): 63 | for test_case in VALID_ENTRIES: 64 | with self.subTest(name=test_case["name"]): 65 | entry_parser = EntryParser() 66 | entry = entry_parser.parse(test_case["name"]) 67 | if entry is None: 68 | self.fail("EntryParser returned None for valid entry") 69 | 70 | self.assertEqual(entry.datetime, test_case["expected_datetime"]) 71 | self.assertEqual(entry.name, test_case["expected_name"]) 72 | self.assertEqual(entry.comment, test_case["expected_comment"]) 73 | 74 | 75 | class InvalidEntry(unittest.TestCase): 76 | def test_invalid_entries(self): 77 | for test_case in INVALID_ENTRIES: 78 | with self.subTest(text=test_case[0]): 79 | entry_parser = EntryParser() 80 | entry = entry_parser.parse(test_case[0]) 81 | self.assertIsNone(entry) 82 | -------------------------------------------------------------------------------- /utt/report/details/view.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from datetime import datetime 3 | 4 | from ...components.output import Output 5 | from ...data_structures.activity import Activity 6 | from .. import formatter 7 | from ..common import timedelta_to_billable 8 | from .model import DetailsModel 9 | 10 | 11 | class DetailsView: 12 | def __init__(self, model: DetailsModel, show_comments: bool = False): 13 | self._model = model 14 | self._show_comments = show_comments 15 | 16 | def _create_line_for_render(self, activity: Activity) -> str: 17 | format_str = "(%s) %s-%s %s" 18 | line = [ 19 | formatter.format_duration(activity.duration), 20 | format_time(activity.start), 21 | format_time(activity.end), 22 | activity.name, 23 | ] 24 | 25 | if self._show_comments and activity.comment: 26 | format_str = " ".join([format_str, " # %s"]) 27 | line.append(activity.comment) 28 | 29 | return format_str % tuple(line) 30 | 31 | def render(self, output: Output) -> None: 32 | print(file=output) 33 | print(formatter.title("Details"), file=output) 34 | print(file=output) 35 | 36 | # Print date only when the activities have different dates. 37 | if not self._model.activities: 38 | print_date = False 39 | else: 40 | print_date = self._model.activities[0].start.date() != self._model.activities[-1].start.date() 41 | current_date = None 42 | for activity in self._model.activities: 43 | if print_date and current_date != activity.start.date(): 44 | if current_date is not None: 45 | print("", file=output) 46 | current_date = activity.start.date() 47 | print("{}:".format(current_date.isoformat()), file=output) 48 | print("", file=output) 49 | print(self._create_line_for_render(activity), file=output) 50 | 51 | print(file=output) 52 | 53 | def csv(self, output: Output) -> None: 54 | if not self._model.activities: 55 | print(" -- No activities for this time range --", file=output) 56 | return 57 | 58 | fieldnames = ["date", "projects", "tasks", "duration", "type", "comment"] 59 | writer = csv.DictWriter(output, fieldnames=fieldnames) 60 | writer.writerow({fn: fn.capitalize() for fn in fieldnames}) 61 | 62 | for activity in self._model.activities: 63 | task_details = { 64 | "date": activity.start.strftime("%Y-%m-%d"), 65 | "projects": activity.name.project, 66 | "tasks": activity.name.task, 67 | "duration": timedelta_to_billable(activity.duration).strip(), 68 | "type": Activity.Type.name(activity.type), 69 | "comment": activity.comment, 70 | } 71 | writer.writerow(task_details) 72 | 73 | 74 | def format_time(datetime: datetime) -> str: 75 | return datetime.strftime("%H:%M") 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.31 (2025-11-01) 2 | 3 | * Remove support for Python 3.8 and 3.9 4 | * Remove timezone support 5 | 6 | ## 1.30 (2024-01-17) 7 | 8 | * Add 'per-task' CSV report type 9 | 10 | ## 1.29 (2021-01-16) 11 | 12 | * Show total time in summary section 13 | * Remove redundant "time" label from summary section 14 | 15 | ## 1.28 (2021-01-16) 16 | 17 | * Fix current activity start time overflow issue 18 | 19 | ## 1.27 (2020-08-29) 20 | 21 | * Update pytz 22 | 23 | ## 1.26 (2020-07-18) 24 | 25 | * Migrate to isort 5 26 | * Weekly working and break times are no longer shown in single-day reports 27 | * The current activity is now included in multi-day reports 28 | 29 | ## 1.25 (2020-05-16) 30 | 31 | * Use Cargo for dependency injection. 32 | 33 | ## 1.24 (2020-05-16) 34 | 35 | * Migrate from Pipenv to Poetry 36 | * Migrate from CircleCI to GitHub Actions 37 | 38 | ## 1.23 (2020-05-09) 39 | 40 | * utt report --week now works on Python 3.7 41 | 42 | ## 1.22 (2020-04-05) 43 | 44 | * Warn if Python version is unsupported. 45 | * [Security] Bump bleach from 3.1.2 to 3.1.4 46 | 47 | ## 1.21 (2020-03-29) 48 | 49 | * Plugins can now register components 50 | 51 | ## 1.19 (2020-03-14) 52 | 53 | * Prevent EINVAL when reading empty log file 54 | 55 | ## 1.18 (2020-01-01) 56 | 57 | * Add plugin support 58 | 59 | ## 1.17 (2019-12-27) 60 | 61 | * Drop Python 2 support 62 | 63 | ## 1.16 (2019-12-24) 64 | 65 | * Add ability to comment/annotate an entry 66 | 67 | ## 1.15 (2019-12-14) 68 | 69 | * Add month and week switch to filter by month and week 70 | * Add csv switch to output in csv 71 | 72 | ## 1.14 (2019-12-07) 73 | 74 | * Add `--details` flag to show details even on multi-day reports 75 | 76 | ## 1.13 (2019-11-27) 77 | 78 | * Add switches to report for specific project, and also hours worked 79 | per day 80 | 81 | ## 1.12 (2018-11-11) 82 | 83 | * Format duration > 24h as hours instead of days 84 | 85 | ## 1.11 (2018-11-11) 86 | 87 | * Fix current activity start time when previous entry is "hello" 88 | 89 | ## 1.10 (2018-11-10) 90 | 91 | * utt now works on Windows 92 | 93 | ## 1.9 (2018-11-04) 94 | 95 | * Add timezone support (experimental) 96 | * Range report accepts day of week 97 | 98 | ## 1.8 (2018-08-31) 99 | 100 | * Add range report 101 | * Add overnight activity support 102 | 103 | ## 1.7 (2017-05-21) 104 | 105 | * Add bash completion 106 | 107 | ## 1.6 (2016-04-17) 108 | 109 | * Strech appends an entry instead of modifying the latest entry 110 | in-place 111 | 112 | ## 1.5 (2016-04-10) 113 | 114 | * Insert a blank line between days in log file 115 | * Add argument --version 116 | 117 | ## 1.4 (2015-02-15) 118 | 119 | * Report accepts days of the week 120 | * Align project name to the left 121 | 122 | ## 1.3 (2014-09-30) 123 | 124 | * Add Python 2 compatibility 125 | * Add argument --now 126 | 127 | ## 1.2 (2014-09-08) 128 | 129 | * Add stretch command 130 | * Name and project are case insensitive 131 | 132 | ## 1.1 (2014-03-27) 133 | 134 | * Add weekly working and break times 135 | 136 | ## 1.0 (2013-07-21) 137 | 138 | * Original implementation 139 | -------------------------------------------------------------------------------- /utt/components/activities.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | from typing import List, Optional 4 | 5 | from ..constants import HELLO_ENTRY_NAME 6 | from ..data_structures.activity import Activity 7 | from .entries import Entries 8 | from .now import Now 9 | from .report_args import DateRange, ReportArgs 10 | 11 | Activities = List[Activity] 12 | 13 | 14 | def filter_activities_by_project(activities: Activities, project_name: Optional[str]): 15 | for activity in activities: 16 | if project_name is None or project_name == activity.name.project: 17 | yield activity 18 | 19 | 20 | def filter_activities_by_range(activities: Activities, date_range: DateRange): 21 | start_datetime = datetime.datetime(date_range.start.year, date_range.start.month, date_range.start.day) 22 | end_datetime = datetime.datetime(date_range.end.year, date_range.end.month, date_range.end.day, 23, 59, 59, 99999) 23 | 24 | for full_activity in activities: 25 | activity = full_activity.clip(start_datetime, end_datetime) 26 | if activity.duration > datetime.timedelta(): 27 | yield activity 28 | 29 | 30 | def get_current_activity( 31 | current_activity_name: Optional[str], 32 | last_activity: Optional[Activity], 33 | now: Now, 34 | start_datetime: datetime.datetime, 35 | end_datetime: datetime.datetime, 36 | ) -> Optional[Activity]: 37 | if current_activity_name is None or last_activity is None: 38 | return 39 | 40 | last_activity_end = max(last_activity.end, start_datetime) 41 | 42 | now_is_between_last_activity_and_end_report_range = last_activity_end < now <= end_datetime 43 | if not now_is_between_last_activity_and_end_report_range: 44 | return 45 | 46 | return Activity(current_activity_name, last_activity_end, now, True, comment=None) 47 | 48 | 49 | def remove_hello_activities(activities): 50 | for activity in activities: 51 | if activity.name.name != HELLO_ENTRY_NAME: 52 | yield activity 53 | 54 | 55 | def activities(report_args: ReportArgs, now: Now, entries: Entries) -> Activities: 56 | activities = list(_activities(entries)) 57 | _filtered_activities = list(filter_activities_by_range(activities, report_args.range)) 58 | 59 | start_datetime = datetime.datetime( 60 | year=report_args.range.start.year, month=report_args.range.start.month, day=report_args.range.start.day 61 | ) 62 | 63 | end_datetime = datetime.datetime( 64 | year=report_args.range.end.year, month=report_args.range.end.month, day=report_args.range.end.day 65 | ) + datetime.timedelta(days=1) 66 | 67 | last_activity = activities[-1] if activities else None 68 | current_activity = get_current_activity( 69 | report_args.current_activity_name, last_activity, now, start_datetime, end_datetime 70 | ) 71 | if current_activity is not None: 72 | _filtered_activities.append(current_activity) 73 | 74 | _filtered_activities = list(remove_hello_activities(_filtered_activities)) 75 | _filtered_activities = list(filter_activities_by_project(_filtered_activities, report_args.project_name_filter)) 76 | 77 | return _filtered_activities 78 | 79 | 80 | def _activities(entries: Entries): 81 | for prev_entry, next_entry in _pairwise(entries): 82 | activity = Activity( 83 | next_entry.name, 84 | prev_entry.datetime, 85 | next_entry.datetime, 86 | False, 87 | comment=next_entry.comment, 88 | ) 89 | yield activity 90 | 91 | 92 | def _pairwise(iterable): 93 | "s -> (s0,s1), (s1,s2), (s2, s3), ..." 94 | a, b = itertools.tee(iterable) 95 | next(b, None) 96 | return zip(a, b) 97 | -------------------------------------------------------------------------------- /utt/plugins/0_report.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from ..api import _v1 4 | 5 | 6 | class ReportHandler: 7 | def __init__( 8 | self, 9 | report_model: _v1._private.ReportModel, 10 | output: _v1.Output, 11 | report_view: _v1.ReportView, 12 | csv_report_view: _v1._private.CSVReportView, 13 | ): 14 | self._report = report_model 15 | self._output = output 16 | self._report_view = report_view 17 | self._csv_report_view = csv_report_view 18 | 19 | def __call__(self): 20 | view = self._get_view() 21 | view.render(self._output) 22 | 23 | def _get_view(self): 24 | if self._report.args.csv_section: 25 | return self._csv_report_view 26 | 27 | return self._report_view 28 | 29 | 30 | def add_args(parser: argparse.ArgumentParser): 31 | parser.add_argument("report_date", metavar="date", type=str, nargs="?") 32 | 33 | parser.add_argument( 34 | "--current-activity", 35 | default="-- Current Activity --", 36 | type=str, 37 | help="Set the current activity", 38 | ) 39 | 40 | parser.add_argument( 41 | "--no-current-activity", 42 | action="store_true", 43 | default=False, 44 | help="Do not display the current activity", 45 | ) 46 | 47 | parser.add_argument( 48 | "--from", 49 | default=None, 50 | dest="from_date", 51 | type=str, 52 | help="Specify an inclusive start date to report.", 53 | ) 54 | 55 | parser.add_argument( 56 | "--to", 57 | default=None, 58 | dest="to_date", 59 | type=str, 60 | help=( 61 | "Specify an inclusive end date to report. " 62 | "If this is a day of the week, then it is the next occurrence " 63 | "from the start date of the report, including the start date " 64 | "itself." 65 | ), 66 | ) 67 | 68 | parser.add_argument( 69 | "--project", 70 | default=None, 71 | type=str, 72 | help="Show activities only for the specified project.", 73 | ) 74 | 75 | parser.add_argument( 76 | "--per-day", 77 | action="store_true", 78 | default=False, 79 | help="Show total hours per day.", 80 | ) 81 | 82 | parser.add_argument( 83 | "--csv-section", 84 | choices=list(_v1._private.csv_section_name_to_csv_section.keys()), 85 | default=None, 86 | help="Instead of text output, print CSV of desired section", 87 | ) 88 | 89 | parser.add_argument( 90 | "--month", 91 | default=None, 92 | nargs="?", 93 | const="this", 94 | type=str, 95 | help=( 96 | "Specify a month. " 97 | "Allowed formats include, '2019-10', 'Oct', 'this' 'prev'. " 98 | "The report will start on the first day of the month and end " 99 | "on the last. '--from' or '--to' if present will override " 100 | "start and end, respectively. If the month is the current " 101 | "month, 'today' will be the last day of the report." 102 | ), 103 | ) 104 | 105 | parser.add_argument( 106 | "--week", 107 | default=None, 108 | nargs="?", 109 | const="this", 110 | type=str, 111 | help=( 112 | "Specify a week. " 113 | "Allowed formats include, 'this' 'prev', or week number. " 114 | "The report will start on the first day of the week (Monday) " 115 | "and end on the last (Sunday). '--from' or '--to' if present " 116 | "will override start and end, respectively. If the week is " 117 | "the current week, 'today' will be the last day of the report." 118 | ), 119 | ) 120 | 121 | parser.add_argument( 122 | "--details", 123 | action="store_true", 124 | default=False, 125 | help="Show details even for multi-day reports.", 126 | ) 127 | 128 | parser.add_argument( 129 | "--comments", 130 | action="store_true", 131 | default=False, 132 | help="Show comments in details sections.", 133 | ) 134 | 135 | 136 | report_command = _v1.Command("report", "Summarize tasks for given time period", ReportHandler, add_args) 137 | 138 | _v1.register_command(report_command) 139 | -------------------------------------------------------------------------------- /utt/components/report_args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import calendar 3 | import datetime 4 | from enum import Enum, auto 5 | from typing import NamedTuple, Optional 6 | 7 | from .now import Now 8 | 9 | 10 | class CSVSection(Enum): 11 | per_day = auto() 12 | per_task = auto() 13 | 14 | 15 | csv_section_name_to_csv_section = { 16 | "per-day": CSVSection.per_day, 17 | "per_day": CSVSection.per_day, 18 | "per_task": CSVSection.per_task, 19 | "per-task": CSVSection.per_task, 20 | } 21 | 22 | 23 | class DateRange(NamedTuple): 24 | start: datetime.date 25 | end: datetime.date 26 | 27 | 28 | class ReportArgs(NamedTuple): 29 | range: DateRange 30 | current_activity_name: Optional[str] 31 | project_name_filter: Optional[str] 32 | csv_section: Optional[CSVSection] 33 | show_comments: bool 34 | show_details: bool 35 | show_per_day: bool 36 | 37 | 38 | def parse_report_range_arguments( 39 | unparsed_report_date: Optional[str], 40 | unparsed_month: Optional[str], 41 | unparsed_week: Optional[str], 42 | unparsed_from_date: Optional[str], 43 | unparsed_to_date: Optional[str], 44 | today: datetime.date, 45 | ) -> DateRange: 46 | if unparsed_report_date is None: 47 | report_date = today 48 | else: 49 | report_date = parse_date(today, unparsed_report_date, is_past=True) 50 | 51 | if unparsed_month: 52 | report_start_date, report_end_date = parse_month(report_date, unparsed_month) 53 | elif unparsed_week: 54 | report_start_date, report_end_date = parse_week(report_date, unparsed_week) 55 | else: 56 | report_start_date = report_end_date = report_date 57 | 58 | report_start_date = ( 59 | report_start_date if unparsed_from_date is None else parse_date(today, unparsed_from_date, is_past=True) 60 | ) 61 | report_end_date = ( 62 | report_end_date if unparsed_to_date is None else parse_date(report_start_date, unparsed_to_date, is_past=False) 63 | ) 64 | 65 | return DateRange(start=report_start_date, end=report_end_date) 66 | 67 | 68 | def parse_date(today: datetime.date, datestring: str, is_past: bool): 69 | day = parse_relative_day(today, datestring) 70 | if day is not None: 71 | return day 72 | date = parse_relative_date(today, datestring, is_past=is_past) 73 | if date is not None: 74 | return date 75 | return parse_absolute_date(datestring) 76 | 77 | 78 | def parse_absolute_date(datestring): 79 | return datetime.datetime.strptime(datestring, "%Y-%m-%d").date() 80 | 81 | 82 | def parse_relative_day(today, datestring): 83 | """Parses day like 'today' or 'yesterday'. 84 | 85 | Note that 'today' has the same effect as "not supplying a date" but 86 | it's included for completeness. 87 | """ 88 | if "TODAY".startswith(datestring.upper()): 89 | return today 90 | if "YESTERDAY".startswith(datestring.upper()): 91 | return today - datetime.timedelta(days=1) 92 | return None 93 | 94 | 95 | def parse_day(day): 96 | day_upper = day.upper() 97 | if day_upper in DAY_NAMES: 98 | return day_upper 99 | return None 100 | 101 | 102 | def parse_relative_date(today, datestring, is_past): 103 | day = parse_day(datestring) 104 | if day is None: 105 | return None 106 | now_weekday_offset = today.weekday() 107 | report_weekday_offset = DAY_NAMES.index(day) 108 | if is_past: 109 | delta = now_weekday_offset - report_weekday_offset 110 | delta = -(delta % 7) 111 | else: 112 | delta = report_weekday_offset - now_weekday_offset 113 | delta = delta % 7 114 | return today + datetime.timedelta(days=delta) 115 | 116 | 117 | def parse_relative_month(today, monthstring): 118 | month = parse_integer_month(today, monthstring) 119 | if month is not None: 120 | return month 121 | if len(monthstring) < 3: 122 | # ambiguous month 123 | return None 124 | month_upper = monthstring.upper() 125 | for i, monthname in enumerate(MONTH_NAMES): 126 | if monthname.startswith(month_upper): 127 | month = i + 1 128 | break 129 | else: 130 | if "THIS".startswith(month_upper): 131 | month = today.month 132 | elif "PREVIOUS".startswith(month_upper): 133 | month = today.month - 1 134 | if month == 0: 135 | month = 12 136 | else: 137 | return None 138 | 139 | year = today.year if month <= today.month else (today.year - 1) 140 | return datetime.date(year, month, 1) 141 | 142 | 143 | def parse_integer_month(today, monthstring): 144 | """Parse integer month 145 | 146 | This can be a month number (10 -- Oct) 147 | or a negative number (-2 -- 2 months ago). 148 | """ 149 | try: 150 | monthnum = int(monthstring) 151 | except ValueError: 152 | return None 153 | 154 | if monthnum == 0: 155 | return None 156 | elif monthnum < 0: 157 | if monthnum < -11: 158 | return None 159 | monthnum = today.month + monthnum 160 | if monthnum < 1: 161 | monthnum += 12 162 | if monthnum < 1: 163 | return None 164 | year = today.year 165 | if monthnum > today.month: 166 | year -= 1 167 | return datetime.date(year, monthnum, 1) 168 | 169 | 170 | def parse_absolute_month(monthstring): 171 | return datetime.datetime.strptime(monthstring, "%Y-%m").date() 172 | 173 | 174 | def parse_month(today, monthstring): 175 | month = parse_relative_month(today, monthstring) 176 | if month is None: 177 | month = parse_absolute_month(monthstring) 178 | (_month_weekday, month_days) = calendar.monthrange(month.year, month.month) 179 | start = month 180 | end = month.replace(day=month_days) 181 | 182 | return (start, end) 183 | 184 | 185 | def parse_relative_week(today, weekstring): 186 | """Try to parse a week string ('this' or 'previous'). 187 | 188 | Return the first day of the week as a datetime.date 189 | """ 190 | week_upper = weekstring.upper() 191 | if "THIS".startswith(week_upper): 192 | (year, week, _d) = today.isocalendar() 193 | elif "PREVIOUS".startswith(week_upper): 194 | (year, week, _d) = (today - datetime.timedelta(days=7)).isocalendar() 195 | else: 196 | return None 197 | return datetime.date.fromisocalendar(year, week, 1) 198 | 199 | 200 | def parse_week_number(today, weekstring): 201 | try: 202 | weeknum = int(weekstring) 203 | except ValueError: 204 | return None 205 | 206 | if weeknum == 0: 207 | return None 208 | elif weeknum < 0: 209 | one_week = datetime.timedelta(days=7) 210 | # Note: weeknum is negative so this effectively subtracts 211 | (year, week, _d) = (today + weeknum * one_week).isocalendar() 212 | return datetime.date.fromisocalendar(year, week, 1) 213 | else: 214 | (year, week, _d) = today.isocalendar() 215 | if weeknum > week: 216 | year -= 1 217 | return datetime.date.fromisocalendar(year, weeknum, 1) 218 | 219 | 220 | def parse_week(today: datetime.date, weekstring: str): 221 | week = parse_relative_week(today, weekstring) 222 | if week is None: 223 | week = parse_week_number(today, weekstring) 224 | if week is None: 225 | raise ValueError(f"Invalid week string: {weekstring}") 226 | 227 | start = week 228 | end = week + datetime.timedelta(days=6) 229 | 230 | return (start, end) 231 | 232 | 233 | DAY_NAMES = [ 234 | "MONDAY", 235 | "TUESDAY", 236 | "WEDNESDAY", 237 | "THURSDAY", 238 | "FRIDAY", 239 | "SATURDAY", 240 | "SUNDAY", 241 | ] 242 | MONTH_NAMES = [ 243 | "JANUARY", 244 | "FEBRUARY", 245 | "MARCH", 246 | "APRIL", 247 | "MAY", 248 | "JUNE", 249 | "JULY", 250 | "AUGUST", 251 | "SEPTEMBER", 252 | "OCTOBER", 253 | "NOVEMBER", 254 | "DECEMBER", 255 | ] 256 | 257 | 258 | def report_args(args: argparse.Namespace, now: Now) -> ReportArgs: 259 | report_range = parse_report_range_arguments( 260 | unparsed_report_date=args.report_date, 261 | unparsed_month=args.month, 262 | unparsed_week=args.week, 263 | unparsed_from_date=args.from_date, 264 | unparsed_to_date=args.to_date, 265 | today=now.date(), 266 | ) 267 | 268 | current_activity_name = args.current_activity 269 | if args.no_current_activity: 270 | current_activity_name = None 271 | 272 | return ReportArgs( 273 | range=report_range, 274 | current_activity_name=current_activity_name, 275 | project_name_filter=args.project, 276 | csv_section=csv_section_name_to_csv_section.get(args.csv_section), 277 | show_comments=args.comments, 278 | show_details=args.details, 279 | show_per_day=args.per_day, 280 | ) 281 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ultimate Time Tracker 2 | ====================== 3 | 4 | Ultimate Time Tracker (utt) is a simple command-line time tracking 5 | application written in Python. 6 | 7 | **Table of Contents** 8 | 9 | 10 | - [Ultimate Time Tracker](#ultimate-time-tracker) 11 | - [Quick Start](#quick-start) 12 | - [hello](#hello) 13 | - [add](#add) 14 | - [report](#report) 15 | - [edit](#edit) 16 | - [Commands](#commands) 17 | - [`hello`](#hello-1) 18 | - [`add`](#add-1) 19 | - [Activity Type](#activity-type) 20 | - [`edit`](#edit-1) 21 | - [`report`](#report-1) 22 | - [Sections](#sections) 23 | - [Report Date](#report-date) 24 | - [Current Activity](#current-activity) 25 | - [`stretch`](#stretch) 26 | - [Plugins](#plugins) 27 | - [Plugin development](#plugin-development) 28 | - [Bash Completion](#bash-completion) 29 | - [Contributing](#contributing) 30 | - [Contributors](#contributors) 31 | - [License](#license) 32 | - [Website](#website) 33 | 34 | 35 | ## Quick Start 36 | 37 | 38 | ### Prerequisites 39 | 40 | `utt` requires Python version 3.7 or above. 41 | 42 | 43 | ### Installing `utt` 44 | 45 | Install `utt` from PyPI: 46 | 47 | `$ pip install utt` 48 | 49 | 50 | ### hello 51 | 52 | Say hello when you arrive in the morning: 53 | 54 | `$ utt hello` 55 | 56 | 57 | ### add 58 | 59 | Add a task when you have finished working on it: 60 | 61 | `$ utt add "programming"` 62 | 63 | 64 | ### report 65 | 66 | Show report: 67 | 68 | ``` 69 | $ utt report 70 | 71 | ------------------------ Monday, Jul 08, 2013 (week 28) ------------------------ 72 | 73 | Working Time: 0h07 74 | Break Time: 0h00 75 | 76 | ----------------------------------- Projects ----------------------------------- 77 | 78 | (0h07) : programming 79 | 80 | ---------------------------------- Activities ---------------------------------- 81 | 82 | (0h07) : programming 83 | 84 | 85 | ----------------------------------- Details ------------------------------------ 86 | 87 | (0h07) 08:27-08:34 programming 88 | ``` 89 | 90 | 91 | ### edit 92 | 93 | Edit your timesheet: 94 | 95 | `$ utt edit` 96 | 97 | 98 | ## Commands 99 | 100 | ### `hello` 101 | 102 | `$ utt hello` should be the first command you execute when you start 103 | your day. It tells `utt` that you are now tracking your time. 104 | 105 | Example: 106 | 107 | ``` 108 | $ utt hello 109 | ``` 110 | 111 | ### `add` 112 | 113 | When you have completed a task, add it to `utt` with the `add` 114 | command. 115 | 116 | Example: 117 | 118 | `$ utt add programming` 119 | 120 | You add a task when you have completed it, not when you start doing 121 | it. 122 | 123 | #### Activity Type 124 | 125 | There are three types of activities: working, break and 126 | ignored. Working activities contribute to the working time, break 127 | activities to the break time and ignored activities to neither. This 128 | feature is very useful when viewing your timesheet with the `report` 129 | command as it enables `utt` to group your activities by type. 130 | 131 | The activity type is defined by its name. If it ends with `**` it's a 132 | break activity. If it ends with `***` it's an ignored 133 | activity. Otherwise, it's a working activity. 134 | 135 | Examples: 136 | 137 | 138 | - Working activity 139 | 140 | ``` 141 | $ utt add "task #4" 142 | ``` 143 | 144 | - Break activity 145 | 146 | ``` 147 | $ utt add "lunch **" 148 | ``` 149 | 150 | - Ignored activity 151 | 152 | ``` 153 | $ utt add "commuting ***" 154 | ``` 155 | 156 | 157 | ### `edit` 158 | 159 | `edit` opens your timesheet in a text editor so you can edit it. 160 | 161 | Example: 162 | 163 | ``` 164 | $ utt edit 165 | ``` 166 | 167 | `utt` opens the text editor defined by the environment variable 168 | `$VISUAL` and, if not set, by the environment variable `$EDITOR`. If 169 | neither is set, `utt` opens `vi`. 170 | 171 | 172 | ### `report` 173 | 174 | `$ utt report` shows your timesheet. 175 | 176 | Examples: 177 | 178 | - Timesheet for today: `$ utt report` 179 | 180 | - Timesheet for a specific date: `$ utt report 2018-03-25` 181 | 182 | - Timesheet for a period: `$ utt report --from 2018-10-22 --to 2018-10-26` 183 | 184 | 185 | 186 | #### Sections 187 | 188 | There are four sections in a report. As we will see, each one is a 189 | aggregated view of the previous one. 190 | 191 | 1. Summary: shows the report date and the total working and break 192 | time. 193 | 194 | 2. Projects: groups activities by project. This is useful to track the 195 | total time by projects. We will see how to specify the project for an 196 | activity. 197 | 198 | 3. Activities: groups activities by name. This is useful to track the 199 | total time worked on a task when you have worked on it multiple times. 200 | 201 | 4. Details: timeline of your activities. 202 | 203 | 204 | Let's look at an example. Let's say you entered those activities 205 | throughout the day: 206 | 207 | ``` 208 | $ utt hello 209 | $ utt add "project-1: task-3" 210 | $ utt add "project-2: task-2" 211 | $ utt add "project-1: task-1" 212 | $ utt add "lunch **" 213 | $ utt add "project-2: task-2" 214 | $ utt add "project-1: task-2" 215 | ``` 216 | 217 | And then you view your timesheet: 218 | 219 | ``` 220 | $ utt report 221 | 222 | ----------------------- Saturday, Nov 03, 2018 (week 44) ----------------------- 223 | 224 | Working Time: 7h00 225 | Break Time: 1h00 226 | 227 | ----------------------------------- Projects ----------------------------------- 228 | 229 | (5h00) project-1: task-1, task-2, task-3 230 | (2h00) project-2: task-2 231 | 232 | ---------------------------------- Activities ---------------------------------- 233 | 234 | (2h15) project-1: task-1 235 | (2h15) project-1: task-2 236 | (0h30) project-1: task-3 237 | (2h00) project-2: task-2 238 | 239 | (1h00) : lunch ** 240 | 241 | ----------------------------------- Details ------------------------------------ 242 | 243 | (0h30) 09:00-09:30 project-1: task-3 244 | (0h15) 09:30-09:45 project-2: task-2 245 | (2h15) 09:45-12:00 project-1: task-1 246 | (1h00) 12:00-13:00 lunch ** 247 | (1h45) 13:00-14:45 project-2: task-2 248 | (2h15) 14:45-17:00 project-1: task-2 249 | ``` 250 | 251 | The first section, the summary section, shows that you worked 7h and 252 | had a 1-hour break. 253 | 254 | Then, the projects section shows that you worked 5h on project 1 and 255 | 2h on project 2. You can specify the project of an activity by 256 | prefixing it with a non-whitespace string followed by a colon (e.g 257 | `project-1:`, `project2:`). 258 | 259 | The next section, the activities section, shows how long you worked on 260 | each activity. For instance, even though you worked twice on 261 | `project-2: task-2` (0h15 + 1h45), it is shown once in that section. 262 | 263 | Finally, the details section shows a timeline of all your activity. 264 | 265 | 266 | #### Report Date 267 | 268 | You can choose the report date by passing a date to the `report` 269 | command. The date must be either an absolute date formatted as 270 | "%Y-%m-%d" or a day of the week. 271 | 272 | Examples: 273 | 274 | Absolute date: 275 | 276 | ``` 277 | $ utt report 2013-07-01 278 | ``` 279 | 280 | Day of the week: 281 | 282 | ``` 283 | $ utt report monday 284 | ``` 285 | 286 | If today is Wednesday, Feb 18, the report date is Monday, Feb 16. 287 | 288 | You can also specify a date range. All the activities will be aggregated for 289 | the given time period. 290 | 291 | To report activities from 2013-07-01 00:00:00 to 2013-12-31 23:59:59 : 292 | ``` 293 | $ utt report --from 2013-07-01 --to 2013-12-31 294 | ``` 295 | 296 | To report activities since Monday: 297 | ``` 298 | $ utt report --from monday 299 | ``` 300 | 301 | 302 | #### Current Activity 303 | 304 | A `-- Current Activity --` is inserted if the current time is included in the report range. 305 | 306 | The first duration between the parentheses (1h00) represents the 307 | working time without the current activity. The second duration between 308 | the parentheses (0h22) represents the duration of the current 309 | activity. 310 | 311 | Example: 312 | 313 | ``` 314 | $ utt add "#12" 315 | $ utt report 316 | 317 | ------------------------ Monday, Jul 08, 2013 (week 28) ------------------------ 318 | 319 | Working Time: 1h22 (1h00 + 0h22) 320 | Break Time: 0h00 321 | 322 | ----------------------------------- Projects ----------------------------------- 323 | 324 | (1h22) : #12, -- Current Activity -- 325 | 326 | ---------------------------------- Activities ---------------------------------- 327 | 328 | (1h00) : #12 329 | (0h22) : -- Current Activity -- 330 | 331 | ... 332 | ``` 333 | 334 | You can change the current activity name with the `--current-activity` 335 | argument. 336 | 337 | Example: 338 | 339 | ``` 340 | $ utt report --current-activity "#76" 341 | 342 | ------------------------ Monday, Jul 08, 2013 (week 28) ------------------------ 343 | 344 | Working Time: 1h22 (1h00 + 0h22) 345 | Break Time: 0h00 346 | 347 | ----------------------------------- Projects ----------------------------------- 348 | 349 | (1h22) : #12, #76 350 | 351 | ---------------------------------- Activities ---------------------------------- 352 | 353 | (1h00) : #12 354 | (0h22) : #76 355 | 356 | ... 357 | ``` 358 | 359 | Or, you can remove the current activity with the 360 | `--no-current-activity` flag. 361 | 362 | Example: 363 | 364 | ``` 365 | $ utt report --no-current-activity 366 | 367 | ------------------------ Monday, Jul 08, 2013 (week 28) ------------------------ 368 | 369 | Working Time: 1h00 370 | Break Time: 0h00 371 | 372 | ----------------------------------- Projects ----------------------------------- 373 | 374 | (1h00) : #12 375 | 376 | ---------------------------------- Activities ---------------------------------- 377 | 378 | (1h00) : #12 379 | ``` 380 | 381 | ### `stretch` 382 | 383 | Stretch the latest task to the current time: 384 | 385 | Example: 386 | 387 | ``` 388 | $ utt stretch 389 | stretched 2013-07-08 08:34 programming 390 | → 2013-07-08 09:00 programming 391 | ``` 392 | 393 | ## Plugins 394 | 395 | `utt` can be extended by installing plugins. 396 | 397 | Available plugins: 398 | 399 | - [utt-balance](https://github.com/loganthomas/utt-balance) - Adds a balance command that shows worked time against daily and weekly targets 400 | 401 | Open an issue or a pull request to add your plugin here. 402 | 403 | 404 | ### Plugin development 405 | 406 | See 407 | [docs/CONTRIBUTING.md#how-can-i-create-a-plugin](docs/CONTRIBUTING.md#how-can-i-create-a-plugin) 408 | how to create a utt plugin. 409 | 410 | 411 | ## Bash Completion 412 | 413 | `utt` uses [argcomplete](https://github.com/kislyuk/argcomplete) to 414 | provide bash completion. 415 | 416 | First, make sure 417 | [`bash-completion`](https://github.com/scop/bash-completion) is 418 | installed: 419 | 420 | - Fedora: `$ sudo dnf install bash-completion` 421 | - Ubuntu: `$ sudo apt-get install bash-completion` 422 | 423 | 424 | Then execute: 425 | 426 | ``` 427 | $ register-python-argcomplete utt >> ~/.bashrc 428 | ``` 429 | 430 | Finally, start a new shell. 431 | 432 | 433 | ## Contributing 434 | 435 | See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for how to contribute to utt. 436 | 437 | 438 | ## Contributors 439 | 440 | - Mathieu Larose <> 441 | - David Munger <> 442 | - Paul Ivanov <> 443 | - Jason Stewart <> 444 | - Kit Choi <> 445 | - Henrik Holm <> 446 | - Stephan Gross <> 447 | - Kent Martin <> 448 | - fighterpoul <> 449 | 450 | 451 | ## License 452 | 453 | utt is released under the GPLv3. See the LICENSE file for details. 454 | 455 | 456 | ## Website 457 | 458 | http://github.com/larose/utt 459 | -------------------------------------------------------------------------------- /test/integration/Makefile: -------------------------------------------------------------------------------- 1 | UTT_DATA_FILENAME = $(HOME)/.local/share/utt/utt.log 2 | UTT = /usr/local/bin/utt 3 | 4 | .PHONY: all 5 | all: \ 6 | add \ 7 | completion \ 8 | edit \ 9 | example-plugin \ 10 | hello \ 11 | stretch \ 12 | report-1 \ 13 | report-dayname \ 14 | report-no-current-activity \ 15 | report-uppercase \ 16 | report-range report-overnight \ 17 | report-overnight-range \ 18 | report-hello-only-today \ 19 | report-project \ 20 | report-per-day \ 21 | report-project-per-day \ 22 | report-project-per-day-csv \ 23 | report-project-per-day-csv-2 \ 24 | report-per-day-csv \ 25 | report-per-task-csv \ 26 | report-truncate-current-activity \ 27 | report-month \ 28 | report-details \ 29 | report-comments \ 30 | report-week-current \ 31 | version 32 | 33 | $(UTT): 34 | pip install utt-*.whl 35 | 36 | .PHONY: add 37 | add: $(UTT) 38 | @echo 39 | @echo ">> ADD" 40 | 41 | rm -f $(UTT_DATA_FILENAME) 42 | utt --now "2014-01-01 8:00" add " spaces " 43 | utt --now "2014-01-01 9:00" add "utt: programming" 44 | utt --now "2014-01-02 8:00" add "utt: programming" 45 | utt --now "2014-01-02 9:00" add "utt: programming" 46 | 47 | bash -c 'diff $(UTT_DATA_FILENAME) data/add/utt.log' 48 | 49 | @echo "<< ADD" 50 | 51 | .PHONY: completion 52 | completion: $(UTT) 53 | @echo 54 | @echo ">> COMPLETION" 55 | 56 | register-python-argcomplete utt >> ~/.bashrc 57 | bash -i -c 'diff <(COMP_LINE="utt" COMP_POINT=4 _python_argcomplete utt && echo $${COMPREPLY[@]} | tr " " "\n" | sort) <(echo -h --help --data --now --version add config edit hello report stretch | tr " " "\n" | sort)' 58 | 59 | @echo "<< COMPLETION" 60 | 61 | 62 | .PHONY: edit 63 | edit: $(UTT) 64 | @echo 65 | @echo ">> EDIT" 66 | 67 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 68 | cp data/hello/utt.log $(UTT_DATA_FILENAME) 69 | bash -c 'diff <(EDITOR=cat utt edit) data/hello/utt.log' 70 | 71 | @echo "<< EDIT" 72 | 73 | .PHONY: empty-file 74 | empty-file: $(UTT) 75 | @echo 76 | @echo ">> EMPTY FILE" 77 | 78 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 79 | echo -n "" > $(UTT_DATA_FILENAME) 80 | utt hello 81 | 82 | @echo "<< EMPTY FILE" 83 | 84 | .PHONY: example-plugin 85 | example-plugin: $(UTT) 86 | @echo 87 | @echo ">> EXAMPLE PLUGIN" 88 | 89 | cd utt_example_plugin && python3 -m pip install . 90 | bash -c 'diff <(utt --now "2020-01-03 9:00" foo) <(echo Now: 2020-01-03 09:00:00)' 91 | rm -f $(UTT_DATA_FILENAME) 92 | utt --now "2020-01-03 9:00" hello 93 | utt --now "2020-01-03 10:00" add work 94 | bash -c 'diff <(utt --now "2020-01-03 10:30" report) data/utt-example-plugin-report.stdout' 95 | pip uninstall --yes utt-foo 96 | 97 | @echo "<< EXAMPLE PLUGIN" 98 | 99 | .PHONY: hello 100 | hello: $(UTT) 101 | @echo 102 | @echo ">> HELLO" 103 | 104 | rm -f $(UTT_DATA_FILENAME) 105 | utt --now "2014-01-01 8:00" hello 106 | utt --now "2014-01-01 9:00" hello 107 | utt --now "2014-01-02 8:00" hello 108 | utt --now "2014-01-02 9:00" hello 109 | 110 | bash -c 'diff $(UTT_DATA_FILENAME) data/hello/utt.log' 111 | 112 | @echo "<< HELLO" 113 | 114 | .PHONY: stretch 115 | stretch: $(UTT) 116 | @echo 117 | @echo ">> STRETCH" 118 | 119 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 120 | cp data/stretch/before.log $(UTT_DATA_FILENAME) 121 | bash -c 'diff <(PYTHONIOENCODING=utf_8 utt --now "2014-01-01 10:00" stretch) data/stretch/stdout' 122 | bash -c 'diff $(UTT_DATA_FILENAME) data/stretch/after.log' 123 | 124 | @echo "<< STRETCH" 125 | 126 | .PHONY: report-1 127 | report-1: $(UTT) 128 | @echo 129 | @echo ">> REPORT-1" 130 | 131 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 132 | cp data/utt-1.log $(UTT_DATA_FILENAME) 133 | bash -c 'diff <(utt --now "2014-3-19 18:30" report 2014-3-19) data/utt-1.stdout' 134 | 135 | @echo "<< REPORT-1" 136 | 137 | .PHONY: report-dayname 138 | report-dayname: $(UTT) 139 | @echo 140 | @echo ">> REPORT-DAYNAME" 141 | 142 | utt --now "2015-2-16 12:00" report thursday | grep ^- | head -n 1 | diff data/report/dayname - 143 | 144 | @echo "<< REPORT-DAYNAME" 145 | 146 | .PHONY: report-no-current-activity 147 | report-no-current-activity: $(UTT) 148 | @echo 149 | @echo ">> REPORT-NO-CURRENT-ACTIVITY" 150 | 151 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 152 | cp data/utt-no-current-activity.log $(UTT_DATA_FILENAME) 153 | bash -c 'diff <(utt --now "2018-08-21 20:00" report 2018-08-20) data/utt-no-current-activity.stdout' 154 | 155 | @echo "<< REPORT-NO-CURRENT-ACTIVITY" 156 | 157 | .PHONY: report-uppercase 158 | report-uppercase: $(UTT) 159 | @echo 160 | @echo ">> REPORT-UPPERCASE" 161 | 162 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 163 | cp data/utt-upper-case.log $(UTT_DATA_FILENAME) 164 | bash -c 'diff <(utt --now "2014-3-14 12:00" report 2014-3-14 --no-current-activity) data/utt-upper-case.stdout' 165 | 166 | @echo "<< REPORT-UPPERCASE" 167 | 168 | report-range: $(UTT) 169 | @echo 170 | @echo ">> REPORT-RANGE" 171 | 172 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 173 | cp data/utt-1.log $(UTT_DATA_FILENAME) 174 | bash -c 'diff <(utt --now "2014-3-19 18:30" report --from 2014-3-15 --to 2014-03-19 --no-current-activity) data/utt-range.stdout' 175 | 176 | @echo "<< REPORT-UPPERCASE" 177 | 178 | report-overnight: $(UTT) 179 | @echo 180 | @echo ">> REPORT-OVERNIGHT" 181 | 182 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 183 | cp data/utt-overnight.log $(UTT_DATA_FILENAME) 184 | bash -c 'diff <(utt --now "2014-3-19 18:30" report "2014-03-14") data/utt-overnight.stdout' 185 | 186 | @echo "<< REPORT-OVERNIGHT" 187 | 188 | 189 | report-overnight-range: $(UTT) 190 | @echo 191 | @echo ">> REPORT-OVERNIGHT-RANGE" 192 | 193 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 194 | cp data/utt-overnight.log $(UTT_DATA_FILENAME) 195 | bash -c 'diff <(utt --now "2014-3-19 18:30" report --from "2014-03-14" --to "2014-03-18") data/utt-overnight-range.stdout' 196 | 197 | @echo "<< REPORT-OVERNIGHT-RANGE" 198 | 199 | report-hello-only-today: $(UTT) 200 | @echo 201 | @echo ">> REPORT-HELLO-ONLY-TODAY" 202 | 203 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 204 | cp data/utt-no-current-activity.log $(UTT_DATA_FILENAME) 205 | bash -c 'diff <(utt --now "2018-08-20 20:00" report 2018-08-20) data/utt-hello-only-today.stdout' 206 | 207 | @echo "<< REPORT-HELLO-ONLY-TODAY" 208 | 209 | .PHONY: version 210 | version: $(UTT) 211 | @echo 212 | @echo ">> VERSION" 213 | 214 | utt --version 215 | 216 | @echo "<< VERSION" 217 | 218 | 219 | .PHONY: report-project 220 | report-project: $(UTT) 221 | @echo 222 | @echo ">> REPORT-PROJECT" 223 | 224 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 225 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 226 | bash -c 'diff -u <(utt --now "2018-08-20 20:00" report --project project_1) data/utt-report-project.stdout' 227 | 228 | @echo "<< REPORT-PROJECT" 229 | 230 | 231 | .PHONY: report-per-day 232 | report-per-day: $(UTT) 233 | # NOTE: this is not an intended use of the `--per-day` switch. 234 | # It only really works well in conjunction with a 235 | # `--project ` switch. But it still works. 236 | @echo 237 | @echo ">> REPORT-PER-DAY" 238 | 239 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 240 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 241 | bash -c 'diff -u <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --per-day --no-current-activity) data/utt-report-per-day.stdout' 242 | 243 | @echo "<< REPORT-PER-DAY" 244 | 245 | 246 | .PHONY: report-project-per-day 247 | report-project-per-day: $(UTT) 248 | @echo 249 | @echo ">> REPORT-PROJECT-PER-DAY" 250 | 251 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 252 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 253 | bash -c 'diff -u <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --project project_1 --per-day --no-current-activity) data/utt-report-project-per-day.stdout' 254 | 255 | @echo "<< REPORT-PROJECT-PER-DAY" 256 | 257 | 258 | .PHONY: report-project-per-day-csv 259 | report-project-per-day-csv: $(UTT) 260 | @echo 261 | @echo ">> REPORT-PROJECT-PER-DAY-CSV" 262 | 263 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 264 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 265 | bash -c 'diff -u --strip-trailing-cr <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --project project_1 --csv-section per_day --no-current-activity) data/utt-report-project-per-day-csv.csv' 266 | 267 | @echo "<< REPORT-PROJECT-PER-DAY-CSV" 268 | 269 | 270 | # Like test above, but allow `--csv-section per-day` (not only `per_day`) 271 | .PHONY: report-project-per-day-csv-2 272 | report-project-per-day-csv-2: $(UTT) 273 | @echo 274 | @echo ">> REPORT-PROJECT-PER-DAY-CSV-2" 275 | 276 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 277 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 278 | bash -c 'diff -u --strip-trailing-cr <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --project project_1 --csv-section per-day) data/utt-report-project-per-day-csv.csv' 279 | 280 | @echo "<< REPORT-PROJECT-PER-DAY-CSV-2" 281 | 282 | 283 | .PHONY: report-per-day-csv 284 | report-per-day-csv: $(UTT) 285 | # NOTE: this is not an intended use of the `--csv-section` switch. 286 | # It only really works well in conjunction with a 287 | # `--project ` switch. But it still works. 288 | @echo 289 | @echo ">> REPORT-PER-DAY-CSV" 290 | 291 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 292 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 293 | bash -c 'diff -u --strip-trailing-cr <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --csv-section per_day --no-current-activity) data/utt-report-per-day-csv.csv' 294 | 295 | @echo "<< REPORT-PER-DAY-CSV" 296 | 297 | 298 | .PHONY: report-per-task-csv 299 | report-per-task-csv: $(UTT) 300 | @echo 301 | @echo ">> REPORT-PER-TASK-CSV" 302 | 303 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 304 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 305 | bash -c 'diff -u --strip-trailing-cr <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --csv-section per_task --no-current-activity) data/utt-report-per-task-csv.csv' 306 | 307 | @echo "<< REPORT-PER-TASK-CSV" 308 | 309 | 310 | .PHONY: report-month 311 | report-month: $(UTT) 312 | @echo 313 | @echo ">> REPORT-MONTH" 314 | 315 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 316 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 317 | bash -c 'diff -u <(utt --now "2018-09-21 20:00" report --month prev) data/utt-report-month.stdout' 318 | 319 | @echo "<< REPORT-MONTH" 320 | 321 | 322 | .PHONY: report-details 323 | report-details: $(UTT) 324 | @echo 325 | @echo ">> REPORT-DETAILS" 326 | 327 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 328 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 329 | bash -c 'diff -u <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --details --no-current-activity) data/utt-report-details.stdout' 330 | 331 | @echo "<< REPORT-DETAILS" 332 | 333 | 334 | .PHONY: report-comments 335 | report-comments: $(UTT) 336 | @echo 337 | @echo ">> REPORT-COMMENTS" 338 | 339 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 340 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 341 | bash -c 'diff -u <(utt --now "2018-08-21 20:00" report --from 2018-08-20 --to 2018-08-21 --details --comments --no-current-activity) data/utt-report-comments.stdout' 342 | 343 | @echo "<< REPORT-COMMENTS" 344 | 345 | 346 | .PHONY: report-week-current 347 | report-week-current: $(UTT) 348 | @echo 349 | @echo ">> REPORT-WEEK-CURRENT" 350 | 351 | mkdir -p `dirname $(UTT_DATA_FILENAME)` 352 | cp data/utt-report-project.log $(UTT_DATA_FILENAME) 353 | bash -c 'diff -u <(utt --now "2018-08-21 20:00" report --week --no-current-activity) data/utt-report-week-current.stdout' 354 | 355 | @echo "<< REPORT-WEEK-CURRENT" 356 | 357 | .PHONY: report-truncate-current-activity 358 | report-truncate-current-activity: $(UTT) 359 | @echo 360 | @echo ">> REPORT-TRUNCATE-CURRENT-ACTIVITY" 361 | 362 | rm -f $(UTT_DATA_FILENAME) 363 | utt --now "2020-12-29 23:00" hello 364 | utt --now "2020-12-29 23:00" add a 365 | 366 | bash -c 'diff -u <(utt --now "2020-12-30 01:00" report) data/utt-report-truncate-current-activity.stdout' 367 | 368 | @echo "<< REPORT-TRUNCATE-CURRENT-ACTIVITY" 369 | 370 | 371 | .PHONY: shell 372 | shell: 373 | bash 374 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "argcomplete" 5 | version = "3.2.1" 6 | description = "Bash tab completion for argparse" 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["main"] 10 | files = [ 11 | {file = "argcomplete-3.2.1-py3-none-any.whl", hash = "sha256:30891d87f3c1abe091f2142613c9d33cac84a5e15404489f033b20399b691fec"}, 12 | {file = "argcomplete-3.2.1.tar.gz", hash = "sha256:437f67fb9b058da5a090df505ef9be0297c4883993f3f56cb186ff087778cfb4"}, 13 | ] 14 | 15 | [package.extras] 16 | test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] 17 | 18 | [[package]] 19 | name = "black" 20 | version = "25.9.0" 21 | description = "The uncompromising code formatter." 22 | optional = false 23 | python-versions = ">=3.9" 24 | groups = ["dev"] 25 | files = [ 26 | {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, 27 | {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, 28 | {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, 29 | {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, 30 | {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, 31 | {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, 32 | {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, 33 | {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, 34 | {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, 35 | {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, 36 | {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, 37 | {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, 38 | {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, 39 | {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, 40 | {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, 41 | {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, 42 | {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, 43 | {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, 44 | {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, 45 | {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, 46 | {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, 47 | {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, 48 | ] 49 | 50 | [package.dependencies] 51 | click = ">=8.0.0" 52 | mypy-extensions = ">=0.4.3" 53 | packaging = ">=22.0" 54 | pathspec = ">=0.9.0" 55 | platformdirs = ">=2" 56 | pytokens = ">=0.1.10" 57 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 58 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 59 | 60 | [package.extras] 61 | colorama = ["colorama (>=0.4.3)"] 62 | d = ["aiohttp (>=3.10)"] 63 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 64 | uvloop = ["uvloop (>=0.15.2)"] 65 | 66 | [[package]] 67 | name = "cargo" 68 | version = "0.3" 69 | description = "Dependency injection library for Python." 70 | optional = false 71 | python-versions = ">=3.7,<4.0" 72 | groups = ["main"] 73 | files = [ 74 | {file = "cargo-0.3-py3-none-any.whl", hash = "sha256:eb41caddbdfab94d5ce72c363fb1109ff74e55d3381ba545a62ca25763cd8adb"}, 75 | {file = "cargo-0.3.tar.gz", hash = "sha256:38534a877b2df38f3ab6c2767e673351888dfeb062ce6f2a715b3dce6ca1f8fe"}, 76 | ] 77 | 78 | [[package]] 79 | name = "certifi" 80 | version = "2025.10.5" 81 | description = "Python package for providing Mozilla's CA Bundle." 82 | optional = false 83 | python-versions = ">=3.7" 84 | groups = ["dev"] 85 | files = [ 86 | {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, 87 | {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, 88 | ] 89 | 90 | [[package]] 91 | name = "charset-normalizer" 92 | version = "3.4.4" 93 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 94 | optional = false 95 | python-versions = ">=3.7" 96 | groups = ["dev"] 97 | files = [ 98 | {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, 99 | {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, 100 | {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, 101 | {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, 102 | {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, 103 | {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, 104 | {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, 105 | {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, 106 | {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, 107 | {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, 108 | {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, 109 | {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, 110 | {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, 111 | {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, 112 | {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, 113 | {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, 114 | {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, 115 | {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, 116 | {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, 117 | {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, 118 | {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, 119 | {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, 120 | {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, 121 | {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, 122 | {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, 123 | {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, 124 | {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, 125 | {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, 126 | {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, 127 | {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, 128 | {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, 129 | {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, 130 | {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, 131 | {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, 132 | {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, 133 | {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, 134 | {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, 135 | {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, 136 | {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, 137 | {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, 138 | {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, 139 | {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, 140 | {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, 141 | {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, 142 | {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, 143 | {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, 144 | {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, 145 | {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, 146 | {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, 147 | {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, 148 | {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, 149 | {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, 150 | {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, 151 | {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, 152 | {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, 153 | {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, 154 | {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, 155 | {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, 156 | {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, 157 | {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, 158 | {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, 159 | {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, 160 | {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, 161 | {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, 162 | {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, 163 | {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, 164 | {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, 165 | {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, 166 | {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, 167 | {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, 168 | {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, 169 | {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, 170 | {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, 171 | {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, 172 | {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, 173 | {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, 174 | {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, 175 | {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, 176 | {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, 177 | {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, 178 | {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, 179 | {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, 180 | {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, 181 | {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, 182 | {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, 183 | {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, 184 | {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, 185 | {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, 186 | {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, 187 | {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, 188 | {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, 189 | {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, 190 | {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, 191 | {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, 192 | {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, 193 | {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, 194 | {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, 195 | {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, 196 | {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, 197 | {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, 198 | {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, 199 | {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, 200 | {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, 201 | {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, 202 | {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, 203 | {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, 204 | {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, 205 | {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, 206 | {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, 207 | {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, 208 | {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, 209 | {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, 210 | {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, 211 | ] 212 | 213 | [[package]] 214 | name = "click" 215 | version = "8.3.0" 216 | description = "Composable command line interface toolkit" 217 | optional = false 218 | python-versions = ">=3.10" 219 | groups = ["dev"] 220 | files = [ 221 | {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, 222 | {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, 223 | ] 224 | 225 | [package.dependencies] 226 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 227 | 228 | [[package]] 229 | name = "colorama" 230 | version = "0.4.6" 231 | description = "Cross-platform colored terminal text." 232 | optional = false 233 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 234 | groups = ["dev"] 235 | markers = "platform_system == \"Windows\"" 236 | files = [ 237 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 238 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 239 | ] 240 | 241 | [[package]] 242 | name = "flake8" 243 | version = "7.0.0" 244 | description = "the modular source code checker: pep8 pyflakes and co" 245 | optional = false 246 | python-versions = ">=3.8.1" 247 | groups = ["dev"] 248 | files = [ 249 | {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, 250 | {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, 251 | ] 252 | 253 | [package.dependencies] 254 | mccabe = ">=0.7.0,<0.8.0" 255 | pycodestyle = ">=2.11.0,<2.12.0" 256 | pyflakes = ">=3.2.0,<3.3.0" 257 | 258 | [[package]] 259 | name = "idna" 260 | version = "3.11" 261 | description = "Internationalized Domain Names in Applications (IDNA)" 262 | optional = false 263 | python-versions = ">=3.8" 264 | groups = ["dev"] 265 | files = [ 266 | {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, 267 | {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, 268 | ] 269 | 270 | [package.extras] 271 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 272 | 273 | [[package]] 274 | name = "isort" 275 | version = "5.13.2" 276 | description = "A Python utility / library to sort Python imports." 277 | optional = false 278 | python-versions = ">=3.8.0" 279 | groups = ["dev"] 280 | files = [ 281 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 282 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 283 | ] 284 | 285 | [package.extras] 286 | colors = ["colorama (>=0.4.6)"] 287 | 288 | [[package]] 289 | name = "mccabe" 290 | version = "0.7.0" 291 | description = "McCabe checker, plugin for flake8" 292 | optional = false 293 | python-versions = ">=3.6" 294 | groups = ["dev"] 295 | files = [ 296 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 297 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 298 | ] 299 | 300 | [[package]] 301 | name = "mypy-extensions" 302 | version = "1.1.0" 303 | description = "Type system extensions for programs checked with the mypy type checker." 304 | optional = false 305 | python-versions = ">=3.8" 306 | groups = ["dev"] 307 | files = [ 308 | {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, 309 | {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, 310 | ] 311 | 312 | [[package]] 313 | name = "nodeenv" 314 | version = "1.9.1" 315 | description = "Node.js virtual environment builder" 316 | optional = false 317 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 318 | groups = ["dev"] 319 | files = [ 320 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 321 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 322 | ] 323 | 324 | [[package]] 325 | name = "packaging" 326 | version = "25.0" 327 | description = "Core utilities for Python packages" 328 | optional = false 329 | python-versions = ">=3.8" 330 | groups = ["dev"] 331 | files = [ 332 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 333 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 334 | ] 335 | 336 | [[package]] 337 | name = "pathspec" 338 | version = "0.12.1" 339 | description = "Utility library for gitignore style pattern matching of file paths." 340 | optional = false 341 | python-versions = ">=3.8" 342 | groups = ["dev"] 343 | files = [ 344 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 345 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 346 | ] 347 | 348 | [[package]] 349 | name = "platformdirs" 350 | version = "4.5.0" 351 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 352 | optional = false 353 | python-versions = ">=3.10" 354 | groups = ["dev"] 355 | files = [ 356 | {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, 357 | {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, 358 | ] 359 | 360 | [package.extras] 361 | docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] 362 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] 363 | type = ["mypy (>=1.18.2)"] 364 | 365 | [[package]] 366 | name = "pycodestyle" 367 | version = "2.11.1" 368 | description = "Python style guide checker" 369 | optional = false 370 | python-versions = ">=3.8" 371 | groups = ["dev"] 372 | files = [ 373 | {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, 374 | {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, 375 | ] 376 | 377 | [[package]] 378 | name = "pyflakes" 379 | version = "3.2.0" 380 | description = "passive checker of Python programs" 381 | optional = false 382 | python-versions = ">=3.8" 383 | groups = ["dev"] 384 | files = [ 385 | {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, 386 | {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, 387 | ] 388 | 389 | [[package]] 390 | name = "pyright" 391 | version = "1.1.407" 392 | description = "Command line wrapper for pyright" 393 | optional = false 394 | python-versions = ">=3.7" 395 | groups = ["dev"] 396 | files = [ 397 | {file = "pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21"}, 398 | {file = "pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262"}, 399 | ] 400 | 401 | [package.dependencies] 402 | nodeenv = ">=1.6.0" 403 | typing-extensions = ">=4.1" 404 | 405 | [package.extras] 406 | all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] 407 | dev = ["twine (>=3.4.1)"] 408 | nodejs = ["nodejs-wheel-binaries"] 409 | 410 | [[package]] 411 | name = "pytokens" 412 | version = "0.2.0" 413 | description = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons." 414 | optional = false 415 | python-versions = ">=3.8" 416 | groups = ["dev"] 417 | files = [ 418 | {file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"}, 419 | {file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"}, 420 | ] 421 | 422 | [package.extras] 423 | dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] 424 | 425 | [[package]] 426 | name = "requests" 427 | version = "2.32.5" 428 | description = "Python HTTP for Humans." 429 | optional = false 430 | python-versions = ">=3.9" 431 | groups = ["dev"] 432 | files = [ 433 | {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, 434 | {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, 435 | ] 436 | 437 | [package.dependencies] 438 | certifi = ">=2017.4.17" 439 | charset_normalizer = ">=2,<4" 440 | idna = ">=2.5,<4" 441 | urllib3 = ">=1.21.1,<3" 442 | 443 | [package.extras] 444 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 445 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 446 | 447 | [[package]] 448 | name = "setuptools" 449 | version = "80.9.0" 450 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 451 | optional = false 452 | python-versions = ">=3.9" 453 | groups = ["dev"] 454 | files = [ 455 | {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, 456 | {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, 457 | ] 458 | 459 | [package.extras] 460 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] 461 | core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 462 | cover = ["pytest-cov"] 463 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 464 | enabler = ["pytest-enabler (>=2.2)"] 465 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 466 | type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] 467 | 468 | [[package]] 469 | name = "tomli" 470 | version = "2.3.0" 471 | description = "A lil' TOML parser" 472 | optional = false 473 | python-versions = ">=3.8" 474 | groups = ["dev"] 475 | markers = "python_version == \"3.10\"" 476 | files = [ 477 | {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, 478 | {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, 479 | {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, 480 | {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, 481 | {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, 482 | {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, 483 | {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, 484 | {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, 485 | {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, 486 | {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, 487 | {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, 488 | {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, 489 | {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, 490 | {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, 491 | {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, 492 | {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, 493 | {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, 494 | {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, 495 | {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, 496 | {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, 497 | {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, 498 | {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, 499 | {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, 500 | {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, 501 | {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, 502 | {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, 503 | {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, 504 | {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, 505 | {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, 506 | {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, 507 | {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, 508 | {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, 509 | {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, 510 | {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, 511 | {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, 512 | {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, 513 | {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, 514 | {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, 515 | {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, 516 | {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, 517 | {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, 518 | {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, 519 | ] 520 | 521 | [[package]] 522 | name = "typing-extensions" 523 | version = "4.9.0" 524 | description = "Backported and Experimental Type Hints for Python 3.8+" 525 | optional = false 526 | python-versions = ">=3.8" 527 | groups = ["dev"] 528 | files = [ 529 | {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, 530 | {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, 531 | ] 532 | 533 | [[package]] 534 | name = "urllib3" 535 | version = "2.6.2" 536 | description = "HTTP library with thread-safe connection pooling, file post, and more." 537 | optional = false 538 | python-versions = ">=3.9" 539 | groups = ["dev"] 540 | files = [ 541 | {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, 542 | {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, 543 | ] 544 | 545 | [package.extras] 546 | brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] 547 | h2 = ["h2 (>=4,<5)"] 548 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 549 | zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] 550 | 551 | [metadata] 552 | lock-version = "2.1" 553 | python-versions = "^3.10" 554 | content-hash = "51b410ec0f95923043cadbc2ac300ead64f2f53aeb80fb3cf7c5190c07d13fd8" 555 | --------------------------------------------------------------------------------