feat: set up modularized version of project with testing

This commit is contained in:
Jef Roosens 2026-05-22 10:09:59 +02:00
commit 7bea08ddac
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
19 changed files with 1138 additions and 0 deletions

BIN
.coverage Normal file

Binary file not shown.

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.13

112
AGENTS.md Normal file
View file

@ -0,0 +1,112 @@
# Timesheets — Agent Guide
## Project overview
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.
### Package layout
```
timesheets/
├── pyproject.toml # package metadata, entry point, dev dependencies
├── AGENTS.md
└── src/timesheets/
├── cli.py # argument parsing, main() entry point
├── parser.py # markdown table parsing and row aggregation
├── projects.py # project_map.json loading and key resolution
├── output.py # CSV writing and summary printing
└── utils.py # shared low-level helpers (duration parsing, formatting, etc.)
```
Tests live in `tests/`, one file per source module:
```
tests/
├── test_utils.py
├── test_parser.py
├── test_projects.py
└── test_output.py
```
---
## Package manager — uv
All dependency management and script execution is done via [`uv`](https://docs.astral.sh/uv/).
Do **not** use `pip` or `python` directly.
| Task | Command |
|---|---|
| Install / sync dependencies | `uv sync` |
| Add a runtime dependency | `uv add <package>` |
| Add a dev-only dependency | `uv add --dev <package>` |
| Run the CLI | `uv run timesheets <args>` |
| Run any Python script | `uv run python <script>` |
---
## CLI usage
```sh
# Print CSV to stdout (date defaults to today)
uv run timesheets input.md
# Write CSV to a file
uv run timesheets input.md -o output.csv
# Override the date (DD/MM/YY)
uv run timesheets input.md --date 22/03/26
# Use a specific project map file
uv run timesheets input.md --map /path/to/project_map.json
# Print a human-readable summary instead of CSV
uv run timesheets input.md --summary
# Read from stdin
cat input.md | uv run timesheets -
```
`project_map.json` is auto-discovered in the current working directory if
`--map` is not provided.
---
## Testing
The test suite uses **pytest** with **pytest-cov** for coverage reporting.
```sh
# Run all tests
uv run pytest
# Run with coverage report
uv run pytest --cov
# Run a specific test file
uv run pytest tests/test_parser.py
# Run a specific test
uv run pytest tests/test_parser.py::TestParseTable::test_empty_input
```
### Rules for adding or changing functionality
1. **Always update or add tests** when introducing new behaviour or modifying
existing behaviour. Tests live in the `tests/` file that corresponds to the
module being changed (e.g. changes to `parser.py``tests/test_parser.py`).
2. **Run the full test suite before finishing** and confirm it passes with no
failures:
```sh
uv run pytest --cov
```
3. **Do not reduce coverage.** Every new function or branch should have at
least one test covering the happy path. Edge cases and error paths should be
covered where the logic is non-trivial.
4. `cli.py` is intentionally excluded from unit tests — it is thin glue code.
All logic worth testing belongs in the other modules.

0
README.md Normal file
View file

57
project_map.json Normal file
View file

@ -0,0 +1,57 @@
{
"bugs": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian] Bugs"
},
"scrum": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian] SCRUM Meetings"
},
"office": {
"Project": "[Factry] Internal",
"Task": "Office Management"
},
"requests": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian] Incoming requests"
},
"internal": {
"Project": "[Factry] Internal"
},
"environ": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian] Environment Management"
},
"v8": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian][Feature] v8 - Release"
},
"refine": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian] Product refinement /26"
},
"ignition": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian][Feature] Ignition Module"
},
"e2e": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian] E2E Testing"
},
"tsdb": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian][Feature] Alernative TSDB"
},
"rate": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian][Feature] API Rate limiting | OpenAPI v3"
},
"hatch": {
"Project": "[Factry] Historian Product Development",
"Task": "[Historian][Feature] Hatchry | MR previews"
},
"product": {
"Project": "[Factry] Historian Product Development",
"Task": ""
}
}

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[project]
name = "timesheets"
version = "0.1.0"
description = "Parse markdown timesheet tables and export to CSV"
readme = "README.md"
authors = [
{ name = "Jef Roosens", email = "roosenj@factry.io" }
]
requires-python = ">=3.13"
dependencies = []
[project.scripts]
timesheets = "timesheets.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["timesheets"]
[tool.coverage.report]
show_missing = true
[build-system]
requires = ["uv_build>=0.10.8,<0.11.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-cov>=7.1.0",
]

