Add --raw flag to csv command to skip aggregation
- Add `to_csv_entries()` to output.py: converts raw rows to write_csv entries one-for-one, without merging by (project, description) - Add `--raw` flag to the csv subparser; _cmd_csv branches on it - Add TestToCsvEntries with 6 tests - Update README with --raw usage example - Add .coverage and htmlcov/ to .gitignore
This commit is contained in:
parent
91ce81a65f
commit
985ee28113
6 changed files with 97 additions and 4 deletions
BIN
.coverage
BIN
.coverage
Binary file not shown.
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,3 +13,7 @@ wheels/
|
||||||
timesheets.toml
|
timesheets.toml
|
||||||
|
|
||||||
timesheets.csv
|
timesheets.csv
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ uv run timesheets summary -w -ss --joplin # weekly totals only: one line
|
||||||
```sh
|
```sh
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### stories
|
### stories
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from .output import (
|
||||||
print_summary_weekly,
|
print_summary_weekly,
|
||||||
print_summary_weekly_short,
|
print_summary_weekly_short,
|
||||||
print_summary_weekly_totals,
|
print_summary_weekly_totals,
|
||||||
|
to_csv_entries,
|
||||||
write_csv,
|
write_csv,
|
||||||
)
|
)
|
||||||
from .parser import (
|
from .parser import (
|
||||||
|
|
@ -130,6 +131,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
help="Export timesheet entries as CSV.",
|
help="Export timesheet entries as CSV.",
|
||||||
)
|
)
|
||||||
_add_shared_args(csv_parser)
|
_add_shared_args(csv_parser)
|
||||||
|
csv_parser.add_argument(
|
||||||
|
"--raw",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help=(
|
||||||
|
"Skip aggregation: output one CSV row per timesheet entry instead of "
|
||||||
|
"combining entries that share the same project and story."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
status_parser = subparsers.add_parser(
|
status_parser = subparsers.add_parser(
|
||||||
"status",
|
"status",
|
||||||
|
|
@ -389,15 +399,15 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
|
||||||
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)
|
||||||
aggregated = aggregate_rows(rows)
|
entries = to_csv_entries(rows) if args.raw else aggregate_rows(rows)
|
||||||
project_map = _resolve_project_map(args, config)
|
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(aggregated, f, date_str, project_map)
|
write_csv(entries, f, date_str, project_map)
|
||||||
print(f"Written to {args.output}", file=sys.stderr)
|
print(f"Written to {args.output}", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
write_csv(aggregated, sys.stdout, date_str, project_map)
|
write_csv(entries, sys.stdout, date_str, project_map)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,26 @@ from .projects import resolve_project_task
|
||||||
from .utils import decimal_to_hhmm
|
from .utils import decimal_to_hhmm
|
||||||
|
|
||||||
|
|
||||||
|
def to_csv_entries(rows: list[dict]) -> list[dict]:
|
||||||
|
"""Convert raw parsed rows to write_csv-compatible entries without aggregating.
|
||||||
|
|
||||||
|
Each row becomes its own entry. Open entries (duration_hours is None) are
|
||||||
|
skipped. Rows are not combined, even if they share the same project and
|
||||||
|
description.
|
||||||
|
"""
|
||||||
|
from .parser import build_description
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"project": row["project"].strip(),
|
||||||
|
"description": build_description(row["story"], row["note"]),
|
||||||
|
"quantity": row["duration_hours"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
if row["duration_hours"] is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def write_csv(
|
def write_csv(
|
||||||
aggregated: list[dict],
|
aggregated: list[dict],
|
||||||
output: IO[str],
|
output: IO[str],
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import io
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from timesheets.output import print_stories, print_summary, write_csv
|
from timesheets.output import print_stories, print_summary, to_csv_entries, write_csv
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared fixtures
|
# Shared fixtures
|
||||||
|
|
@ -20,6 +20,64 @@ AGGREGATED = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# to_csv_entries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_row(project, story, note, duration_hours):
|
||||||
|
return {
|
||||||
|
"project": project,
|
||||||
|
"story": story,
|
||||||
|
"story_raw": story,
|
||||||
|
"note": note,
|
||||||
|
"start": "09:00",
|
||||||
|
"end": "10:00" if duration_hours is not None else None,
|
||||||
|
"duration_hours": duration_hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestToCsvEntries:
|
||||||
|
def test_basic_conversion(self):
|
||||||
|
rows = [_raw_row("bugs", "ticket 1", "", 1.0)]
|
||||||
|
assert to_csv_entries(rows) == [
|
||||||
|
{"project": "bugs", "description": "ticket 1", "quantity": 1.0}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_skips_open_entries(self):
|
||||||
|
rows = [_raw_row("bugs", "ticket 1", "", None)]
|
||||||
|
assert to_csv_entries(rows) == []
|
||||||
|
|
||||||
|
def test_does_not_aggregate(self):
|
||||||
|
rows = [
|
||||||
|
_raw_row("bugs", "ticket 1", "", 0.5),
|
||||||
|
_raw_row("bugs", "ticket 1", "", 0.5),
|
||||||
|
]
|
||||||
|
entries = to_csv_entries(rows)
|
||||||
|
assert len(entries) == 2
|
||||||
|
assert entries[0]["quantity"] == 0.5
|
||||||
|
assert entries[1]["quantity"] == 0.5
|
||||||
|
|
||||||
|
def test_description_combines_story_and_note(self):
|
||||||
|
rows = [_raw_row("bugs", "ticket 1", "fix", 1.0)]
|
||||||
|
assert to_csv_entries(rows)[0]["description"] == "ticket 1 - fix"
|
||||||
|
|
||||||
|
def test_project_stripped(self):
|
||||||
|
rows = [_raw_row(" bugs ", "", "dsu", 0.25)]
|
||||||
|
assert to_csv_entries(rows)[0]["project"] == "bugs"
|
||||||
|
|
||||||
|
def test_mixed_open_and_closed(self):
|
||||||
|
rows = [
|
||||||
|
_raw_row("bugs", "ticket 1", "", 1.0),
|
||||||
|
_raw_row("bugs", "ticket 2", "", None),
|
||||||
|
_raw_row("scrum", "", "dsu", 0.25),
|
||||||
|
]
|
||||||
|
entries = to_csv_entries(rows)
|
||||||
|
assert len(entries) == 2
|
||||||
|
assert entries[0]["description"] == "ticket 1"
|
||||||
|
assert entries[1]["description"] == "dsu"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# write_csv
|
# write_csv
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue