From ba4aa689f5d933dea9fa5f1facb2fdf440cd783e Mon Sep 17 00:00:00 2001 From: David Flagg Date: Mon, 16 Mar 2026 11:14:57 -0400 Subject: [PATCH 1/2] feat: add --json output, fix config crash, extract validation utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bounty fixes in one cohesive change: **Issue #1 ($50) — Add --json output format** - Added --json flag to all subcommands (add, list, done) - list --json outputs valid JSON array of tasks - add/done --json output status confirmation objects **Issue #2 ($75) — Fix crash when config file missing** - load_config() now creates default config if missing instead of crashing - Default config matches config.yaml.example values - Parent directories created automatically **Issue #3 ($100) — Extract validation to utils module** - Created commands/utils.py with shared functions: validate_description(), validate_task_id(), get_tasks_file(), load_tasks(), save_tasks() - Removed duplicated get_tasks_file() from all command modules - Removed scattered validation functions from individual commands - All commands now import from utils Tests: 8 tests covering validation, config creation, JSON output, and save/load round-trips. All passing. Fixes #1, Fixes #2, Fixes #3 --- __pycache__/task.cpython-312.pyc | Bin 0 -> 3478 bytes .../test_task.cpython-312-pytest-8.2.2.pyc | Bin 0 -> 13740 bytes commands/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 203 bytes commands/__pycache__/add.cpython-312.pyc | Bin 0 -> 776 bytes commands/__pycache__/done.cpython-312.pyc | Bin 0 -> 879 bytes commands/__pycache__/list.cpython-312.pyc | Bin 0 -> 977 bytes commands/__pycache__/utils.cpython-312.pyc | Bin 0 -> 2038 bytes commands/add.py | 28 +----- commands/done.py | 23 +---- commands/list.py | 47 ++++------ commands/utils.py | 40 ++++++++ task.py | 32 ++++++- test_task.py | 87 +++++++++++++++++- 13 files changed, 178 insertions(+), 79 deletions(-) create mode 100644 __pycache__/task.cpython-312.pyc create mode 100644 __pycache__/test_task.cpython-312-pytest-8.2.2.pyc create mode 100644 commands/__pycache__/__init__.cpython-312.pyc create mode 100644 commands/__pycache__/add.cpython-312.pyc create mode 100644 commands/__pycache__/done.cpython-312.pyc create mode 100644 commands/__pycache__/list.cpython-312.pyc create mode 100644 commands/__pycache__/utils.cpython-312.pyc create mode 100644 commands/utils.py diff --git a/__pycache__/task.cpython-312.pyc b/__pycache__/task.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f834ce620f11e45411f612c53e2ab936e02d954 GIT binary patch literal 3478 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

