Add --weekly flag to csv command
- Add `write_csv_weekly()` to output.py: writes entries from multiple days as a single CSV with one header row, correct date per row - Add `-w`/`--weekly` flag to csv subparser - _cmd_csv branches on args.weekly: fetches week sections, formats per-day date strings, calls write_csv_weekly; --raw is honoured - Add TestWriteCsvWeekly with 6 tests - Update README with weekly csv usage examples
This commit is contained in:
parent
985ee28113
commit
cd8ca789aa
4 changed files with 134 additions and 10 deletions
|
|
@ -67,6 +67,8 @@ uv run timesheets summary -w -ss --joplin # weekly totals only: one line
|
||||||
uv run timesheets csv --joplin # stdout
|
uv run timesheets csv --joplin # stdout
|
||||||
uv run timesheets csv --joplin -o output.csv # write to file
|
uv run timesheets csv --joplin -o output.csv # write to file
|
||||||
uv run timesheets csv --raw --joplin # one row per entry, no aggregation
|
uv run timesheets csv --raw --joplin # one row per entry, no aggregation
|
||||||
|
uv run timesheets csv -w --joplin # full week
|
||||||
|
uv run timesheets csv -w --raw --joplin # full week, no aggregation
|
||||||
```
|
```
|
||||||
|
|
||||||
### stories
|
### stories
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from .output import (
|
||||||
print_summary_weekly_totals,
|
print_summary_weekly_totals,
|
||||||
to_csv_entries,
|
to_csv_entries,
|
||||||
write_csv,
|
write_csv,
|
||||||
|
write_csv_weekly,
|
||||||
)
|
)
|
||||||
from .parser import (
|
from .parser import (
|
||||||
aggregate_rows,
|
aggregate_rows,
|
||||||
|
|
@ -140,6 +141,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
"combining entries that share the same project and story."
|
"combining entries that share the same project and story."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
csv_parser.add_argument(
|
||||||
|
"--weekly",
|
||||||
|
"-w",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Export the full week containing the given day instead of a single day.",
|
||||||
|
)
|
||||||
|
|
||||||
status_parser = subparsers.add_parser(
|
status_parser = subparsers.add_parser(
|
||||||
"status",
|
"status",
|
||||||
|
|
@ -396,12 +404,31 @@ def _cmd_stories(args: argparse.Namespace, config: dict) -> None:
|
||||||
|
|
||||||
def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
|
def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
|
||||||
target_date, date_str = _resolve_date(args)
|
target_date, date_str = _resolve_date(args)
|
||||||
|
project_map = _resolve_project_map(args, config)
|
||||||
|
|
||||||
|
if args.weekly:
|
||||||
|
day_sections_raw = _resolve_week_sections(args, config, target_date)
|
||||||
|
if not day_sections_raw:
|
||||||
|
print("Warning: no timesheet rows found for this week.", file=sys.stderr)
|
||||||
|
return
|
||||||
|
day_sections = [
|
||||||
|
(
|
||||||
|
format_date(day),
|
||||||
|
to_csv_entries(rows) if args.raw else aggregate_rows(rows),
|
||||||
|
)
|
||||||
|
for day, rows in day_sections_raw
|
||||||
|
]
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w", newline="", encoding="utf-8") as f:
|
||||||
|
write_csv_weekly(day_sections, f, project_map)
|
||||||
|
print(f"Written to {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
write_csv_weekly(day_sections, sys.stdout, project_map)
|
||||||
|
else:
|
||||||
rows = _resolve_rows(args, config, target_date)
|
rows = _resolve_rows(args, config, target_date)
|
||||||
if not rows:
|
if not rows:
|
||||||
print("Warning: no timesheet rows found in input.", file=sys.stderr)
|
print("Warning: no timesheet rows found in input.", file=sys.stderr)
|
||||||
entries = to_csv_entries(rows) if args.raw else aggregate_rows(rows)
|
entries = to_csv_entries(rows) if args.raw else aggregate_rows(rows)
|
||||||
project_map = _resolve_project_map(args, config)
|
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
with open(args.output, "w", newline="", encoding="utf-8") as f:
|
with open(args.output, "w", newline="", encoding="utf-8") as f:
|
||||||
write_csv(entries, f, date_str, project_map)
|
write_csv(entries, f, date_str, project_map)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,32 @@ def to_csv_entries(rows: list[dict]) -> list[dict]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv_weekly(
|
||||||
|
day_sections: list[tuple[str, list[dict]]],
|
||||||
|
output: IO[str],
|
||||||
|
project_map: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Write entries from multiple days as a single CSV with one header row.
|
||||||
|
|
||||||
|
day_sections is a list of (date_str, entries) pairs where entries are
|
||||||
|
already write_csv-compatible (project, description, quantity).
|
||||||
|
"""
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["Date*", "Project*", "Task", "Description", "Quantity"])
|
||||||
|
for date_str, entries in day_sections:
|
||||||
|
for entry in entries:
|
||||||
|
project, task = resolve_project_task(entry["project"], project_map)
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
date_str,
|
||||||
|
project,
|
||||||
|
task,
|
||||||
|
entry["description"],
|
||||||
|
f"{entry['quantity']:.2f}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def write_csv(
|
def write_csv(
|
||||||
aggregated: list[dict],
|
aggregated: list[dict],
|
||||||
output: IO[str],
|
output: IO[str],
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import io
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from timesheets.output import print_stories, print_summary, to_csv_entries, write_csv
|
from timesheets.output import (
|
||||||
|
print_stories,
|
||||||
|
print_summary,
|
||||||
|
to_csv_entries,
|
||||||
|
write_csv,
|
||||||
|
write_csv_weekly,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared fixtures
|
# Shared fixtures
|
||||||
|
|
@ -129,6 +135,69 @@ class TestWriteCsv:
|
||||||
assert rows[1][3] == "ticket 1"
|
assert rows[1][3] == "ticket 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# write_csv_weekly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
DAY_SECTIONS = [
|
||||||
|
(
|
||||||
|
"22/03/26",
|
||||||
|
[
|
||||||
|
{"project": "bugs", "description": "ticket 1", "quantity": 1.0},
|
||||||
|
{"project": "scrum", "description": "dsu", "quantity": 0.25},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"23/03/26",
|
||||||
|
[
|
||||||
|
{"project": "bugs", "description": "ticket 2", "quantity": 0.5},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteCsvWeekly:
|
||||||
|
def _run(self, day_sections=None, project_map=None):
|
||||||
|
buf = io.StringIO()
|
||||||
|
write_csv_weekly(
|
||||||
|
DAY_SECTIONS if day_sections is None else day_sections,
|
||||||
|
buf,
|
||||||
|
project_map or {},
|
||||||
|
)
|
||||||
|
buf.seek(0)
|
||||||
|
return list(csv.reader(buf))
|
||||||
|
|
||||||
|
def test_header_written_once(self):
|
||||||
|
rows = self._run()
|
||||||
|
assert rows[0] == ["Date*", "Project*", "Task", "Description", "Quantity"]
|
||||||
|
assert sum(1 for r in rows if r[0] == "Date*") == 1
|
||||||
|
|
||||||
|
def test_row_count(self):
|
||||||
|
rows = self._run()
|
||||||
|
assert len(rows) == 1 + 3 # header + 3 entries across 2 days
|
||||||
|
|
||||||
|
def test_correct_date_per_row(self):
|
||||||
|
rows = self._run()
|
||||||
|
assert rows[1][0] == "22/03/26"
|
||||||
|
assert rows[2][0] == "22/03/26"
|
||||||
|
assert rows[3][0] == "23/03/26"
|
||||||
|
|
||||||
|
def test_empty_sections_produce_header_only(self):
|
||||||
|
rows = self._run(day_sections=[])
|
||||||
|
assert rows == [["Date*", "Project*", "Task", "Description", "Quantity"]]
|
||||||
|
|
||||||
|
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_quantity_format(self):
|
||||||
|
rows = self._run()
|
||||||
|
assert rows[1][4] == "1.00"
|
||||||
|
assert rows[3][4] == "0.50"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# print_summary
|
# print_summary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue