From a52bad28fb9aa038d5f08322f55b99cb876f4338 Mon Sep 17 00:00:00 2001 From: Nicholas Karlson Date: Tue, 20 Jan 2026 17:02:42 -0800 Subject: [PATCH] Track D: add BYOD adapter interface (passthrough) --- src/pystatsv1/cli.py | 1 + src/pystatsv1/trackd/adapters/__init__.py | 6 ++ src/pystatsv1/trackd/adapters/base.py | 34 ++++++++ src/pystatsv1/trackd/byod.py | 78 ++++++++++++++----- .../test_trackd_byod_adapter_selection_cli.py | 23 ++++++ 5 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 src/pystatsv1/trackd/adapters/__init__.py create mode 100644 src/pystatsv1/trackd/adapters/base.py create mode 100644 tests/test_trackd_byod_adapter_selection_cli.py diff --git a/src/pystatsv1/cli.py b/src/pystatsv1/cli.py index 2f4a4b8..36f93ac 100644 --- a/src/pystatsv1/cli.py +++ b/src/pystatsv1/cli.py @@ -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')} diff --git a/src/pystatsv1/trackd/adapters/__init__.py b/src/pystatsv1/trackd/adapters/__init__.py new file mode 100644 index 0000000..05831dc --- /dev/null +++ b/src/pystatsv1/trackd/adapters/__init__.py @@ -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. +""" diff --git a/src/pystatsv1/trackd/adapters/base.py b/src/pystatsv1/trackd/adapters/base.py new file mode 100644 index 0000000..655566d --- /dev/null +++ b/src/pystatsv1/trackd/adapters/base.py @@ -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.""" + + ... diff --git a/src/pystatsv1/trackd/byod.py b/src/pystatsv1/trackd/byod.py index 74b3557..f8cdbf2 100644 --- a/src/pystatsv1/trackd/byod.py +++ b/src/pystatsv1/trackd/byod.py @@ -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 @@ -30,6 +31,7 @@ def _read_trackd_config(project_root: Path) -> dict[str, str]: - [trackd].profile - [trackd].tables_dir + - [trackd].adapter Notes ----- @@ -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 @@ -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") @@ -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. @@ -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 @@ -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) diff --git a/tests/test_trackd_byod_adapter_selection_cli.py b/tests/test_trackd_byod_adapter_selection_cli.py new file mode 100644 index 0000000..fcfb64a --- /dev/null +++ b/tests/test_trackd_byod_adapter_selection_cli.py @@ -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