View file

@ -0,0 +1,3 @@
from .cli import main
__all__ = ["main"]

85
src/timesheets/cli.py Normal file
View file

@ -0,0 +1,85 @@
import argparse
import os
import sys
from datetime import date
from .output import print_summary, write_csv
from .parser import aggregate_rows, detect_has_duration_column, parse_table
from .projects import load_project_map
from .utils import format_date
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Parse a markdown timesheet table and output a CSV file."
)
parser.add_argument(
"input",
help="Path to the markdown file containing the timesheet table, or '-' to read from stdin.",
)
parser.add_argument(
"-o", "--output",
help="Path to the output CSV file. Defaults to stdout.",
default=None,
)
parser.add_argument(
"--date",
help="Date to use in the output (DD/MM/YY). Defaults to today.",
default=None,
)
parser.add_argument(
"--map",
help=(
"Path to a JSON file mapping project keys to Project+Task pairs. "
"Defaults to project_map.json in the current working directory if it exists."
),
default=None,
)
parser.add_argument(
"--summary",
action="store_true",
help="Print a human-readable summary instead of writing CSV.",
)
return parser
def main() -> None:
args = build_parser().parse_args()
date_str = args.date or format_date(date.today())
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_table(lines, has_duration_col=detect_has_duration_column(lines))
if not rows:
print("Warning: no timesheet rows found in input.", file=sys.stderr)
aggregated = aggregate_rows(rows)
# Resolve project map: explicit --map flag, else project_map.json in cwd
map_path = args.map
if map_path is None:
default_map = os.path.join(os.getcwd(), "project_map.json")
if os.path.exists(default_map):
map_path = default_map
project_map = load_project_map(map_path)
if args.summary:
print_summary(aggregated, project_map)
elif args.output:
with open(args.output, "w", newline="", encoding="utf-8") as f:
write_csv(aggregated, f, date_str, project_map)
print(f"Written to {args.output}", file=sys.stderr)
else:
write_csv(aggregated, sys.stdout, date_str, project_map)

54
src/timesheets/output.py Normal file
View file

@ -0,0 +1,54 @@
import csv
import sys
from collections import OrderedDict
from typing import IO
from .projects import resolve_project_task
from .utils import decimal_to_hhmm
def write_csv(
aggregated: list[dict],
output: IO[str],
date_str: str,
project_map: dict,
) -> None:
"""Write the aggregated timesheet data as CSV."""
writer = csv.writer(output)
writer.writerow(["Date*", "Project*", "Task", "Description", "Quantity"])
for entry in aggregated:
project, task = resolve_project_task(entry["project"], project_map)
writer.writerow(
[
date_str,
project,
task,
entry["description"],
f"{entry['quantity']:.2f}",
]
)
def print_summary(aggregated: list[dict], project_map: dict) -> None:
"""Print a human-readable summary of time blocks to stdout."""
grouped: dict[str, list[dict]] = OrderedDict()
for entry in aggregated:
project, task = resolve_project_task(entry["project"], project_map)
label = f"{project} / {task}" if task else project
grouped.setdefault(label, []).append(entry)
total_all = sum(e["quantity"] for e in aggregated)
all_descs = [e["description"] for e in aggregated]
desc_width = max(max((len(d) for d in all_descs), default=40), 40)
separator = "-" * (desc_width + 16)
for label, entries in grouped.items():
project_total = sum(e["quantity"] for e in entries)
print(f"\n {label} ({decimal_to_hhmm(project_total)})")
print(" " + separator)
for entry in entries:
print(f" {entry['description']:<{desc_width}} {decimal_to_hhmm(entry['quantity'])}")
print(" " + separator)
print(f"\n {'TOTAL':<{desc_width + 2}} {decimal_to_hhmm(total_all)}\n")

115
src/timesheets/parser.py Normal file
View file

