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
1 change: 1 addition & 0 deletions src/pystatsv1/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ def cmd_trackd_byod_normalize(args: argparse.Namespace) -> int:
f"""\
Track D BYOD normalization complete.

Adapter: {report.get('adapter')}
Profile: {report.get('profile')}
Project: {report.get('project')}
Input tables: {report.get('tables_dir')}
Expand Down
6 changes: 6 additions & 0 deletions src/pystatsv1/trackd/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: MIT
"""Track D BYOD adapter interfaces.

Adapters live under :mod:`pystatsv1.trackd.adapters` and are used by the
BYOD normalization pipeline.
"""
34 changes: 34 additions & 0 deletions src/pystatsv1/trackd/adapters/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# SPDX-License-Identifier: MIT
"""Adapter interface for Track D BYOD normalization.

The long-term goal is to support multiple data sources (Sheets-first, then
system exports like QuickBooks) without changing the downstream pipeline.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol


@dataclass(frozen=True)
class NormalizeContext:
"""Context passed to a BYOD adapter during normalization."""

project_root: Path
profile: str
tables_dir: Path
raw_dir: Path
normalized_dir: Path


class TrackDAdapter(Protocol):
"""Protocol for BYOD adapters."""

name: str

def normalize(self, ctx: NormalizeContext) -> dict[str, Any]:
"""Normalize project inputs into canonical ``normalized/`` outputs."""

...
78 changes: 57 additions & 21 deletions src/pystatsv1/trackd/byod.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from ._errors import TrackDDataError
from ._types import PathLike
from .adapters.base import NormalizeContext, TrackDAdapter
from .contracts import ALLOWED_PROFILES, schemas_for_profile


Expand All @@ -30,6 +31,7 @@ def _read_trackd_config(project_root: Path) -> dict[str, str]:

- [trackd].profile
- [trackd].tables_dir
- [trackd].adapter

Notes
-----
Expand Down Expand Up @@ -57,7 +59,7 @@ def _read_trackd_config(project_root: Path) -> dict[str, str]:
k, v = line.split("=", 1)
key = k.strip()
val = v.strip().strip('"').strip("'")
if key in {"profile", "tables_dir"}:
if key in {"profile", "tables_dir", "adapter"}:
out[key] = val

return out
Expand Down Expand Up @@ -169,6 +171,7 @@ def init_byod_project(dest: PathLike, *, profile: str = "core_gl", force: bool =
[trackd]
profile = "{p}"
tables_dir = "tables"
adapter = "passthrough"
"""
).lstrip()
(root / "config.toml").write_text(config, encoding="utf-8")
Expand Down Expand Up @@ -205,6 +208,47 @@ def init_byod_project(dest: PathLike, *, profile: str = "core_gl", force: bool =
return root


class _PassthroughAdapter:
"""Sheets-first adapter: treat tables/ as already canonical.

Normalization for this adapter means:
- enforce required headers exist (handled before calling the adapter)
- write out normalized/*.csv with contract column ordering
- preserve any extra columns (appended)
"""

name = "passthrough"

def normalize(self, ctx: NormalizeContext) -> dict[str, Any]:
schemas = schemas_for_profile(ctx.profile)
ctx.normalized_dir.mkdir(parents=True, exist_ok=True)

files: list[dict[str, Any]] = []
for schema in schemas:
src = ctx.tables_dir / schema.name
dst = ctx.normalized_dir / schema.name
files.append(_normalize_csv(src, dst, required_columns=schema.required_columns))

return {
"ok": True,
"adapter": self.name,
"profile": ctx.profile,
"project": str(ctx.project_root),
"tables_dir": str(ctx.tables_dir),
"normalized_dir": str(ctx.normalized_dir),
"files": files,
}


def _get_adapter(name: str | None) -> TrackDAdapter:
n = (name or "").strip().lower() or "passthrough"
if n == "passthrough":
return _PassthroughAdapter()
raise TrackDDataError(
f"Unknown adapter: {name}.\n" "Use one of: passthrough"
)


def normalize_byod_project(project: PathLike, *, profile: str | None = None) -> dict[str, Any]:
"""Normalize BYOD project tables into ``normalized/`` outputs.

Expand All @@ -222,7 +266,7 @@ def normalize_byod_project(project: PathLike, *, profile: str | None = None) ->
Returns
-------
dict
Report dict with keys: ok, profile, project, tables_dir, normalized_dir, files.
Report dict with keys: ok, adapter, profile, project, tables_dir, normalized_dir, files.
"""

from .validate import validate_dataset
Expand All @@ -247,24 +291,16 @@ def normalize_byod_project(project: PathLike, *, profile: str | None = None) ->
"Hint: your BYOD project should contain a 'tables/' folder."
)

# Validate required schema issues first, so normalization can assume headers exist.
validate_dataset(tables_dir, profile=p)

schemas = schemas_for_profile(p)
out_dir = root / "normalized"
out_dir.mkdir(parents=True, exist_ok=True)
adapter = _get_adapter(cfg.get("adapter"))

files: list[dict[str, Any]] = []
for schema in schemas:
src = tables_dir / schema.name
dst = out_dir / schema.name
files.append(_normalize_csv(src, dst, required_columns=schema.required_columns))
# Validate required schema issues first, so adapters can assume headers exist.
validate_dataset(tables_dir, profile=p)

return {
"ok": True,
"profile": p,
"project": str(root),
"tables_dir": str(tables_dir),
"normalized_dir": str(out_dir),
"files": files,
}
ctx = NormalizeContext(
project_root=root,
profile=p,
tables_dir=tables_dir,
raw_dir=(root / "raw"),
normalized_dir=(root / "normalized"),
)
return adapter.normalize(ctx)
23 changes: 23 additions & 0 deletions tests/test_trackd_byod_adapter_selection_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

from pathlib import Path

from pystatsv1.cli import main


def test_trackd_byod_normalize_uses_adapter_from_config(tmp_path: Path, capsys) -> None:
proj = tmp_path / "byod"

rc_init = main(["trackd", "byod", "init", "--dest", str(proj), "--profile", "core_gl"])
assert rc_init == 0

cfg_path = proj / "config.toml"
cfg = cfg_path.read_text(encoding="utf-8")
cfg_path.write_text(cfg.replace('adapter = "passthrough"', 'adapter = "bogus"'), encoding="utf-8")

rc = main(["trackd", "byod", "normalize", "--project", str(proj)])
out = capsys.readouterr().out.lower()

assert rc == 1
assert "unknown adapter" in out
assert "passthrough" in out