Monorepo Restructure Design
Date: 2026-07-19 Status: Approved, ready for implementation planning
Motivation
The repo has grown to contain three distinct tools (a bash downloader, a Python schema-drift analyzer, a K6 SQL-Server load tester) with more planned (a DuckDB-based SQL Server bulk loader). Today they share one flat directory layout, one pyproject.toml, one config file at root, and the Python code is a single package called loadtest. Before making the repo public, we want the three tools to live as clearly separated components under one repo, sharing the code that genuinely belongs shared, and giving each tool its own documentation entry point.
Non-goals
- Splitting into multiple independently-installable packages. One
pip install taxigets everything (decision recorded during brainstorming). - Publishing to PyPI. That happens later; the placeholder name
taxistays in the design and is renamed at publish time. - Rewriting any tool in a different language. The Rust/Go conversation from the brainstorming session was explicitly deferred.
- Building the future SQL Server loader. This restructure only prepares the ground for it.
Current state
The DuckDB→SQL Server bits inside loadtest/type_mapping.py and loadtest/sql_generator.py are the primary shared-code candidates: the future SQL Server loader will need them, and schema-drift may benefit from the type-map too.
Target layout
Naming: directories use kebab-case, Python packages use snake_case. The src/ layout is used consistently for all three Python packages — not strictly required, but it's standard for public packages and prevents accidental imports of top-level directories that happen to share a name with a package.
Per-tool details
downloader/
- No Python, no pyproject entry, no tests package. Pure bash.
- README covers: what the tool does, WAF-aware retry / boundary detection differentiators, install requirements (bash 4+, curl), Windows note (install Git for Windows), the two usage modes (
--recent Nand default full catch-up). - The script's working-directory contract changes: it currently writes to
./raw/(relative to CWD, which is the repo root today). Because the script now lives indownloader/, either: - the script always resolves
raw/relative to the repo root (viadirname "$0"/..), or - the README documents that users invoke it from the repo root as
./downloader/download_taxi_data.sh.
Design decision: resolve raw/ relative to the script's own directory ($(dirname "$0")/../raw) so the script works from any CWD. This is a small edit to the script; verified during implementation.
schema-drift/
- Python package
schema_drift, script entryschema-drift = "schema_drift.cli:main". - The 1073-line source file is split into modules. Final boundaries confirmed during writing-plans by reading the current file; the working target is
cli.py / scanner.py / compare.py / report.py. If reading reveals a natural fifth module or shows a proposed module is tiny, adjust at that point. - Basic import smoke tests added if none exist today. Existing functional tests (if any — the repo currently ships zero tests for schema-drift) are moved into
tests/schema_drift/; if none exist, a minimal smoke test is added so pytest exits clean. - README covers: purpose (schema drift analysis of TLC parquet), install,
schema-drift --helpusage, output format.
k6-loadtest/
- Python package
k6_loadtest, script entry renamedpreprocess→k6-preprocess = "k6_loadtest.preprocess:main". - Directory rename:
loadtest/→k6-loadtest/src/k6_loadtest/. - Two files are extracted to
shared/:type_mapping.py,sql_generator.py. Imports insidek6_loadtestchange fromfrom .type_mapping import Xtofrom taxi_shared.type_mapping import X(and analogous forsql_generator). build_k6.shandconfig.sample.yamlmove inside the tool's directory.- Existing
tests/loadtest/moves totests/k6_loadtest/with import updates. The two tests that cover the extracted modules (test_type_mapping.py,test_sql_generator.py) move totests/taxi_shared/. - README covers: what it does, K6 install prerequisites,
build_k6.shstep,config.sample.yaml → config.yamlworkflow, the two modes (parquet vs synthetic), running the generated test.
shared/ (taxi_shared)
- Python package
taxi_shared. No CLI entry point — it's a library. - Just the two extracted modules + minimal
__init__.py. No new abstractions added; this is a mechanical move. - Reason for extracting now (versus waiting for the SQL loader): the second consumer is already named, extraction is a ~100-line mechanical move, and doing it now avoids drift if the k6-loadtest copies get edited before extraction happens.
pyproject.toml
The downloader has no entry (bash-only). Dependencies stay shared across all three Python packages via a single dependency list — sufficient for duckdb and pyyaml today, which are used by both schema_drift and k6_loadtest.
Migration order
One PR, structured as a sequence of atomic commits. Each commit ends with uv run pytest green before the next starts.
-
Create
shared/package. Movetype_mapping.py+sql_generator.pyfromloadtest/intoshared/src/taxi_shared/. Update the two Python files insideloadtest/that import them (k6_generator.py,preprocess.py— verified during implementation). Updatepyproject.tomlpackages list to addtaxi_shared. Movetest_type_mapping.pyandtest_sql_generator.pyfromtests/loadtest/totests/taxi_shared/with updated imports. Run tests → green. -
Rename
loadtest/→k6-loadtest/src/k6_loadtest/. Updatepyproject.toml: rename package path, rename script entrypreprocess→k6-preprocess. Movebuild_k6.shandconfig.sample.yamlinsidek6-loadtest/. Movetests/loadtest/→tests/k6_loadtest/. Update allfrom loadtest.Ximports tofrom k6_loadtest.X. Run tests → green. -
Move
download_taxi_data.shintodownloader/. Adjust the script'sraw/path resolution to be script-relative ($(dirname "$0")/../raw) so it works from any CWD. Manual smoke-run with--recent 1to confirm no regression. Adddownloader/README.md. -
Split
schema_drift.pyintoschema-drift/src/schema_drift/modules. Largest step; own commit. Reader-driven — module boundaries confirmed by first reading the script during writing-plans. Addpyproject.tomlscript entry. Add at least an import smoke test intests/schema_drift/if no functional tests exist to carry over. Run tests → green. -
Cleanup commit. Delete root
config.yaml(duplicate ofk6-loadtest/config.sample.yaml). Deleteraw_data_urls.txt(unused since downloader rewrite). Update.gitignore: changek6_output/→k6-loadtest/output/; keepraw/. -
Documentation commit. Rewrite top-level
README.mdas an index. Write per-tool READMEs:downloader/README.md,schema-drift/README.md,k6-loadtest/README.md. Top-level README links to each and keeps the existing acknowledgment section pointing atTHIRD_PARTY_NOTICES.
Why in this order: shared extraction goes first because it's the most invasive edit and every subsequent step benefits from clean imports. Downloader move is trivial and can slot anywhere. Schema-drift split is largest and goes late so it doesn't block other work if it turns up unexpected complexity. Documentation goes last so it describes the final state, not intermediate.
Out of scope for this restructure
- Any behavioral change to the downloader, schema-drift, or k6-loadtest beyond what's needed to make imports resolve and the script's
raw/path work. - Renaming the umbrella project from
taxito anything else. - Splitting the umbrella into multiple installable packages (uv workspace).
- Building the SQL Server loader tool.
- CI configuration (there is none today; add later if/when needed).
- Reviewing schema-drift for correctness or improvement during the split — the split preserves behavior; any improvements are a separate task.
Success criteria
- Repo tree matches the target layout above.
uv syncsucceeds with no changes to declared dependencies.uv run pytestpasses.uv run k6-preprocess --helpanduv run schema-drift --helpboth work../downloader/download_taxi_data.sh --recent 1runs successfully from the repo root and writes toraw/.- Every per-tool README loads and describes its tool without needing content from other READMEs.
- No file left orphaned at the repo root that belongs inside a tool directory.