@ -0,0 +1,115 @@
import re
from collections import defaultdict
from .utils import duration_from_start_end, parse_duration, strip_markdown_link
def detect_has_duration_column(lines: list[str]) -> bool:
"""
Inspect the header row to determine whether a Duration column is present.
Falls back to True if no header row is found.
"""
for line in lines:
line = line.strip()
if not (line.startswith("|") and line.endswith("|")):
continue
cells = [c.strip().lower() for c in line.strip("|").split("|")]
if "start" in cells:
return "duration" in cells
return True
def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
"""
Parse markdown table lines into a list of row dicts.
With duration: Start | End | Duration | Project | Story | Note (6 cols)
Without duration: Start | End | Project | Story | Note (5 cols)
"""
min_cols = 6 if has_duration_col else 5
rows = []
for line in lines:
line = line.strip()
if not line or re.match(r"^\|[-| :]+\|$", line):
continue
if not (line.startswith("|") and line.endswith("|")):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < min_cols:
continue
if has_duration_col:
start, end, duration, project, story, note = (
cells[0], cells[1], cells[2], cells[3],
strip_markdown_link(cells[4]),
strip_markdown_link(cells[5]),
)
else:
start, end, project, story, note = (
cells[0], cells[1], cells[2],
strip_markdown_link(cells[3]),
strip_markdown_link(cells[4]),
)
duration = None
if start.lower() == "start":
continue
if not re.match(r"^\d+:\d{2}$", start):
continue
if duration is not None:
if not re.match(r"^\d+:\d{2}$", duration):
continue
duration_hours = parse_duration(duration)
else:
try:
duration_hours = duration_from_start_end(start, end)
except ValueError:
continue
rows.append(
{
"start": start,
"end": end,
"duration_hours": duration_hours,
"project": project,
"story": story,
"note": note,
}
)
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()]
return " - ".join(parts) if parts else "/"
def aggregate_rows(rows: list[dict]) -> list[dict]:
"""
Group rows by (project, description) and sum durations.
Returns a list of dicts with keys: project, description, quantity.
Preserves insertion order of first occurrence.
"""
key_order: list[tuple] = []
totals: dict[tuple, float] = defaultdict(float)
for row in rows:
description = build_description(row["story"], row["note"])
key = (row["project"].strip(), description)
if key not in totals:
key_order.append(key)
totals[key] += row["duration_hours"]
return [
{
"project": project,
"description": description,
"quantity": totals[(project, description)],
}
for project, description in key_order
]

View file

@ -0,0 +1,29 @@
import json
import sys
def load_project_map(path: str | None) -> dict:
"""Load a project map JSON file. Returns an empty dict if path is None or missing."""
if not path:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
print(f"Warning: project map file not found: {path}", file=sys.stderr)
return {}
except json.JSONDecodeError as e:
print(f"Warning: could not parse project map file: {e}", file=sys.stderr)
return {}
def resolve_project_task(raw_project: str, project_map: dict) -> tuple[str, str]:
"""
Look up a raw project key in the project map.
Returns (Project, Task) if found, or (raw_project, "") as fallback.
"""
key = raw_project.strip().lower()
entry = project_map.get(key) or project_map.get(raw_project.strip())
if entry:
return entry.get("Project", raw_project), entry.get("Task", "")
return raw_project, ""

44
src/timesheets/utils.py Normal file
View file

@ -0,0 +1,44 @@
import re
from datetime import date
def parse_duration(duration_str: str) -> float:
"""Convert HH:MM duration string to decimal hours."""
duration_str = duration_str.strip()
match = re.match(r"^(\d+):(\d{2})$", duration_str)
if not match:
raise ValueError(f"Invalid duration format: {duration_str!r}")
return int(match.group(1)) + int(match.group(2)) / 60.0
def duration_from_start_end(start_str: str, end_str: str) -> float:
"""Calculate duration in decimal hours from two HH:MM time strings."""
def to_minutes(t: str) -> int:
match = re.match(r"^(\d+):(\d{2})$", t.strip())
if not match:
raise ValueError(f"Invalid time format: {t!r}")
return int(match.group(1)) * 60 + int(match.group(2))
start_minutes = to_minutes(start_str)
end_minutes = to_minutes(end_str)
if end_minutes < start_minutes:
end_minutes += 24 * 60 # midnight rollover
return (end_minutes - start_minutes) / 60.0
def decimal_to_hhmm(hours: float) -> str:
"""Convert decimal hours to a HH:MM string."""
total_minutes = round(hours * 60)
h, m = divmod(total_minutes, 60)
return f"{h:02d}:{m:02d}"
def strip_markdown_link(text: str) -> str:
"""Strip markdown link syntax [label](url), keeping only the label."""
return re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
def format_date(d: date) -> str:
"""Format date as DD/MM/YY."""
return d.strftime("%d/%m/%y")

