From ecdd28e8a325d208a9350cb4f1a1cd112c9ee714 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Fri, 22 May 2026 10:33:00 +0200 Subject: [PATCH] feat(joplin): add --joplin flag to fetch weekly timesheet note from Joplin - Add joplin.py with fetch_week_note() that walks Work > Timesheets > YYYY and returns the body of the matching YYYY-WNN note via joppy ClientApi - Add filter_rows_by_date() to parser.py to extract only rows belonging to a specific day based on '# ... YYYY-MM-DD' headings in the document - Update cli.py: input and --joplin are now a mutually exclusive required group; add --token flag with JOPLIN_TOKEN env var fallback; --date is parsed into a real date object used for both output and day filtering - Add joppy as a runtime dependency (lazy-imported in cli.py) - Add tests for filter_rows_by_date and full mocked coverage of joplin.py - Update AGENTS.md with Joplin usage, notebook structure, and test rules The actual Joplin structure has notes directly inside the year notebook (Work > Timesheets > YYYY), not in per-week sub-notebooks as initially assumed. fetch_week_note() reflects this flat structure. --- AGENTS.md | 44 +++++++++++++-- pyproject.toml | 4 +- src/timesheets/cli.py | 86 +++++++++++++++++++++++++---- src/timesheets/joplin.py | 68 +++++++++++++++++++++++ src/timesheets/parser.py | 35 ++++++++++++ tests/test_joplin.py | 116 +++++++++++++++++++++++++++++++++++++++ tests/test_parser.py | 62 +++++++++++++++++++++ uv.lock | 115 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 src/timesheets/joplin.py create mode 100644 tests/test_joplin.py diff --git a/AGENTS.md b/AGENTS.md index eb8b696..03e77ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A Python CLI tool that parses markdown pipe-delimited timesheet tables and exports them to CSV for import into Odoo (or similar tools). It also supports -a human-readable summary view. +a human-readable summary view and can fetch notes directly from Joplin. ### Package layout @@ -14,9 +14,10 @@ timesheets/ ├── AGENTS.md └── src/timesheets/ ├── cli.py # argument parsing, main() entry point - ├── parser.py # markdown table parsing and row aggregation + ├── parser.py # markdown table parsing, aggregation, date filtering ├── projects.py # project_map.json loading and key resolution ├── output.py # CSV writing and summary printing + ├── joplin.py # Joplin API integration (notebook traversal, note fetching) └── utils.py # shared low-level helpers (duration parsing, formatting, etc.) ``` @@ -27,7 +28,8 @@ tests/ ├── test_utils.py ├── test_parser.py ├── test_projects.py -└── test_output.py +├── test_output.py +└── test_joplin.py ``` --- @@ -57,7 +59,7 @@ uv run timesheets input.md uv run timesheets input.md -o output.csv # Override the date (DD/MM/YY) -uv run timesheets input.md --date 22/03/26 +uv run timesheets input.md --date 22/05/26 # Use a specific project map file uv run timesheets input.md --map /path/to/project_map.json @@ -67,13 +69,44 @@ uv run timesheets input.md --summary # Read from stdin cat input.md | uv run timesheets - + +# Fetch today's entries from Joplin (token via env var) +JOPLIN_TOKEN=your_token uv run timesheets --joplin + +# Fetch entries for a specific date from Joplin +uv run timesheets --joplin --date 22/05/26 --token your_token ``` +The `--joplin` flag and the file `input` argument are mutually exclusive. +When `--joplin` is used, only entries matching the target date (from `--date`, +or today) are returned, filtered by the `# ... YYYY-MM-DD` day heading in the note. + +The API token can be provided via: +- `--token ` CLI flag +- `JOPLIN_TOKEN` environment variable + `project_map.json` is auto-discovered in the current working directory if `--map` is not provided. --- +## Joplin notebook structure + +The `--joplin` flag expects the following notebook hierarchy in Joplin: + +``` +Work/ +└── Timesheets/ + └── YYYY/ + └── YYYY - WNN/ ← notebook per week + └── YYYY - WNN ← note with the same title as the notebook +``` + +The note body contains one markdown table per day, each preceded by a heading +of the form `# - YYYY-MM-DD`. + +--- + ## Testing The test suite uses **pytest** with **pytest-cov** for coverage reporting. @@ -110,3 +143,6 @@ uv run pytest tests/test_parser.py::TestParseTable::test_empty_input 4. `cli.py` is intentionally excluded from unit tests — it is thin glue code. All logic worth testing belongs in the other modules. + +5. Joplin integration tests in `test_joplin.py` must mock `ClientApi` — do not + require a live Joplin instance. diff --git a/pyproject.toml b/pyproject.toml index d342ad5..a303245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ authors = [ { name = "Jef Roosens", email = "roosenj@factry.io" } ] requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "joppy>=1.0.2", +] [project.scripts] timesheets = "timesheets.cli:main" diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index dc51a33..e48103b 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -4,7 +4,7 @@ import sys from datetime import date from .output import print_summary, write_csv -from .parser import aggregate_rows, parse_document +from .parser import aggregate_rows, filter_rows_by_date, parse_document from .projects import load_project_map from .utils import format_date @@ -13,10 +13,29 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Parse a markdown timesheet table and output a CSV file." ) - parser.add_argument( + + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( "input", + nargs="?", help="Path to the markdown file containing the timesheet table, or '-' to read from stdin.", ) + source.add_argument( + "--joplin", + action="store_true", + default=False, + help=( + "Fetch the weekly timesheet note from Joplin instead of reading a file. " + "Only entries for today (or --date) are included. " + "Requires a Joplin API token via --token or the JOPLIN_TOKEN environment variable." + ), + ) + + parser.add_argument( + "--token", + help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.", + default=None, + ) parser.add_argument( "-o", "--output", @@ -44,23 +63,66 @@ def build_parser() -> argparse.ArgumentParser: return parser +def _resolve_token(args: argparse.Namespace) -> str: + token = args.token or os.environ.get("JOPLIN_TOKEN") + if not token: + print( + "Error: Joplin API token required. " + "Provide --token or set the JOPLIN_TOKEN environment variable.", + file=sys.stderr, + ) + sys.exit(1) + return token + + +def _parse_date(date_str: str | None) -> date: + """Parse DD/MM/YY date string, or return today.""" + if date_str is None: + return date.today() + try: + from datetime import datetime + + return datetime.strptime(date_str, "%d/%m/%y").date() + except ValueError: + print( + f"Error: invalid date format {date_str!r}, expected DD/MM/YY.", + file=sys.stderr, + ) + sys.exit(1) + + def main() -> None: args = build_parser().parse_args() - date_str = args.date or format_date(date.today()) + target_date = _parse_date(args.date) + date_str = format_date(target_date) - if args.input == "-": - content = sys.stdin.read() - else: + if args.joplin: + # Late import so joppy is only required when --joplin is used + from .joplin import fetch_week_note + + token = _resolve_token(args) try: - with open(args.input, "r", encoding="utf-8") as f: - content = f.read() - except FileNotFoundError: - print(f"Error: file not found: {args.input}", file=sys.stderr) + content = fetch_week_note(token, target_date) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) sys.exit(1) - lines = content.splitlines() - rows = parse_document(lines) + lines = content.splitlines() + rows = filter_rows_by_date(lines, target_date) + else: + if args.input == "-": + content = sys.stdin.read() + else: + try: + with open(args.input, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + print(f"Error: file not found: {args.input}", file=sys.stderr) + sys.exit(1) + + lines = content.splitlines() + rows = parse_document(lines) if not rows: print("Warning: no timesheet rows found in input.", file=sys.stderr) diff --git a/src/timesheets/joplin.py b/src/timesheets/joplin.py new file mode 100644 index 0000000..b9ec9f9 --- /dev/null +++ b/src/timesheets/joplin.py @@ -0,0 +1,68 @@ +""" +Joplin integration via the joppy ClientApi. + +Actual notebook structure: + Work > Timesheets > YYYY + +Notes live directly inside the year notebook and are titled 'YYYY - WNN'. +""" + +from datetime import date +from typing import Optional + +from joppy.client_api import ClientApi + + +def _iso_week_label(d: date) -> str: + """Return the note title for the week containing the given date, e.g. '2026 - W21'.""" + year, week, _ = d.isocalendar() + return f"{year} - W{week:02d}" + + +def _find_notebook( + api: ClientApi, title: str, parent_id: Optional[str] = None +) -> Optional[str]: + """Return the ID of a notebook matching title (and optionally parent_id), or None.""" + for nb in api.get_all_notebooks(): + if nb.title == title: + if parent_id is None or nb.parent_id == parent_id: + return nb.id + return None + + +def fetch_week_note(token: str, target_date: date) -> str: + """ + Fetch the body of the weekly timesheet note from Joplin for the week + containing target_date. + + Notebook path: Work > Timesheets > YYYY + Note title: YYYY - WNN + + Raises RuntimeError if any notebook or the note cannot be found. + """ + api = ClientApi(token=token) + week_label = _iso_week_label(target_date) + year_str = str(target_date.year) + + work_id = _find_notebook(api, "Work") + if work_id is None: + raise RuntimeError("Joplin notebook 'Work' not found") + + timesheets_id = _find_notebook(api, "Timesheets", parent_id=work_id) + if timesheets_id is None: + raise RuntimeError("Joplin notebook 'Work > Timesheets' not found") + + year_id = _find_notebook(api, year_str, parent_id=timesheets_id) + if year_id is None: + raise RuntimeError( + f"Joplin notebook 'Work > Timesheets > {year_str}' not found" + ) + + notes = api.get_all_notes(notebook_id=year_id, fields="id,title,body") + for note in notes: + if note.title == week_label: + return note.body + + raise RuntimeError( + f"Joplin note '{week_label}' not found in 'Work > Timesheets > {year_str}'" + ) diff --git a/src/timesheets/parser.py b/src/timesheets/parser.py index 6ac3ae5..eb2cd71 100644 --- a/src/timesheets/parser.py +++ b/src/timesheets/parser.py @@ -1,8 +1,12 @@ import re from collections import defaultdict +from datetime import date from .utils import duration_from_start_end, parse_duration, strip_markdown_link +# Matches a date in YYYY-MM-DD format anywhere in a heading line +_DATE_HEADING_RE = re.compile(r"^#+.*?(\d{4}-\d{2}-\d{2})") + def _is_table_line(line: str) -> bool: """Return True if the line looks like part of a markdown table.""" @@ -141,6 +145,37 @@ def parse_document(lines: list[str]) -> list[dict]: return rows +def filter_rows_by_date(lines: list[str], target: date) -> list[dict]: + """ + Parse a document and return only rows that fall under the heading for + target date. Headings are detected by the pattern '# ... YYYY-MM-DD'. + Rows before the first dated heading are discarded. + """ + target_str = target.strftime("%Y-%m-%d") + sections: list[tuple[str | None, list[str]]] = [] + current_date: str | None = None + current_lines: list[str] = [] + + for line in lines: + m = _DATE_HEADING_RE.match(line) + if m: + if current_lines: + sections.append((current_date, current_lines)) + current_date = m.group(1) + current_lines = [] + else: + current_lines.append(line) + + if current_lines: + sections.append((current_date, current_lines)) + + rows: list[dict] = [] + for section_date, section_lines in sections: + if section_date == target_str: + rows.extend(parse_document(section_lines)) + return rows + + def build_description(story: str, note: str) -> str: """Combine story and note into a single description string.""" parts = [p.strip() for p in [story, note] if p.strip()] diff --git a/tests/test_joplin.py b/tests/test_joplin.py new file mode 100644 index 0000000..8fd922c --- /dev/null +++ b/tests/test_joplin.py @@ -0,0 +1,116 @@ +""" +Tests for the Joplin integration module. + +All tests mock the joppy ClientApi so no live Joplin instance is required. +""" + +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest + +from timesheets.joplin import _iso_week_label, fetch_week_note + +# --------------------------------------------------------------------------- +# _iso_week_label +# --------------------------------------------------------------------------- + + +class TestIsoWeekLabel: + @pytest.mark.parametrize( + "d, expected", + [ + (date(2026, 5, 22), "2026 - W21"), + (date(2026, 5, 18), "2026 - W21"), # Monday of same week + (date(2026, 1, 1), "2026 - W01"), + (date(2026, 12, 28), "2026 - W53"), + ], + ) + def test_label(self, d, expected): + assert _iso_week_label(d) == expected + + +# --------------------------------------------------------------------------- +# fetch_week_note — mocked API +# --------------------------------------------------------------------------- + + +def _make_notebook(id_, title, parent_id=""): + nb = MagicMock() + nb.id = id_ + nb.title = title + nb.parent_id = parent_id + return nb + + +def _make_note(id_, title, body): + note = MagicMock() + note.id = id_ + note.title = title + note.body = body + return note + + +def _build_api(note_body: str, week_label: str = "2026 - W21"): + """Return a mocked ClientApi that serves a minimal notebook tree.""" + api = MagicMock() + api.get_all_notebooks.return_value = [ + _make_notebook("work_id", "Work", parent_id=""), + _make_notebook("ts_id", "Timesheets", parent_id="work_id"), + _make_notebook("yr_id", "2026", parent_id="ts_id"), + ] + api.get_all_notes.return_value = [ + _make_note("note_id", week_label, note_body), + ] + return api + + +class TestFetchWeekNote: + def _patch(self, api): + return patch("timesheets.joplin.ClientApi", return_value=api) + + def test_returns_note_body(self): + api = _build_api("# content") + with self._patch(api): + result = fetch_week_note("dummy_token", date(2026, 5, 22)) + assert result == "# content" + + def test_constructs_client_with_token(self): + api = _build_api("body") + with patch("timesheets.joplin.ClientApi", return_value=api) as mock_cls: + fetch_week_note("my_secret_token", date(2026, 5, 22)) + mock_cls.assert_called_once_with(token="my_secret_token") + + def test_missing_work_notebook_raises(self): + api = MagicMock() + api.get_all_notebooks.return_value = [] + with self._patch(api): + with pytest.raises(RuntimeError, match="'Work' not found"): + fetch_week_note("token", date(2026, 5, 22)) + + def test_missing_timesheets_notebook_raises(self): + api = MagicMock() + api.get_all_notebooks.return_value = [ + _make_notebook("work_id", "Work", parent_id=""), + ] + with self._patch(api): + with pytest.raises(RuntimeError, match="Timesheets"): + fetch_week_note("token", date(2026, 5, 22)) + + def test_missing_year_notebook_raises(self): + api = MagicMock() + api.get_all_notebooks.return_value = [ + _make_notebook("work_id", "Work", parent_id=""), + _make_notebook("ts_id", "Timesheets", parent_id="work_id"), + ] + with self._patch(api): + with pytest.raises(RuntimeError, match="2026"): + fetch_week_note("token", date(2026, 5, 22)) + + def test_missing_note_raises(self): + api = _build_api("body", week_label="2026 - W21") + # Return a note with the wrong title + api.get_all_notes.return_value = [_make_note("x", "wrong title", "body")] + with self._patch(api): + with pytest.raises(RuntimeError, match="note '2026 - W21' not found"): + fetch_week_note("token", date(2026, 5, 22)) diff --git a/tests/test_parser.py b/tests/test_parser.py index 176dfd4..a043573 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -7,6 +7,7 @@ from timesheets.parser import ( build_description, detect_has_duration_column, extract_table_blocks, + filter_rows_by_date, parse_document, parse_table, ) @@ -215,6 +216,67 @@ class TestParseDocument: ) +# --------------------------------------------------------------------------- +# filter_rows_by_date +# --------------------------------------------------------------------------- + + +class TestFilterRowsByDate: + # Reuse the W21 file which has one table per day-heading + with open(WEEK_FILE, encoding="utf-8") as _f: + _WEEK_LINES = _f.read().splitlines() + + def test_returns_only_matching_day(self): + from datetime import date + + rows = filter_rows_by_date(self._WEEK_LINES, date(2026, 5, 22)) + assert len(rows) > 0 + # Friday has these projects in the sample file + projects = {r["project"] for r in rows} + assert "scrum" in projects + + def test_different_day_returns_different_rows(self): + from datetime import date + + rows_fri = filter_rows_by_date(self._WEEK_LINES, date(2026, 5, 22)) + rows_mon = filter_rows_by_date(self._WEEK_LINES, date(2026, 5, 18)) + assert rows_fri != rows_mon + assert len(rows_mon) > 0 + + def test_no_match_returns_empty(self): + from datetime import date + + rows = filter_rows_by_date(self._WEEK_LINES, date(2026, 1, 1)) + assert rows == [] + + def test_inline_document(self): + from datetime import date + + lines = [ + "# Maandag - 2026-05-18", + "| Start | End | Project | Story | Note |", + "|-------|-------|---------|-------|------||", + "| 08:00 | 08:30 | bugs | | fix |", + "", + "# Dinsdag - 2026-05-19", + "| Start | End | Project | Story | Note |", + "|-------|-------|---------|-------|------||", + "| 09:00 | 09:30 | scrum | | dsu |", + ] + rows = filter_rows_by_date(lines, date(2026, 5, 18)) + assert len(rows) == 1 + assert rows[0]["project"] == "bugs" + + rows = filter_rows_by_date(lines, date(2026, 5, 19)) + assert len(rows) == 1 + assert rows[0]["project"] == "scrum" + + def test_empty_input(self): + from datetime import date + + assert filter_rows_by_date([], date(2026, 5, 22)) == [] + + # --------------------------------------------------------------------------- # build_description # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index d078cce..cd261ec 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,72 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -80,6 +146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -89,6 +164,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "joppy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/f9/3f6691ecba05f0695246a87940f9d2ea556f64d05ca1490fbcfa9797dee9/joppy-1.0.2.tar.gz", hash = "sha256:f9f8650b088bc5bf7230580c7207aea548731429072c834e5dbfa2d517d06b3a", size = 36759, upload-time = "2026-03-10T19:13:52.634Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/ca/79f80b83614b9c3f8125e5d5f3ab15491fe266fafb8b07d3420b6febbf30/joppy-1.0.2-py3-none-any.whl", hash = "sha256:a82c5d2952845eb33521a37dcc83baf660b92e46bddfffdd1070c8521fc62ee2", size = 25417, upload-time = "2026-03-10T19:13:50.775Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -146,10 +233,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "timesheets" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "joppy" }, +] [package.dev-dependencies] dev = [ @@ -158,9 +263,19 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "joppy", specifier = ">=1.0.2" }] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=7.1.0" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]