From 985ee28113859e39a1c7ef8df743a7b04f4578e8 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Thu, 28 May 2026 13:07:50 +0200 Subject: [PATCH] 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 --- .coverage | Bin 53248 -> 0 bytes .gitignore | 4 +++ README.md | 1 + src/timesheets/cli.py | 16 +++++++++-- src/timesheets/output.py | 20 +++++++++++++ tests/test_output.py | 60 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 97 insertions(+), 4 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 7f4642487670ed6420f012ccbb4d9d0e03a865dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI4UyK_^9mi*PZLjxo_5>Zl3YT!h1G=1g=j(|`G)>?xrGcgmg0^XeR8(hekMH)d zz0U4B$$?Hem!Kk$c;OYP5~6+XLm!ZeNKl19Kxm`9Afbp#NKKGvD%4O>()Rex{_&qA zSM@AKy`H`+duMiLezU*dXMQuYvup3;j~;UZDQd3QumkZ$wv%ZZTM&XVX3;lC-()i> zl1dIJ)cWEZMJ-nR-pL$)l8t7*&G^%~&+?hk#nFH7`1$C4))PA%bJ{9W1~w1?0T2Lz zZ9|~*=%`uPy<2-`HL%Nd8F+R@deJ`j)yIw;K7K?TKm5S4BO*!@`$hz9Gc)3_@Z8g) zB|TAd>QXq(s#CE8r@1JCC7G0N`?9L$EJ~wUOBF1J@+wwxsw5T2MXI9ZISt!e5#N<7 z(_w-5Y=JxzLgvLyX!VRtR8xTNQqAhOnT8?%8c>P|B>v*|m5<2Hpn zBP;DdR_|!ww1Spw1h3y3s_TR@=tLwLpR12fPxua!aq(EVM#?qa1Zz)FotlJ0oty1O znUF8H8!gp1BoUoBa5s|NNH%a|V?T1++@URd_6{^>f?Ty|B-L&@C)={y_|u{%IUSnO zbf<+ff;`|0J2Hg>V;Uo`lxO^tb-Hva+3mm$k4x0}r8(M-p8HhREKE#j&z=k&Md2xx zoxtx#tO2L#CU4+6Dt}|YQN3i)NmMVY*M(aPyNr?+QR9#|_xG9E1cvRYjwal=AZhxs zdYe*~bP(mbTORba&|`c_9fZb$q*$%ptf;QyD;p~ADe2jZvKZ$^!@|eyNaQ+mR;F-d zVk6h-x-SOdZT30 zN8%hZXU5DF4v%kSPF$`u7e}%kGi4Tb?b13?k5J>1`lbhrP$|7GaLYvS(baF+!IHRL zca=^hAmE!**^ z%hDJe%>Y6btlCuAF1zg@^j~!tx~H0Zdiq8c^o|quRB9?LPTl)kuJ1cEt*U9uzduP8 zlS{U*Mj?MvlwG$j?PfGLki=1+&gu9f)ZzA7X}(v7zoP0`JT)2WluRyam_JUPF6mzs zX*BF+HJ-23G#u4RW54R0>O9dPLV{wFI;Brt^p-l4O5{$lo`*Zp<+aA=~k_xY96ATWDJe0nVK{{ z%y(21m*&)7t&+3qQm!+hnT31q(K_w82SO5T~aE(KpL4+3w({=Wc0xX0w4eaAOHd&00JNY0w4eaAOHd& zu>A-any#5@{jc-a8NW_DY#;yvAOHd&00JNY0w4eaAOHd&00N�9UImnT09xp${# zjPH(r1MsEd-2URdBV?7%uQ7g&|K}5FAe06H5C8!X009sH0T2KI5C8!X009u_5*Ri1 zYRPQ?eZ&~I;=2Iq`M;HWjq&sR9RC{6=6|36asF67Pf6H700ck)1V8`;KmY_l00ck) z1hz1NLzcnL&MvtPIlH{<%~s{9S-;{rt-zmE|B3gPq@<|tRR#_NJ81j#m-|E6A*7ex z7QM4VNt;7Rw`|Xs9!Vd}7;JU3nzsX|t`wh7r&!0>taQtBmt}?Cc0#(RhR~hf=p{|693Bj9=hC|mO>3U4|1%4zj- zd`iE0asK>I-@iP*{?O}VzdrxYfso(+-@db|eLhV^A58t`&u^`>>sQ&r%jb2SwHV7& zkv;Toe#Wx-6czbgR-eAOs{iWctGS#($9oL@!t>S#&pr0Qew_}N^`{rsPKc;o)}kP_ zz}5`L?xUlWswk{2%+i=hYbndhkA(mK=dUn+li%QP^1t&-bRXca{0;ucupo9I00JNY z0w4eaAOHd&00JNY0w4eaTZ4eQ*PvyrFtD;=kT%1>%!ENI9R}G{7#Kzv=z5B70EEx~ z+0LzD2($nJ5C8!X009sH0T2KI5C8!X009uVI|S78f2{xS4qMa;0w4eaAOHd&00JNY v0w4eaAOHeeg@F41Ki2G9RmLchiooc diff --git a/.gitignore b/.gitignore index efc3959..7ce0ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ wheels/ timesheets.toml timesheets.csv + +# Test coverage +.coverage +htmlcov/ diff --git a/README.md b/README.md index a8bfb04..4f1c929 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ uv run timesheets summary -w -ss --joplin # weekly totals only: one line ```sh uv run timesheets csv --joplin # stdout uv run timesheets csv --joplin -o output.csv # write to file +uv run timesheets csv --raw --joplin # one row per entry, no aggregation ``` ### stories diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index b44290e..2364d9c 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -19,6 +19,7 @@ from .output import ( print_summary_weekly, print_summary_weekly_short, print_summary_weekly_totals, + to_csv_entries, write_csv, ) from .parser import ( @@ -130,6 +131,15 @@ def build_parser() -> argparse.ArgumentParser: help="Export timesheet entries as CSV.", ) _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", @@ -389,15 +399,15 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None: rows = _resolve_rows(args, config, target_date) if not rows: 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) if args.output: 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) else: - write_csv(aggregated, sys.stdout, date_str, project_map) + write_csv(entries, sys.stdout, date_str, project_map) # --------------------------------------------------------------------------- diff --git a/src/timesheets/output.py b/src/timesheets/output.py index c1bbf98..925b672 100644 --- a/src/timesheets/output.py +++ b/src/timesheets/output.py @@ -11,6 +11,26 @@ from .projects import resolve_project_task 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( aggregated: list[dict], output: IO[str], diff --git a/tests/test_output.py b/tests/test_output.py index ecda4b9..a315016 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -3,7 +3,7 @@ import io 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 @@ -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 # ---------------------------------------------------------------------------