0
tests/__init__.py Normal file
View file

113
tests/test_output.py Normal file
View file

@ -0,0 +1,113 @@
import csv
import io
import pytest
from timesheets.output import print_summary, write_csv
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
PROJECT_MAP = {
"bugs": {"Project": "[Factry] Historian", "Task": "[Historian] Bugs"},
}
AGGREGATED = [
{"project": "bugs", "description": "ticket 1", "quantity": 1.0},
{"project": "bugs", "description": "ticket 2", "quantity": 0.5},
{"project": "scrum", "description": "dsu", "quantity": 0.25},
]
# ---------------------------------------------------------------------------
# write_csv
# ---------------------------------------------------------------------------
class TestWriteCsv:
def _run(self, aggregated=None, date_str="22/03/26", project_map=None):
buf = io.StringIO()
write_csv(
aggregated or AGGREGATED,
buf,
date_str,
project_map or {},
)
buf.seek(0)
return list(csv.reader(buf))
def test_header_row(self):
rows = self._run()
assert rows[0] == ["Date*", "Project*", "Task", "Description", "Quantity"]
def test_row_count(self):
rows = self._run()
assert len(rows) == 1 + len(AGGREGATED)
def test_date_column(self):
rows = self._run(date_str="01/01/26")
assert all(r[0] == "01/01/26" for r in rows[1:])
def test_quantity_format(self):
rows = self._run()
assert rows[1][4] == "1.00"
assert rows[2][4] == "0.50"
def test_project_map_applied(self):
rows = self._run(project_map=PROJECT_MAP)
assert rows[1][1] == "[Factry] Historian"
assert rows[1][2] == "[Historian] Bugs"
def test_unmapped_project_fallback(self):
rows = self._run(project_map=PROJECT_MAP)
# "scrum" is not in the map
scrum_row = next(r for r in rows[1:] if "dsu" in r)
assert scrum_row[1] == "scrum"
assert scrum_row[2] == ""
def test_description_column(self):
rows = self._run()
assert rows[1][3] == "ticket 1"
# ---------------------------------------------------------------------------
# print_summary
# ---------------------------------------------------------------------------
class TestPrintSummary:
def _run(self, aggregated=None, project_map=None, capsys=None):
print_summary(aggregated or AGGREGATED, project_map or {})
return capsys.readouterr().out
def test_contains_project_label(self, capsys):
out = self._run(capsys=capsys)
assert "scrum" in out
def test_contains_mapped_project_label(self, capsys):
out = self._run(project_map=PROJECT_MAP, capsys=capsys)
assert "[Factry] Historian" in out
assert "[Historian] Bugs" in out
def test_contains_description(self, capsys):
out = self._run(capsys=capsys)
assert "ticket 1" in out
assert "dsu" in out
def test_contains_total(self, capsys):
out = self._run(capsys=capsys)
assert "TOTAL" in out
# 1.0 + 0.5 + 0.25 = 1.75 hours = 01:45
assert "01:45" in out
def test_project_subtotal(self, capsys):
out = self._run(capsys=capsys)
# bugs total = 1.0 + 0.5 = 1.5 hours = 01:30
assert "01:30" in out
def test_hhmm_durations_shown(self, capsys):
out = self._run(capsys=capsys)
assert "01:00" in out
assert "00:30" in out
assert "00:15" in out

150
tests/test_parser.py Normal file
View file

@ -0,0 +1,150 @@
import pytest
from timesheets.parser import (
aggregate_rows,
build_description,
detect_has_duration_column,
parse_table,
)
# ---------------------------------------------------------------------------
# Fixtures / shared data
# ---------------------------------------------------------------------------
WITH_DURATION = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|-------------|---------|",
"| 08:00 | 08:30 | 00:30 | bugs | story one | |",
"| 08:30 | 09:00 | 00:30 | bugs | story one | |",
"| 09:00 | 09:15 | 00:15 | scrum | | dsu |",
]
WITHOUT_DURATION = [
"| Start | End | Project | Story | Note |",
"|-------|-------|---------|-------------|---------|",
"| 08:00 | 08:30 | bugs | story one | |",
"| 08:30 | 09:15 | scrum | | dsu |",
]
# ---------------------------------------------------------------------------
# detect_has_duration_column
# ---------------------------------------------------------------------------
class TestDetectHasDurationColumn:
def test_with_duration(self):
assert detect_has_duration_column(WITH_DURATION) is True
def test_without_duration(self):
assert detect_has_duration_column(WITHOUT_DURATION) is False
def test_no_header_defaults_to_true(self):
assert detect_has_duration_column(["no table here"]) is True
def test_case_insensitive(self):
lines = ["| Start | End | DURATION | Project | Story | Note |"]
assert detect_has_duration_column(lines) is True
# ---------------------------------------------------------------------------
# parse_table
# ---------------------------------------------------------------------------
class TestParseTable:
def test_with_duration_column(self):
rows = parse_table(WITH_DURATION, has_duration_col=True)
assert len(rows) == 3
assert rows[0]["project"] == "bugs"
assert rows[0]["duration_hours"] == 0.5
assert rows[2]["project"] == "scrum"
assert rows[2]["note"] == "dsu"
def test_without_duration_column(self):
rows = parse_table(WITHOUT_DURATION, has_duration_col=False)
assert len(rows) == 2
assert rows[0]["duration_hours"] == 0.5 # 08:0008:30
assert rows[1]["duration_hours"] == 0.75 # 08:3009:15
def test_header_row_skipped(self):
rows = parse_table(WITH_DURATION)
assert all(r["start"] != "Start" for r in rows)
def test_separator_row_skipped(self):
rows = parse_table(WITH_DURATION)
assert all(r["start"] != "---" for r in rows)
def test_markdown_link_stripped_in_story(self):
lines = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|----------------------------|------|",
"| 08:00 | 08:30 | 00:30 | bugs | [ticket 1](:/abc123) | |",
]
rows = parse_table(lines)
assert rows[0]["story"] == "ticket 1"
def test_invalid_duration_row_skipped(self):
lines = [
"| Start | End | Duration | Project | Story | Note |",
"|-------|-------|----------|---------|-------|------|",
"| 08:00 | 08:30 | bad | bugs | | |",
]
assert parse_table(lines) == []
def test_empty_input(self):
assert parse_table([]) == []
def test_non_table_lines_ignored(self):
lines = ["# My Timesheet", "", "Some prose."] + WITH_DURATION
rows = parse_table(lines)
assert len(rows) == 3
# ---------------------------------------------------------------------------
# build_description
# ---------------------------------------------------------------------------
class TestBuildDescription:
def test_story_and_note(self):
assert build_description("story", "note") == "story - note"
def test_story_only(self):
assert build_description("story", "") == "story"
def test_note_only(self):
assert build_description("", "note") == "note"
def test_both_empty(self):
assert build_description("", "") == "/"
def test_strips_whitespace(self):
assert build_description(" story ", " note ") == "story - note"
# ---------------------------------------------------------------------------
# aggregate_rows
# ---------------------------------------------------------------------------
class TestAggregateRows:
def test_same_project_story_summed(self):
rows = parse_table(WITH_DURATION)
aggregated = aggregate_rows(rows)
bugs = next(e for e in aggregated if e["project"] == "bugs")
assert bugs["quantity"] == 1.0 # 00:30 + 00:30
def test_distinct_entries_preserved(self):
rows = parse_table(WITH_DURATION)
aggregated = aggregate_rows(rows)
assert len(aggregated) == 2 # bugs/story-one and scrum/dsu
def test_insertion_order_preserved(self):
rows = parse_table(WITH_DURATION)
aggregated = aggregate_rows(rows)
assert aggregated[0]["project"] == "bugs"
assert aggregated[1]["project"] == "scrum"
def test_empty_input(self):
assert aggregate_rows([]) == []

67
tests/test_projects.py Normal file
View file

