Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ outputs/

# Sphinx build outputs
docs/build/

# Local BYOD projects (generated)
/byod_*/

# Local BYOD projects (generated)
/pystatsv1_trackd_byod/
43 changes: 43 additions & 0 deletions src/pystatsv1/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,27 @@ def cmd_trackd_validate(args: argparse.Namespace) -> int:
return 0



def cmd_trackd_byod_init(args: argparse.Namespace) -> int:
# Keep CLI wiring lightweight: creation logic lives in pystatsv1.trackd.byod.
from pystatsv1.trackd import TrackDDataError
from pystatsv1.trackd.byod import init_byod_project

try:
root = init_byod_project(args.dest, profile=args.profile, force=args.force)
except TrackDDataError as e:
print(str(e))
return 1

print(
textwrap.dedent(
f"""\
✅ Track D BYOD project created at:\n
{root}\n
Next steps:\n 1) cd {root}\n 2) Fill in the required CSVs in tables/\n 3) pystatsv1 trackd validate --datadir tables --profile {args.profile}\n """
).rstrip()
)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="pystatsv1",
Expand Down Expand Up @@ -517,6 +538,28 @@ def build_parser() -> argparse.ArgumentParser:
)
p_td_validate.set_defaults(func=cmd_trackd_validate)

p_td_byod = td_sub.add_parser("byod", help="Bring-your-own-data (BYOD) project helpers.")
byod_sub = p_td_byod.add_subparsers(dest="trackd_byod_cmd", required=True)

p_byod_init = byod_sub.add_parser("init", help="Create a BYOD project folder with CSV header templates.")
p_byod_init.add_argument(
"--dest",
default="pystatsv1_trackd_byod",
help="Destination directory to create (default: pystatsv1_trackd_byod).",
)
p_byod_init.add_argument(
"--profile",
default="core_gl",
choices=["core_gl", "ar", "full"],
help="Which profile to scaffold (default: core_gl).",
)
p_byod_init.add_argument(
"--force",
action="store_true",
help="Allow writing into an existing non-empty directory.",
)
p_byod_init.set_defaults(func=cmd_trackd_byod_init)


return p

Expand Down
124 changes: 124 additions & 0 deletions src/pystatsv1/trackd/byod.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# SPDX-License-Identifier: MIT
"""Bring-your-own-data (BYOD) helpers for Track D.

Phase 2 foundation:
- create a local BYOD project folder
- generate header-only CSV templates from the canonical Track D contracts

Design: avoid shipping a second "header pack" contract.
We generate templates directly from :mod:`pystatsv1.trackd.schema` so the
single source of truth stays in one place.
"""

from __future__ import annotations

import csv
import textwrap
from pathlib import Path

from ._errors import TrackDDataError
from ._types import PathLike
from .contracts import ALLOWED_PROFILES, schemas_for_profile


def init_byod_project(dest: PathLike, *, profile: str = "core_gl", force: bool = False) -> Path:
"""Create a Track D BYOD project folder.

The project layout is intentionally simple:

- tables/ student-edited CSVs (header templates created here)
- raw/ optional dumps from source systems
- normalized/ adapter outputs (generated)
- notes/ assumptions, mapping notes, and QA

Parameters
----------
dest:
Destination folder to create.
profile:
One of: core_gl, ar, full.
force:
Allow writing into an existing non-empty directory.

Returns
-------
Path
The created project root.

Raises
------
TrackDDataError
If *dest* is non-empty and *force* is False, or if *profile* is invalid.
"""

root = Path(dest).expanduser().resolve()

if root.exists() and any(root.iterdir()) and not force:
raise TrackDDataError(
f"Refusing to write into non-empty directory: {root}\n"
"Use --force to overwrite into an existing directory."
)

p = (profile or "").strip().lower()
try:
schemas = schemas_for_profile(p)
except ValueError as e:
raise TrackDDataError(
f"Unknown profile: {profile}.\n" f"Use one of: {', '.join(ALLOWED_PROFILES)}"
) from e

# Create core folders
root.mkdir(parents=True, exist_ok=True)
(root / "tables").mkdir(parents=True, exist_ok=True)
(root / "raw").mkdir(parents=True, exist_ok=True)
(root / "normalized").mkdir(parents=True, exist_ok=True)
(root / "notes").mkdir(parents=True, exist_ok=True)

# Write header-only CSV templates into tables/
for schema in schemas:
out = root / "tables" / schema.name
with out.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(list(schema.required_columns))

# Tiny config (write-only for now)
config = textwrap.dedent(
f"""\
# Track D BYOD project config
[trackd]
profile = "{p}"
tables_dir = "tables"
"""
).lstrip()
(root / "config.toml").write_text(config, encoding="utf-8")

readme = textwrap.dedent(
f"""\
# Track D — Bring Your Own Data (BYOD)

This folder is a starter project for using your own accounting data with Track D.

## What to edit

- `tables/` contains **student-edited** CSVs.
Header-only templates are generated from the Track D contract.

## What not to edit

- `normalized/` is for **generated** outputs from future adapters.

## Quickstart

1) Fill in the required CSVs under `tables/`.
2) Validate your dataset:

```bash
pystatsv1 trackd validate --datadir tables --profile {p}
```

If validation fails, fix the missing files/columns and re-run.
"""
).lstrip()

(root / "README.md").write_text(readme, encoding="utf-8")
return root
2 changes: 1 addition & 1 deletion src/pystatsv1/trackd/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def _format_report(report: dict[str, Any]) -> str:

lines += [
"Fix: export the required CSV(s) and ensure the header names match the Track D contract.",
"Tip: compare your exported CSV headers against the workbook downloads.",
"Tip: generate a header-only template pack with: pystatsv1 trackd byod init --dest my_byod --profile <profile>",
]
return "\n".join(lines)

Expand Down
41 changes: 41 additions & 0 deletions tests/test_trackd_byod_init_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from pathlib import Path

from pystatsv1.cli import main


def test_trackd_byod_init_creates_structure_and_headers(tmp_path: Path, capsys) -> None:
dest = tmp_path / "byod"

rc = main(["trackd", "byod", "init", "--dest", str(dest), "--profile", "core_gl"])
out = capsys.readouterr().out

assert rc == 0
assert "BYOD project created" in out

assert (dest / "tables").is_dir()
assert (dest / "raw").is_dir()
assert (dest / "normalized").is_dir()
assert (dest / "notes").is_dir()
assert (dest / "config.toml").exists()
assert (dest / "README.md").exists()

coa = (dest / "tables" / "chart_of_accounts.csv").read_text(encoding="utf-8").splitlines()[0]
gl = (dest / "tables" / "gl_journal.csv").read_text(encoding="utf-8").splitlines()[0]

assert coa == "account_id,account_name,account_type,normal_side"
assert gl == "txn_id,date,doc_id,description,account_id,debit,credit"


def test_trackd_byod_init_refuses_non_empty_dir_without_force(tmp_path: Path, capsys) -> None:
dest = tmp_path / "byod"
dest.mkdir()
(dest / "keep.txt").write_text("x", encoding="utf-8")

rc = main(["trackd", "byod", "init", "--dest", str(dest), "--profile", "core_gl"])
out = capsys.readouterr().out

assert rc == 1
assert "Refusing to write into non-empty directory" in out
assert "--force" in out