KEF5p7hOb|2q%!aIHd4>s zE;UjoE}!^ROe~3A4Y6xU>}!a9*T?UOeT!n6756m6o@>KD+y2h>Pm^ue``#-wD+M;~h)!Esgk=PvXi_ ze7F%GzC3X+ntW^Q&9STJK8bF=JpMrJS^E8dABek`9_Rb9xch-Pxb!&RkHx`9aR}hC zNifaBUpyT@$qB#Yq?3_mMb7c&CP1BQRpekE~Eg@uL!R z80gsyx2Q%DEoImA^Yq%IW~!0fzoQ1(Z9&m-uD~88s77uJAA@pb3s6iVYqxQg7!uv^ z$h$m!`i$J2S5TqW9I;yyMjBnGuB$?=-KX;;k(d=al+lmtQK1kAS@N6_om4H!SA`0J z;vl+e)O@Jb?7hlgIb)G$vbN2aJPYMnZQcyQA&+K^Xa$%KtvxVu>0iJw=vfp;L7b+O zAWcRCZaBG0IBpdmA_ISLA!4hQ0lF%l33gkqz+v^0e0}?0sRAUXz368T@;7dN(u{uk=*|-erybaYtn?c3Ix9adPpk- z6BbbW@3p0;obZ+nWla`z6o|ozpto4i;ahRH4S2EO(?kqOn3tAhL#)3AO_Fu+{OJOO zuTaB(XYKergnUrLPhks>mD6FL($BBwfz)=*ftnc~fU!r)Jb-SWJ{GkLlmMx2O&=0) zYXYRUlMc&%3X)FJr3Z`V%E6F^J}Z_yy(w0aXQPMg*|)b~KP3(YFp75c7xWC7o% z+A|FJrsu#u*oi7>{%omW+YjK>DwULrReD~{&_9Q>(I23A0R@7e*IbCdG&iqI(duM@ z77OYG&0!@698GMjb^e+117$R$G}ZMu0U5qDI|0+F7KPfxv03b1M1e1y2jyIBL!W^s z>cIjYt_M)|#Wtn~Gi~|X*qXTkk=|`(*t?RzU6`9=(?{!2@eKUbB@o|;BE0qXwubz| z=RyG0-vFw=0Z@NoaaWW2J9tlSU)XCE5pr88tKzw%T>wfQw&mz7J;zEK!OUE3D zj~@`;KOl{NYn2ehttK7QyGnpSjQ^HkLjwa5SGyF!kq2>oaOF8b1OVg&9_-f;v{lEw z=DOVL0ucc+1YLAoBfrzoeaVuab_tpxf<_qIxS-LbiTUW0_9&QU==R@cpUK^7j>c=h zl%N^9Eqok1p(5ANI+63>oSq*VO`e4?8(4|?j_Giee4c+GH{`L zJGOD33{M-Md#5`ttaa)+4nGIAV?}U=EK&6{cfhlvVUrTGtG=zGmi`XE2xgwrT#kz6Zt`Z z9cj^DMQINr_2|3bzx};N=g~VIBCOagd&BJ*-Y_~(f9az`jVWQ20?mz2p|&XDaT;*58Vo}9U?J7ca8z;*kyz#@1? z*eNnXR+(v*Fe_yMrV(R_+H|RSmWB6>_*|u^;SI=?tCQ#1Q(+{UosDcIySV}p>A`$n z@X0Y^PDS`<4aN7}h&x5d<4lY2lri5XM%&@v1>gZ?D=FB@Znj*IElOa)KIZ&lHVk8b z_8{Eg9zsKoJu}Pl^W`d;D;55TUWGFBVyLK8d@k@j|8HE+Z#nJ_?r);M;^OzY?)%)< z2VC(kSG>;++~^M96-i&h7^Von5mnd31k%OmZTP!=$0fFrKgtYCqv9G){l?R%*!l^kJl@x{Ka7dvC6K94QL$5 VUBw_bePCu}WW2?oT*Lz8000H5HpKt{ literal 0 HcmV?d00001 diff --git a/commands/__pycache__/add.cpython-312.pyc b/commands/__pycache__/add.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..997bae095841dafa4d567132c4203d80e98d32b8 GIT binary patch literal 776 zcmYjPJ#5oJ6uz_1ag)$M0+b2K3za-1p)m0yBqV-@L@f(T7R$;zid+9g_9ZC6k%ELo zQI~FwRFJ}m%D~9T&H`$Y;M9#s6&q5vPTaXR;Yoh)yZ7Ebz3)4JSzIh2VaJz0Iqxt+ zzvLk^B?Yr(0d^6f76J^4i#?^Kcxp@aNQ-z{OT!4%m#CHN6H^U;CZ$W zwclbeG6^rOQ)bCZWIb_Q$_s8_!)bySBD+UZe4%H%4%m!ZK%;i(beI$PZ8;>foB*o$ zW*>yxMeJWKM*ArqA|^W$+fU;VGcAQE##txefk+OO7;orVbsl3SMxcQDM$P&$V)?8- zpCYowEDI!7_7G?>+Cy*2{9Zq2>VvaVAKUQhvqYs@C}ny8xg^&Ty}2%f3Nu{TVZLyv_1xi@*wsuK28}_{j2`gCW zL1_=UDTv^)#Y>O=54}W8K^G5&3Z*w`3I$J{w~5AqH}huZn|ZU}?6<)|4N(524(v$@ z;HPjd1^rMu_t9(tg*;F~r804(yyVDv*-`Qe0V;ohyjoY%YGdrR>6Hv_vXVjFie(gC z$1y83^P6CYv>a$<*Q5oJXMx7dRjYSTu9{_=n!M{5Y+4irmlDH^jHlB^wFUetx8Fmo z5uY%f3rr}K?DsGP>JnEk<390WzxP#Z;FBD@c>=H|`4W}K5#&SZ5db5fbgE!Xox>Q9 z_N)6{ue}xVVFzdw@iEb32X>V{PQ0(cHS|D;)^_DSrY@!9joTq}W|upY3FfM0i(8q- z(2^TQneo=GRp{hWAlY;|Pz1pZU6fh0Th`DFtQ7GXBZyOr6+OGkZMV`;m&HmjUvYW2 zO^*6_=={^~>@K96IEzX?#ajrrzObA0=8OMCEL zdDy`rU@095q6HkUSSSQ)joW1wauo8?Wk^i@1-v90FM;YJHs`psR<@pdH_;V89UDP) VE)zmdAoUAo4`H?wNs_6s=s&3t+d%*T literal 0 HcmV?d00001 diff --git a/commands/__pycache__/list.cpython-312.pyc b/commands/__pycache__/list.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d360015fa9931287dfda9819bca125b7df6238ab GIT binary patch literal 977 zcmaJ<%}X0W6rb6z#AIVN8mWS16-$YbEEL*9zrZ$n5)alwNs+K*R=aVto9@gO3=ur^ zkW!%$3xm=;#Kc%V2iYha=f4XU<$$ z$ID3_uWOgVZX2azAt=H7pxck&-#Zk2yu15<^b+Ob6TjPL=^q@J0*jXcSmR0@5{BtW z4u?bwB5959;-f%`e*;jE*7UF%>W8QwKKI{AUCfOufF$%=6t+?O_w9pd^j=@^lIi85zd;!M=FVH^O6a6Rl1ZF%5 z6OFhSvqptV>xTN+&g&}Do*GC^>3D_M1ywT=%La9Dg6Nneh~_NoL~VZ~tg^c$3#;@8 zQRn;zIWY;bWnUx4eGhUL`3rSaPJ z*XHJfJ-$3c!7EdhrOLzVd`+k=dSWG1}o_A-I+-5i908g z*i%0+_-w8ofqaN?LNE{B1+j)Wmb)PGL7>3~JU8n)!LEi7QKSq>sR}uTl41AUxXJZ0 z+@o_xh5*4>wwZP#Y1_#ewijk1ZZ{^vOXY0KjSG#X^UNtF6)K|f)LEx!{)!I7g)c@j zVwEAryhSo&CM$5KoH4A_j0Flyag*^BH|UJXGdZVNG)jbJ1QIM=Ui1>Mos}14MHo~t z6NSlM1+k3&(N29c^kC@gk%t{s?c(})ReNnCU)8QZ8LevL6?L4(;Mg(5fKVT?Wl$Os z3()xzEup;likC)UXUYs9#lwL)9|^RhCwOQ&S_N9*VYAK>;EmlIcN~KRKvSpaqpg2v z@;O>!x>+pqMcuX-*R6ZfrW40xrORA57hpG?3`eLwYo z+rR$tYo3^Z+bq~Ix$&}T4m7#({2~Eh+w7Xj8e(r`d!m6guUM&TDPz91a}&@8AHXNXr z;L|T`uhkNRmDu2JG=6`i7Cray($nY#X}D2K+^ocI?r80P)zq|} zirV9IP|sa(gpdkTa&Q|I-~h6_0CtvG!be;jp)N5`9PvSvCHx)wL1{o91G*u%<2bq# zV}7e(={-@IZtFxzey0C}nHU?^p>6A6p}inL7vJhdMSf;2ax6Jp4)Lj5>18 200: - raise ValueError("Description too long (max 200 chars)") - return description.strip() +from .utils import get_tasks_file, load_tasks, save_tasks, validate_description def add_task(description): """Add a new task.""" description = validate_description(description) - tasks_file = get_tasks_file() - tasks_file.parent.mkdir(parents=True, exist_ok=True) - - tasks = [] - if tasks_file.exists(): - tasks = json.loads(tasks_file.read_text()) - + tasks = load_tasks() task_id = len(tasks) + 1 tasks.append({"id": task_id, "description": description, "done": False}) - tasks_file.write_text(json.dumps(tasks, indent=2)) + save_tasks(tasks) print(f"Added task {task_id}: {description}") diff --git a/commands/done.py b/commands/done.py index c9dfd42..47d9aec 100644 --- a/commands/done.py +++ b/commands/done.py @@ -1,36 +1,21 @@ """Mark task done command.""" -import json -from pathlib import Path - - -def get_tasks_file(): - """Get path to tasks file.""" - return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" - - -def validate_task_id(tasks, task_id): - """Validate task ID exists.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) - if task_id < 1 or task_id > len(tasks): - raise ValueError(f"Invalid task ID: {task_id}") - return task_id +from .utils import load_tasks, save_tasks, validate_task_id def mark_done(task_id): """Mark a task as complete.""" - tasks_file = get_tasks_file() - if not tasks_file.exists(): + tasks = load_tasks() + if not tasks: print("No tasks found!") return - tasks = json.loads(tasks_file.read_text()) task_id = validate_task_id(tasks, task_id) for task in tasks: if task["id"] == task_id: task["done"] = True - tasks_file.write_text(json.dumps(tasks, indent=2)) + save_tasks(tasks) print(f"Marked task {task_id} as done: {task['description']}") return diff --git a/commands/list.py b/commands/list.py index 714315d..5b29413 100644 --- a/commands/list.py +++ b/commands/list.py @@ -1,37 +1,26 @@ """List tasks command.""" import json -from pathlib import Path +from .utils import load_tasks -def get_tasks_file(): - """Get path to tasks file.""" - return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" - -def validate_task_file(): - """Validate tasks file exists.""" - # NOTE: Validation logic scattered here - should be in utils (refactor bounty) - tasks_file = get_tasks_file() - if not tasks_file.exists(): - return [] - return tasks_file - - -def list_tasks(): - """List all tasks.""" - # NOTE: No --json flag support yet (feature bounty) - tasks_file = validate_task_file() - if not tasks_file: - print("No tasks yet!") - return - - tasks = json.loads(tasks_file.read_text()) +def list_tasks(as_json=False): + """List all tasks. Returns task list if as_json is True.""" + tasks = load_tasks() if not tasks: - print("No tasks yet!") - return - - for task in tasks: - status = "✓" if task["done"] else " " - print(f"[{status}] {task['id']}. {task['description']}") + if as_json: + print(json.dumps([])) + else: + print("No tasks yet!") + return tasks + + if as_json: + print(json.dumps(tasks, indent=2)) + else: + for task in tasks: + status = "✓" if task["done"] else " " + print(f"[{status}] {task['id']}. {task['description']}") + + return tasks diff --git a/commands/utils.py b/commands/utils.py new file mode 100644 index 0000000..6c7166f --- /dev/null +++ b/commands/utils.py @@ -0,0 +1,40 @@ +"""Shared validation utilities for task CLI.""" + +import json +from pathlib import Path + + +def get_tasks_file(): + """Get path to tasks file.""" + return Path.home() / ".local" / "share" / "task-cli" / "tasks.json" + + +def load_tasks(): + """Load tasks from file. Returns empty list if file doesn't exist.""" + tasks_file = get_tasks_file() + if not tasks_file.exists(): + return [] + return json.loads(tasks_file.read_text()) + + +def save_tasks(tasks): + """Save tasks to file.""" + tasks_file = get_tasks_file() + tasks_file.parent.mkdir(parents=True, exist_ok=True) + tasks_file.write_text(json.dumps(tasks, indent=2)) + + +def validate_description(description): + """Validate task description.""" + if not description: + raise ValueError("Description cannot be empty") + if len(description) > 200: + raise ValueError("Description too long (max 200 chars)") + return description.strip() + + +def validate_task_id(tasks, task_id): + """Validate task ID exists.""" + if task_id < 1 or task_id > len(tasks): + raise ValueError(f"Invalid task ID: {task_id}") + return task_id diff --git a/task.py b/task.py index 53cc8ed..3265177 100644 --- a/task.py +++ b/task.py @@ -11,9 +11,22 @@ def load_config(): - """Load configuration from file.""" + """Load configuration from file. Creates default if missing.""" config_path = Path.home() / ".config" / "task-cli" / "config.yaml" - # NOTE: This will crash if config doesn't exist - known bug for bounty testing + if not config_path.exists(): + config_path.parent.mkdir(parents=True, exist_ok=True) + default_config = ( + "# Task CLI configuration\n" + "storage:\n" + " format: json\n" + " max_tasks: 1000\n" + "\n" + "display:\n" + " color: true\n" + " unicode: true\n" + ) + config_path.write_text(default_config) + print(f"Created default config at {config_path}") with open(config_path) as f: return f.read() @@ -22,25 +35,38 @@ def main(): parser = argparse.ArgumentParser(description="Simple task manager") subparsers = parser.add_subparsers(dest="command", help="Command to run") + # Shared --json flag on each subcommand + json_arg = {"flags": ["--json"], "action": "store_true", "help": "Output in JSON format"} + # Add command add_parser = subparsers.add_parser("add", help="Add a new task") add_parser.add_argument("description", help="Task description") + add_parser.add_argument(*json_arg["flags"], action=json_arg["action"], help=json_arg["help"]) # List command list_parser = subparsers.add_parser("list", help="List all tasks") + list_parser.add_argument(*json_arg["flags"], action=json_arg["action"], help=json_arg["help"]) # Done command done_parser = subparsers.add_parser("done", help="Mark task as complete") done_parser.add_argument("task_id", type=int, help="Task ID to mark done") + done_parser.add_argument(*json_arg["flags"], action=json_arg["action"], help=json_arg["help"]) args = parser.parse_args() + use_json = args.json if args.command == "add": add_task(args.description) + if use_json: + import json + print(json.dumps({"status": "added", "description": args.description})) elif args.command == "list": - list_tasks() + list_tasks(as_json=use_json) elif args.command == "done": mark_done(args.task_id) + if use_json: + import json + print(json.dumps({"status": "done", "task_id": args.task_id})) else: parser.print_help() diff --git a/test_task.py b/test_task.py index ba98e43..7afd840 100644 --- a/test_task.py +++ b/test_task.py @@ -1,12 +1,18 @@ -"""Basic tests for task CLI.""" +"""Tests for task CLI.""" import json import pytest from pathlib import Path -from commands.add import add_task, validate_description -from commands.done import validate_task_id +from unittest.mock import patch +from commands.utils import validate_description, validate_task_id, get_tasks_file, load_tasks, save_tasks +from commands.add import add_task +from commands.list import list_tasks +from commands.done import mark_done +from task import load_config +# --- Validation tests (Issue #3: utils module) --- + def test_validate_description(): """Test description validation.""" assert validate_description(" test ") == "test" @@ -28,3 +34,78 @@ def test_validate_task_id(): with pytest.raises(ValueError): validate_task_id(tasks, 99) + + +# --- Config crash fix tests (Issue #2) --- + +def test_load_config_creates_default(tmp_path): + """Test that load_config creates default config when missing.""" + config_path = tmp_path / ".config" / "task-cli" / "config.yaml" + with patch.object(Path, 'home', return_value=tmp_path): + config = load_config() + assert config_path.exists() + assert "storage:" in config + assert "display:" in config + + +def test_load_config_reads_existing(tmp_path): + """Test that load_config reads existing config.""" + config_path = tmp_path / ".config" / "task-cli" / "config.yaml" + config_path.parent.mkdir(parents=True) + config_path.write_text("custom: true\n") + with patch.object(Path, 'home', return_value=tmp_path): + config = load_config() + assert "custom: true" in config + + +# --- JSON output tests (Issue #1) --- + +def test_list_tasks_json(tmp_path, capsys): + """Test JSON output for list command.""" + tasks_file = tmp_path / ".local" / "share" / "task-cli" / "tasks.json" + tasks_file.parent.mkdir(parents=True) + tasks_file.write_text(json.dumps([ + {"id": 1, "description": "Test task", "done": False}, + {"id": 2, "description": "Done task", "done": True}, + ])) + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + list_tasks(as_json=True) + output = capsys.readouterr().out + data = json.loads(output) + assert len(data) == 2 + assert data[0]["description"] == "Test task" + assert data[1]["done"] is True + + +def test_list_tasks_json_empty(tmp_path, capsys): + """Test JSON output for empty task list.""" + tasks_file = tmp_path / "nonexistent" / "tasks.json" + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + list_tasks(as_json=True) + output = capsys.readouterr().out + assert json.loads(output) == [] + + +def test_list_tasks_human(tmp_path, capsys): + """Test human-readable output still works.""" + tasks_file = tmp_path / ".local" / "share" / "task-cli" / "tasks.json" + tasks_file.parent.mkdir(parents=True) + tasks_file.write_text(json.dumps([ + {"id": 1, "description": "Buy groceries", "done": False}, + ])) + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + list_tasks(as_json=False) + output = capsys.readouterr().out + assert "[ ] 1. Buy groceries" in output + + +# --- Integration tests (utils shared across commands) --- + +def test_save_and_load_tasks(tmp_path): + """Test save/load round-trip through shared utils.""" + tasks_file = tmp_path / "tasks.json" + with patch('commands.utils.get_tasks_file', return_value=tasks_file): + save_tasks([{"id": 1, "description": "Test", "done": False}]) + loaded = load_tasks() + assert len(loaded) == 1 + assert loaded[0]["description"] == "Test" From 70470434070595ede8958dd1644eb4c460dd047d Mon Sep 17 00:00:00 2001 From: David Flagg Date: Mon, 16 Mar 2026 11:15:04 -0400 Subject: [PATCH 2/2] chore: add .gitignore, remove cached bytecode --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/