@ -0,0 +1,67 @@
import pytest
from timesheets.projects import load_project_map, resolve_project_task
PROJECT_MAP = {
"bugs": {"Project": "[Factry] Historian", "Task": "[Historian] Bugs"},
"internal": {"Project": "[Factry] Internal", "Task": ""},
}
# ---------------------------------------------------------------------------
# load_project_map
# ---------------------------------------------------------------------------
class TestLoadProjectMap:
def test_returns_empty_for_none(self):
assert load_project_map(None) == {}
def test_loads_valid_file(self, tmp_path):
f = tmp_path / "map.json"
f.write_text('{"bugs": {"Project": "X", "Task": "Y"}}')
result = load_project_map(str(f))
assert result == {"bugs": {"Project": "X", "Task": "Y"}}
def test_missing_file_returns_empty(self, tmp_path, capsys):
result = load_project_map(str(tmp_path / "nonexistent.json"))
assert result == {}
assert "Warning" in capsys.readouterr().err
def test_invalid_json_returns_empty(self, tmp_path, capsys):
f = tmp_path / "bad.json"
f.write_text("not json{{{")
result = load_project_map(str(f))
assert result == {}
assert "Warning" in capsys.readouterr().err
# ---------------------------------------------------------------------------
# resolve_project_task
# ---------------------------------------------------------------------------
class TestResolveProjectTask:
def test_exact_key_match(self):
project, task = resolve_project_task("bugs", PROJECT_MAP)
assert project == "[Factry] Historian"
assert task == "[Historian] Bugs"
def test_case_insensitive_lookup(self):
project, task = resolve_project_task("BUGS", PROJECT_MAP)
assert project == "[Factry] Historian"
def test_missing_key_returns_raw(self):
project, task = resolve_project_task("unknown", PROJECT_MAP)
assert project == "unknown"
assert task == ""
def test_empty_task_in_map(self):
project, task = resolve_project_task("internal", PROJECT_MAP)
assert project == "[Factry] Internal"
assert task == ""
def test_empty_map(self):
project, task = resolve_project_task("bugs", {})
assert project == "bugs"
assert task == ""

100
tests/test_utils.py Normal file
View file

@ -0,0 +1,100 @@
from datetime import date
import pytest
from timesheets.utils import (
decimal_to_hhmm,
duration_from_start_end,
format_date,
parse_duration,
strip_markdown_link,
)
class TestParseDuration:
def test_basic(self):
assert parse_duration("01:30") == 1.5
def test_zero(self):
assert parse_duration("00:00") == 0.0
def test_minutes_only(self):
assert parse_duration("00:15") == 0.25
def test_multidigit_hours(self):
assert parse_duration("10:00") == 10.0
def test_strips_whitespace(self):
assert parse_duration(" 01:00 ") == 1.0
@pytest.mark.parametrize("bad", ["1:5", "abc", "1:00:00", ""])
def test_invalid_raises(self, bad):
with pytest.raises(ValueError):
parse_duration(bad)
class TestDurationFromStartEnd:
def test_basic(self):
assert duration_from_start_end("08:00", "09:00") == 1.0
def test_partial_hour(self):
assert duration_from_start_end("08:15", "08:30") == 0.25
def test_midnight_rollover(self):
assert duration_from_start_end("23:45", "00:15") == 0.5
def test_same_time(self):
assert duration_from_start_end("09:00", "09:00") == 0.0
@pytest.mark.parametrize("bad_start,bad_end", [("9:0", "10:00"), ("08:00", "10:0")])
def test_invalid_raises(self, bad_start, bad_end):
with pytest.raises(ValueError):
duration_from_start_end(bad_start, bad_end)
class TestDecimalToHhmm:
@pytest.mark.parametrize(
"hours,expected",
[
(1.0, "01:00"),
(1.5, "01:30"),
(0.25, "00:15"),
(0.0, "00:00"),
(10.0, "10:00"),
# rounding: 0.1666... hours = 10 minutes
(1 / 6, "00:10"),
],
)
def test_conversion(self, hours, expected):
assert decimal_to_hhmm(hours) == expected
class TestStripMarkdownLink:
def test_strips_link(self):
assert strip_markdown_link("[foo bar](http://example.com)") == "foo bar"
def test_strips_joplin_style_link(self):
text = "[29497: collector auto update](:/0ce89020cd874f0281a71f62b9d7b75f)"
assert strip_markdown_link(text) == "29497: collector auto update"
def test_plain_text_unchanged(self):
assert strip_markdown_link("just plain text") == "just plain text"
def test_empty_string(self):
assert strip_markdown_link("") == ""
def test_multiple_links(self):
text = "[a](url1) and [b](url2)"
assert strip_markdown_link(text) == "a and b"
def test_partial_link_unchanged(self):
# Missing closing paren — not a valid link, should be left alone
assert strip_markdown_link("[foo](bar") == "[foo](bar"
class TestFormatDate:
def test_format(self):
assert format_date(date(2026, 3, 22)) == "22/03/26"
def test_single_digit_day_month(self):
assert format_date(date(2026, 1, 5)) == "05/01/26"

166
uv.lock generated Normal file
View file

@ -0,0 +1,166 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ 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 = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
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 = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
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 = "timesheets"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
]