Skip to content

chkit-py: full parity with TypeScript chkit + dual-language docs - #204

Open
Lucasgvdii wants to merge 50 commits into
mainfrom
first-python-port
Open

chkit-py: full parity with TypeScript chkit + dual-language docs#204
Lucasgvdii wants to merge 50 commits into
mainfrom
first-python-port

Conversation

@Lucasgvdii

@Lucasgvdii Lucasgvdii commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • chkit-py reaches full parity with the TypeScript implementation: dictionary primitive, index-only projections, function expressions in keys, the complete Phase-2 backfill engine (chunking planner, async execution loop, plan/run/resume/doctor, managed submit to ObsessionDB jobs), top-level chkit codegen / chkit obsessiondb CLI shortcuts, plugin flag forwarding, function-style configs, and check.failOnExtraObjects. Full decision log in chkit_python/DRIFT.md.
  • Fixes surfaced by adversarial parity review: JS Number()/String() fidelity for chunk boundaries, WHATWG URL.origin environment fingerprints (TS-written plans run under Python), atomic checkpoint writes, snapshot exclude_none for cross-implementation interop, wheel packaging of all plugin packages, and a ClickHouseClient.submit() crash.
  • Docs: every page with code now shows TypeScript and Python side by side via synced Starlight tabs; new Python section; site repositioned as "ClickHouse schema toolkit for TypeScript and Python" (hero, footer, llms.txt, READMEs); raw-markdown integration fixed for Windows.
  • Release prep: chkit-py bumped to 0.2.0 with changelog, plus a tag-triggered PyPI trusted-publishing workflow (chkit-py-v*).

Test plan

  • chkit_python: 1185+ unit tests, mypy --strict, ruff — all green; e2e suite validated 11/11 against live ClickHouse 24.8
  • turbo run typecheck lint build — 28/28 tasks green (TS e2e runs in CI with secrets)
  • Wheel built, twine check passed, clean-venv smoke: all four packages import, chkit --version → 0.2.0
  • Docs site builds (42 pages); every Python doc example executed against chkit-py by a reviewer agent

Lucasgvdii added 30 commits June 5, 2026 10:08
Under --json, whoami/logout/login/service-list/service-select printed a
JSON-encoded *string* (e.g. "Not logged in…") instead of an object: the
serializer JSON.stringify'd whatever it received, and those commands never
threaded jsonMode, so they passed plain strings. This broke the documented
agent contract — piping their --json output to jq failed because a bare
string isn't the {status,next}/{ok,error} envelope callers expect.

Fix in two deliberate layers:
- A catch-all in printOutput wraps any string in {schemaVersion, message}
  while in --json mode. This closes the whole class of bug at its single
  chokepoint, so no command can ever emit a bare string again — even ones
  we don't special-case.
- Purpose-built envelopes for the two commands agents actually consume:
  whoami (logged_in / not_logged_in / session_expired) and service list
  (one object with a services[] array, instead of one JSON line per
  service, which isn't valid single-JSON).

login/logout/alias are intentionally left to the catch-all: they aren't
part of the documented machine flow, so a dedicated envelope each would be
disproportionate plumbing for no chaining benefit.

BREAKING: whoami and service-list --json output changes shape from string
to object. This is the intended fix to the JSON contract.
…onnection

With an ObsessionDB service selected, `chkit pull` printed
"using service <name>" but then opened its own ClickHouse client from
config.clickhouse (defaulting to localhost:8123) and failed with
"connection refused". It discarded the executor the host already resolves
and hands to every command — so pull could not introspect an ObsessionDB
instance at all, and the "using service" line was misleading.

Route pull through pluginContext.executor, the same executor that
generate/migrate/status use. The host resolves it per command (the
ObsessionDB remote executor when a service is selected, a direct ClickHouse
executor otherwise), and the remote executor already implements exactly the
methods pull needs (listSchemaObjects, listTableDetails, query). We only
build a client from config.clickhouse when the host provides no executor.

Chosen over "just fail clearly on an ObsessionDB target" because routing
makes pull actually work against ObsessionDB — the headline onboarding
target — rather than failing more politely. Custom introspectors still open
their own raw-ClickHouse connection (and require the config block), and the
genuine no-target case now errors with an actionable message instead of a
silent localhost fallback.
An async data-load migration submits the query and polls queryStatus until
a terminal state. The poll call had no error handling, so a single HTTP 524
(gateway timeout) on a *poll request* threw out of the loop and aborted the
migration — even though the server-side INSERT kept running (we observed
~34.8M of 100M rows land after chkit had given up). A poll request timing
out was wrongly conflated with the load itself failing.

Wrap the poll in try/catch and treat a transient poll error as "keep
polling", up to a bounded budget (MAX_TRANSIENT_POLL_ERRORS). Only a real
ExceptionWhileProcessing status, or a submit-time rejection, is fatal. When
the budget is exhausted we stop with an explicit message that the load may
still be running and that re-running re-attaches via the deterministic
query_id — never a silent abort and never an infinite loop.

A bounded budget (rather than infinite tolerance) is deliberate: a genuinely
dead endpoint must not hang forever, while normal gateway blips on a
multi-minute load are absorbed. Note this path is only reached by migrations
with an explicit `mode=async` operation (data loads); ordinary schema DDL
generated by `generate` is synchronous and unaffected.
A single create-chkit run printed the "Next steps" block twice — once from
create-chkit's own package-manager-aware printer, and once from the
onboarding flow's printer, which additionally hardcoded `bunx`. So a user
who passed --package-manager npm saw a duplicated block telling them to run
`bunx chkit …`, the wrong runner.

- Thread the resolved package manager into runOnboarding and derive the
  runner word (npx / pnpm dlx / yarn dlx / bunx) instead of hardcoding bunx.
- runOnboarding already prints next-steps on every branch, so only call
  create-chkit's own printer when onboarding is skipped, removing the
  duplicate.

Default to `npx` when the package manager is unknown: it is the most
universal runner and matches the npm-first install instructions; an explicit
--package-manager always overrides it.
The docs and the first-run hint tell users to install the agent skill with
`npx skills add obsessiondb/chkit` — `skills` is a separate CLI, not a chkit
subcommand. Users (and our own tutorial) naturally reached for
`chkit skills add …`, which returned "Unknown command: skills".

Add a thin `chkit skills` command that forwards its arguments to the
external `skills` CLI (`npx skills <args>`) and passes through its exit code.
It is intercepted early in dispatch, like `init`, because it needs no project
config or executor.

A proxy is chosen over a docs-only fix because it removes the foot-gun
entirely — `chkit skills add …` now does the expected thing — while keeping
`skills` as the real underlying tool. The spawner is injectable so the
forwarding and exit-code passthrough are unit-tested without spawning a
process.
… errors

In a non-TTY shell `chkit init` silently skipped onboarding and printed only
static next-steps, while `create-chkit` printed the full connect runbook —
an inconsistency that made it look like `init` had no connect step at all.
Separately, the dynamic import of the obsessiondb plugin was wrapped in a
bare `catch {}` that swallowed any failure, so an installed-but-broken plugin
degraded silently with no signal.

- Keep `--yes` as the silent path for CI/scripts, but otherwise always hand
  off to runOnboarding, which self-gates on TTY: interactive menu when
  attached, connect runbook when not — matching create-chkit.
- Only swallow a genuine "plugin not installed" (module-not-found of the
  plugin package itself); any other import failure now propagates instead of
  a false silent pass.
- Fix init's static next-steps to use `npx` rather than a hardcoded `bunx`.

The not-installed vs load-error distinction is the key decision: the plugin
is optional, so its absence must degrade gracefully — but a present plugin
that fails to load is a real problem the user needs to see.
`chkit obsessiondb logout` always printed "Logged out." even with no stored
credentials, implying it had ended a session that never existed.

Have clearCredentials return whether a credentials file actually existed,
and let logout print "Logged out." vs "No active session." accordingly.

Exit code stays 0 either way: logout is intentionally idempotent so scripts
can call it unconditionally. The bug was the misleading message, not the
exit behavior — so only the message changes.
One changeset per fix (mirrors the per-phase commits): structured --json
envelopes, pull via the host executor, async-load poll resilience,
create-chkit Next-steps dedup, the chkit skills proxy, init connect-runbook
consistency, and the logout no-session message.
`runnerFor` (the package-manager → runner-word helper added with the
create-chkit Next-steps fix) was exported but only used inside onboarding,
which tooling flagged as an unused export. It has no external consumers, so
drop the `export` rather than widen the public surface.
… client + expand top-level package re-exports

httpx is required by chkit_plugin_obsessiondb's RFC 8628 device-code flow,
OTP signup, and oRPC services/jobs/workbench clients (forthcoming commits).
pytest-httpx mocks those calls in tests.

The package re-export expansion surfaces SchemaLoaderError, ModuleLoadError,
codec_raw, import_module_file, load_schema_definitions, is_synthesized_config_path,
and SYNTHESIZED_CONFIG_PATH so user-facing config scripts can import them
from the chkit root.
…odules

- config_path: SYNTHESIZED_CONFIG_PATH sentinel + is_synthesized_config_path
  predicate for the obsessiondb credentials-only fallback config.
- plugin_error: wrap_plugin_run shell mirroring TS wrapPluginRun; turns
  exceptions inside plugin command runs into JSON envelopes (--json) or
  text + exit code (2 for config error, 1 otherwise).
- schema_loader: load_schema_definitions(globs, cwd) — glob-resolves user
  schema files, imports each via the ts_import loader, returns a
  canonicalized list of SchemaDefinition.
- ts_import: import_module_file using compile() + exec() with a monotonic
  counter for the synthetic module name. Bypasses Python's mtime-keyed
  bytecode cache so consecutive in-test rewrites of the same schema file
  see the new content (Windows NTFS mtime granularity is ~10ms).

Tests cover each module's surface + the bytecode-cache fix that motivated
ts_import.
…, dependsOn camelCase)

Exports added to chkit.core for the public surface used by plugin authors:
key_clause helpers (split_top_level_comma, normalize_key_columns),
SQL splitter helpers (split_sql_statements, extract_executable_statements),
SQL normalizers (normalize_sql_fragment, normalize_engine). These were
present internally but never re-exported.

canonical.py:
- canonicalize_definition now backfills empty primary_key from order_by,
  matching TS canonical.ts. Without this a snapshot written by the TS CLI
  (where the user omits primaryKey and TS substitutes orderBy) would
  appear to drift against a Python-written snapshot of the same schema.
- _canonicalize_refresh emits the camelCase 'dependsOn' key (with
  by_alias=True on the inner TableRef dump) so cross-port snapshot JSON
  matches byte-for-byte.
…ion + DDL propagation polling

ClickHouseClient (client.py):
- execute / query / query_json / submit / query_status (system.processes
  + system.query_log polling for async migrations).
- insert(table, rows, *, column_names, database) for bulk inserts.
- list_databases / list_tables.
- list_schema_objects() / list_table_details(databases) — exposed as
  methods that delegate to the standalone introspect helpers.
- Module-level helpers: format_connection_error (auth vs network
  distinction via CH error codes 192/193/516), wrap_connection_error
  + ClickHouseConnectionError typed exception, is_unknown_database_error
  (CH code 81).

create_table_parser.py: 8 parser functions (settings/ttl/engine/pk/orderBy/
partition/uniqueKey/projections) for system.tables.create_table_query.

introspect.py:
- list_schema_objects: enumerates non-system tables/views/MVs, skips
  _chkit_* journal tables.
- list_table_details: joins system.tables + system.columns +
  system.data_skipping_indices into IntrospectedTable rows.
- normalize_column_from_system_row / normalize_index_from_system_row
  with full skip-index variant coverage (minmax, set, bloom_filter,
  tokenbf_v1, ngrambf_v1).

ddl_propagation.py: polls until DDL is visible across replicas. Operation-
type-aware predicates: wait_for_table, wait_for_view, wait_for_column,
wait_for_table_absent, wait_for_column_absent, wait_for_index,
wait_for_index_absent, wait_for_projection, wait_for_projection_absent.
20 attempts × 500ms (~10s budget) matches TS p-retry defaults.
Dispatcher routes alter_table_drop_column → column_absent,
alter_table_add_index → index, alter_rename_table → table, etc.

Tests cover the 8 parser clauses, all 5 skip-index variants, the
introspection joins, the connection-error helpers, and every DDL
propagation predicate + dispatcher route.
…t, get-context)

Public plugin surface mirroring TS packages/cli/src/plugins.ts:

Manifests + plugin:
- ChxPluginManifest / ChxPluginManifestCompatibility / Cli
- ChxPlugin (manifest + hooks + commands + options_schema + extend_commands)
- LoadedPlugin (validated + options resolved)

Hook contexts (10):
- ChxOnInitContext, ChxOnCompleteContext
- ChxOnConfigLoadedContext, ChxOnSchemaLoadedContext, ChxOnPlanCreatedContext
- ChxOnBeforeApplyContext, ChxOnAfterApplyContext
- ChxOnCheckContext, ChxOnCheckResult, ChxOnCheckReportContext, ChxCheckFinding
- ChxOnBeforePluginCommandContext + ChxOnBeforePluginCommandHandled /
  Unhandled / Result (used by obsessiondb to route backfill status/cancel/
  list through its jobs API before the local backfill plugin's stubs run).
- ChxOnPullIntrospectContext: lets plugins inject SchemaDefinition lists,
  bypassing the SQL-based pull (obsessiondb metadata API use case).

Commands + context:
- ChxPluginCommand (name + run + description + flags)
- ChxPluginCommandContext (config + flags + options + table_scope +
  plugin_runtime + plugin_context).
- PluginContext (executor + has_executor) + ChxGetContextInput for the
  getContext hook (plugins return a custom executor; the obsessiondb
  plugin's remote executor uses this when a service is selected).

PluginRuntimeProtocol: subset of the runtime exposed to plugin run methods
(get_command + run_plugin_command, plus run_on_pull_introspect).
…al store, plumbing

plugin_runtime.py:
- PluginRuntime: load + validate (manifest + CLI compatibility) + dispatch
  all 11 lifecycle hooks (run_on_init, run_on_complete, run_on_config_loaded,
  run_on_schema_loaded, run_on_plan_created, run_on_before_apply,
  run_on_after_apply, run_on_check, run_on_check_report,
  run_on_before_plugin_command, run_on_pull_introspect).
- run_plugin_command invokes on_before_plugin_command first; if any plugin
  returns Handled, short-circuits with its exit_code (lets obsessiondb's
  backfill routing intercept before the local backfill plugin runs).
- resolve_context + dispose_context for the getContext hook (plugin-supplied
  executors, e.g. obsessiondb's remote-executor when a service is selected).
- PluginExecutionError wraps third-party plugin exceptions with the failing
  stage name.

table_scope.py + safety_markers.py: --table glob support across the 5
scope-aware commands (generate / migrate / status / check / drift), plus
destructive-op detection (DROP, TRUNCATE, ALTER DROP COLUMN, DETACH, etc.)
with both planner-marker + hand-written SQL scanning.

migration_metadata.py: header parser for '-- log: ...' (KNOWN_KEYS set so
unknown markers raise instead of being silently ignored).

journal_store.py: the _chkit_migrations ClickHouse table abstraction.
Stores migration name + applied_at + checksum + chkit_version +
per-statement OperationState (query_id + status + timestamps + last_error)
for async-apply resume support. UNKNOWN_DATABASE detection so status/drift
gracefully report database-missing without crashing.

json_output.py + logging_setup.py: small plumbing for --json envelope
formatting and CHKIT_DEBUG=1 stdlib-logging configuration.

main.py: Typer app wiring + register every core command. schema_loader.py:
thin wrapper of chkit.core.schema_loader so CLI commands don't need to
import the core sub-module directly.
… dispatch)

Mirrors TS init.ts:
- Writes clickhouse.config.py + src/db/schema/example.py.
- --yes silent mode.
- --connect / --email / --code / --org-name passthrough to the obsessiondb
  onboarding wizard (Typer-validated enum for --connect).
- Dispatch via importlib.import_module('chkit_plugin_obsessiondb') with
  ModuleNotFoundError graceful degrade (prints the static runbook + next
  steps when the plugin isn't installed, mirroring TS's missing-package
  fallback).
- --auto-deps intentionally not ported (Python convention: pip install
  is explicit). Documented in DRIFT.md > init/auto-deps.

Tests cover every flag combination, the silent mode, the runbook output
shape when the plugin is absent, and the OBSESSIONDB_PLUGIN_MODULE
monkeypatch hook used by the cross-cutting test isolation.
…appings + codegen integration

generate.py: diffs current schema against the last snapshot, emits a
migration .sql + a snapshot .json.

Flags mirror TS exactly (--name / --migration-id / --rename-table /
--rename-column / --dryrun / --json / --config / --table).

plan-pipeline (generate_plan_pipeline.py):
- apply_explicit_table_renames: rewrites drop+create pairs as rename ops.
- apply_selected_rename_suggestions: collapses drop+create column pairs.
- build_explicit_column_rename_suggestions: turn CLI mappings into
  suggestions for the renamer.
- assert_cli_column_mappings_resolvable: error if a CLI --rename-column
  references a column not in the diff.

rename-mappings (generate_rename_mappings.py): parse_rename_*_mappings,
merge_*_mappings + conflict assertions, collect_schema_rename_mappings,
resolve_active_table_mappings, remap_old_definitions_for_table_renames.
Cross-DB renames emit CREATE DATABASE IF NOT EXISTS at the right
ordering rank.

Codegen integration: after writing the migration, if a 'codegen' plugin is
registered with run_on_generate != False, dispatch its codegen command.
Factory-supplied options are read from the plugin's hook closure (a
PluginConfig instance under .options) since load_plugin_runtime today
doesn't thread factory options through LoadedPlugin.options.

plan_diff is wrapped in try/except ChxValidationError — under --json the
error surfaces as {error: 'validation_failed', issues: [...]} instead of
a raw stack trace. scope is now included in every JSON output path
(apply / empty-plan / dryrun).
…ts + plugin hooks

migrate.py: --apply / --execute (alias) / --dryrun / --table / --json /
--config / --allow-destructive. Default behaviour is plan/preview.

Apply loop:
- Reads each pending file → runs run_on_before_apply (plugins may transform
  the statement list) → executes each statement, dispatching to async-apply
  for operations marked mode=async in their -- operation: header → writes
  journal entry → runs run_on_after_apply.
- wait_for_ddl_propagation after each statement (operation-type-aware).

migrate_async_apply.py: deterministic query_id = chkit-{migration_checksum}-
{statement_index}. Submit + poll system.query_log until terminal. On
resume, attach to an existing query_id from the journal (checksum
validation refuses to continue if the migration file was edited mid-flight).
20 transient poll-error retries. Honors -- before-retry: SQL marker.

migrate_prompts.py: confirm_apply (TTY, CI auto-skip via CI=1 + isatty),
confirm_destructive_execution + print_destructive_operation_details.
is_background_or_ci() check matches TS exactly.

migrate_scope.py: filter_pending_by_scope splits pending into in-scope vs
undetermined (files whose operations don't carry a table key).
status.py: lists migration files, intersects with the _chkit_migrations
journal applied set (project-scoped — only counts THIS project's
migrations even when the journal table is shared across tenants).
Honors --table, --json, gracefully handles UNKNOWN_DATABASE (returns
{databaseMissing: true, database: ...} in JSON).

drift.py + drift_payload.py + drift_compare.py + drift_diff.py:
- build_drift_payload joins snapshot definitions against live
  introspection (lists schema objects → fetch table details for any
  database appearing in the snapshot).
- compare_schema_objects + compare_table_shape: per-kind + per-column
  + per-engine-arg + per-setting + per-skip-index + per-projection diff.
- summarize_drift_reasons: counts split into object-level vs table-level.
- --live flag (Python-only) opens a connection for full payload; default
  is snapshot-only fast-path. Honors --table for partial scope.

Tests cover the drift_compare permutations.
Aggregates 4 categories:
- validation: validate_definitions(schema) issues.
- pending_migrations: pending count > 0 + fail_on_pending policy.
- checksum_mismatch: applied migrations whose checksum changed on disk.
- schema_drift: snapshot vs schema diff (drift_ops); --live extends with
  live-DB drift via build_drift_payload (live_drifted).

Plugin on_check hooks contribute additional findings; each plugin's
result is collected into a ChxOnCheckResult and its plugin-name + ok
flag emitted as 'plugin:<name>' in failedChecks when failing.

JSON envelope (mirrors TS check/output.ts):
- top-level: strict, policy {failOnPending, failOnChecksumMismatch,
  failOnDrift}, ok, failedChecks, pendingCount, pendingMigrations,
  checksumMismatchCount, checksumMismatches, drifted, driftEvaluated
  (true iff snapshot present), driftOperations, scope.
- when --live: liveDrifted, driftReasonCounts, driftReasonTotals
  (object with total/object/table keys, not a single int).
- when plugins: 'plugins' object map keyed by plugin name (TS shape),
  plus 'pluginCheckResults' array for back-compat.
- finding code for drift is 'schema_drift' (matches TS, not 'drift').

--strict overrides all policy fail_on_* to true. Exit 1 on any failure.
…ntrospect hook

pull.py: introspects a live database into a Python schema file.
--out-file / --database (repeatable) / --force/--overwrite / --dryrun
/ --json. Atomic write (temp + replace).

pull_view_parser.py: parses CREATE VIEW / CREATE MATERIALIZED VIEW
queries from system.tables.create_table_query:
- parse_as_clause: extract the AS SELECT body.
- parse_to_clause: handle backticked + dotted target table names.
- parse_refresh_clause: EVERY / AFTER / OFFSET / RANDOMIZE / DEPENDS ON
  / SETTINGS / APPEND / EMPTY; strips DEFINER / SQL SECURITY for
  managed-CH compatibility.

pull_render.py: renders SchemaDefinition list back to chkit Python DSL
(round-trips through ts_import → canonicalize_definitions → match).

on_pull_introspect hook: when any plugin's hook returns a list of
SchemaDefinition, pull skips the SQL path entirely (used by obsessiondb
to query its metadata API instead of running SQL).

JSON output mirrors TS schemaEnvelope: command='schema' + outFile +
definitionCount + tableCount + viewCount + materializedViewCount +
databases + dryrun + skippedObjects (per-kind count of objects in the
selected databases that didn't end up in the emitted schema). Skipped
objects are computed via the new _summarize_skipped_objects helper.
…ON output)

Single positional SQL arg (rejects multi-arg + empty). --json, --config.

Text-table output: column width auto-sizing, header + separator,
DEFAULT_SHOWN_ROW_LIMIT=25 with truncation indicator.

JSON envelope = ClickHouseJsonQueryResult shape: data[] + meta[] (per
column name + type) + rows + statistics + query_id. Matches TS exactly
for downstream consumers.

Error cleaning: strip the injected 'FORMAT JSON' artifact, truncate
'Expected one of [...]' lists past EXPECTED_TOKEN_CAP=8 tokens. Mirrors
TS error-cleaner behaviour 1:1.

Cell stringification matches TS: null → '', strings/numbers/bools as-is,
complex values via JSON.
Three modes:
- chkit plugin               → list every registered plugin + its commands
- chkit plugin <name>        → list commands for one plugin
- chkit plugin <name> <cmd>  → dispatch the plugin command (with --args)

Lazy ClickHouseClient.connect: not every plugin command needs a DB
connection. When config.clickhouse is set we connect and pass through
plugin_context.executor; otherwise null_plugin_context().

run_plugin_command (called via the runtime) invokes
on_before_plugin_command first — this is what lets the obsessiondb
plugin intercept backfill status/cancel/list and route them through
the jobs API before the local backfill plugin's stubs run.
user_config.py: XDG-compliant user-config directory.
- get_user_config_dir() honors XDG_CONFIG_HOME, defaults to ~/.config,
  always suffixed with /chkit.
- USER_PROFILE_CONFIG_FILE = 'config.py' (Python convention; TS uses
  'config.ts').
- USER_CREDENTIALS_FILE = 'credentials.json' (same as TS).
- Composed-path sugar: get_user_profile_config_path /
  get_user_credentials_path.

config_merge.py: merge_user_config(base, overlay) layers a user-profile
ChxUserConfig under a project ChxUserConfig with TS-matching semantics:
- scalar fields (schema / outDir / migrationsDir / metaDir): overlay
  wins when set, else fall back to base.
- clickhouse / check / safety: shallow-merge (overlay wins per-key).
- plugins: merge by plugin name — overlay entries replace base entries
  with the same name; entries present only in base or only in overlay
  are preserved (preserved-from-base entries appear first, overlay
  entries appended).
- plugin_name_of(registration) does best-effort name extraction for both
  ChxPlugin objects and wrapped {plugin, name?} registration dicts.
…+ services + remote/backfill/onboarding)

Phase 1 — foundation:
- credentials.py: XDG-compliant 0600 ~/.config/chkit/credentials.json,
  resolve_base_url honors OBSESSIONDB_API_URL env override.
- storage.py: SelectedService (only service_slug + service_name required —
  the other org/service-id fields are optional Python additions, kept None
  by default so a TS-written .chkit/obsessiondb.json deserializes here).
  Project + user-global service-state files + alias map.
- engine.py: on_schema_loaded hook rewrites Shared* engines to standard
  ClickHouse equivalents when targeting a non-obsessiondb host. Strips
  cloud-only settings (storage_policy). --force-shared-engines /
  --no-shared-engines overrides; auto-detect from URL.
- plugin.py: obsessiondb() factory + ChxPluginManifest + hooks +
  5 commands (login / logout / whoami / signup / service).

Phase 2 — auth flows:
- api_client.py: RFC 8628 device-code (request + poll + slow_down +
  expired_token), get_session, passwordless OTP (send + verify), org
  create + set_active. SessionExpiredError on 401 surfaces from the
  service_api/jobs_api/workbench_api layer; OtpRateLimitError on 429.
- auth_login.py: device-code flow + browser open + poll loop.
- auth_signup.py: 3 modes (interactive TTY, two-step CI via
  --request-only then --code, scripted via --email + --code). Auto-create
  personal org (derive_org_name strips +subaddress, slugify_org_name
  appends 6-char random suffix).
- run_logout / run_whoami (with --json envelope).

Phase 3 — services:
- service_api.py: oRPC client (POST /rpc/{procedure_path} with
  {input: ...} body, bearer auth). HTTP 401 → SessionExpiredError.
- service_select.py: render_service_organizations + interactive picker
  + save_selected_service.
- service_claim.py: eligibility check → claim → poll-until-running
  (5min deadline, 3s poll) → save selection. Handles already_claimed,
  none_available, provisioning_timeout with --json envelopes.
- service_commands.py: list / select / claim / alias subcommands.
  * 'service list' honors --json (services array + selected flag).
  * 'service alias set <name> <service-name>' accepts the service NAME
    (matches TS); joins trailing args to allow multi-word names.
    Validates: empty / whitespace / -- prefix; rejects collisions with
    existing service names.

Phase 4 — remote + backfill + onboarding:
- workbench_api.py: workbench.query.execute oRPC client (returns
  data/meta/rows/statistics/query_id/error envelope).
- jobs_api.py: jobs.get / list / cancel.
- remote_executor.py: RemoteClickHouseClient duck-typed to
  ClickHouseClient surface (execute / query / query_json / submit /
  query_status / insert / list_schema_objects / list_table_details /
  database / __enter__ / __exit__). Lets drift/pull/migrate/query hit
  obsessiondb cloud without code changes.
- backfill_handler.py: handle_backfill_command for the
  on_before_plugin_command hook. Routes status / cancel / list to the
  jobs API; --local + --plan-id bypass.
- onboarding.py: full wizard with ConnectChoice (claim/account/
  clickhouse/later). ensure_obsessiondb_plugin_in_source text-rewrites
  clickhouse.config.py to add obsessiondb() to plugins[]. Accepts
  package_manager parameter (Python pkg-mgrs: uvx / pipx / poetry /
  rye / pip) to prefix next-steps commands.

Tests cover credentials persistence, storage round-trip, engine
rewriting variants, every auth flow (device + OTP), services list /
select / claim / alias, the Phase-4 surface (workbench RPC + jobs RPC
+ remote executor + backfill_handler dispatch matrix + onboarding
wizard branches with httpx-mock).
Python equivalent of @chkit/plugin-codegen — the TS plugin emits TypeScript
types + Zod schemas; Python emits Pydantic models (which cover both static
typing and runtime validation in one shape).

errors.py: CodegenError / CodegenConfigError / UnsupportedTypeError.

options.py: Pydantic-validated PluginConfig + CodegenOptions:
- out_file (default ./src/generated/chkit_models.py — Python convention,
  not chkit-types.ts).
- table_name_style: 'pascal' | 'camel' | 'raw'.
- bigint_mode: 'int' | 'str' (default 'int' — Python int is unbounded
  so it strictly supersedes TS bigint with no precision loss). Accepts
  TS aliases 'bigint' → 'int' and 'string' → 'str' so a TS-side
  config deserializes here.
- include_views / run_on_generate / fail_on_unsupported_type.
- CODEGEN_FLAGS + CODEGEN_FLAG_MAP for CLI plumbing.

naming.py: Pascal / camel / raw class-name styles with collision suffix
(_2, _3, ...). Non-identifier column names sanitized + aliased via
Field(..., alias='original-name').

type_artifacts.py: CH-type → Python-type resolver. Handles Nullable
/ LowCardinality (unwrap) / Array / Map / Tuple / SimpleAggregateFunction
/ JSON / every CH scalar set (LARGE_INTEGER_TYPES, NUMBER_INT_TYPES,
NUMBER_FLOAT_TYPES, STRING_TYPES, BOOLEAN_TYPES). Generates one
BaseModel per table (or TypeAlias dict[str, Any] for views when
include_views).

plugin.py: codegen() factory + ChxPlugin. ChxPluginCommand 'codegen'
with --check (returns 0 ok / 1 missing/stale) + write mode (atomic
write via temp + os.replace). on_check + on_check_report hooks emit
codegen_missing_output / codegen_stale_output / codegen_unsupported_type
findings.

Intentionally NOT ported (deferred per DRIFT.md): Zod schemas (Pydantic
already covers validation), ingest helpers (clickhouse_connect.Client.insert
+ a Pydantic model is one line at the call site), migrations module
(Python's importlib.resources is the equivalent for runtime-applied
migrations).
…atus/cancel commands)

Python port of @chkit/plugin-backfill — Phase 1 surface only.

Shipped:
- errors.py: BackfillConfigError.
- options.py: Pydantic-validated PluginConfig + PlanOptions + RunOptions
  + ResumeOptions + StatusOptions + CheckOptions. Coercion helpers
  matching TS exactly: _normalize_timestamp (ISO + UTC + ms precision),
  _normalize_target (db.table regex), parse_byte_size (K/M/G/T suffix),
  _normalize_plan_id (16-char lowercase hex), _coerce_positive_int.
  CLI flag definitions + flag mappings.
- types.py: Pydantic models for the persisted plan/run shapes
  (BackfillPlanState / BackfillRunState / BackfillStatusSummary /
  BackfillEnvironment / BackfillExecutionPlan / etc.). chunk_plan is
  intentionally an opaque dict in Phase 1 — Phase 2 will replace it
  with a typed ChunkPlan when the chunking engine is ported.
- state.py: backfill_paths (plans/ + runs/ directories under metaDir),
  compute_environment_fingerprint (SHA256 of origin|database, first
  16 chars), ensure_environment_match (refuses to apply a plan to a
  different host without --force-environment), read_plan / read_run /
  list_plan_ids / write_json. summarize_run_status counts chunks by
  status and rows_written; plan_status_for returns run.status
  verbatim (matches TS exactly — chunk-based status derivation is
  the engine's job).
- plugin.py: backfill() factory with 2 functional commands:
  * status — reads run + summarizes via summarize_run_status.
  * cancel — marks run.status='cancelled' + persists.
  4 stubs (plan / run / resume / doctor) print 'pending Phase 2' and
  exit 2. The remote path (chkit plugin backfill status --job-id /
  --service-slug) is fully functional today via obsessiondb's
  on_before_plugin_command hook.

Deferred to Phase 2 (per DRIFT.md): the chunking engine (~1,400 LoC
TS pure algorithm — strategies + services + boundary codec + SQL
builders) and the async-backfill execution engine. Together they
make 'plan / run / resume / doctor' work locally; their absence
doesn't affect the remote-backfill path which already ships.
test_parity_fixes.py (20 tests, round-1 fixes):
- #11 core public exports.
- #18 ClickHouseClient.list_schema_objects / list_table_details as methods.
- #12 ChxGetContextInput + resolve_context + dispose_context (defer chain
  + close-error swallowing).
- #2 run_on_before_apply threads transformed statements.
- #5 codegen accepts TS bigint aliases ('string' → 'str', 'bigint' → 'int').
- #6 SelectedService deserializes a TS-minimal {service_slug,
  service_name} record.
- #1 plugin dispatch short-circuits on Handled.
- #13 onboarding next-steps honors package_manager (uvx + bare-chkit
  cases).
- #4+#10 _validate_alias rejects empty / whitespace-bounded / '--' prefix.
- #17 plan_status_for returns run.status verbatim (no chunk-based
  override).
- #3 RemoteClickHouseClient has insert / list_schema_objects /
  list_table_details bound methods.
- #14+#15 generate --json on invalid schema exits 1 with structured
  envelope.
- #7 check JSON envelope contains policy + driftEvaluated + scope +
  plugins map + schema_drift finding code (via source inspection).
- #8 pull payload contains command + skippedObjects;
  _summarize_skipped_objects counts per-kind correctly.
- #9 service list --json emits not_logged_in error envelope (not bare
  text).

test_parity_round2_fixes.py (22 tests, round-2 fixes):
- #R1 canonical primary_key falls back to order_by when empty.
- #R2 MaterializedViewRefresh depends_on serializes as 'dependsOn'
  (camelCase) with no snake_case leak.
- #R4 ddl_propagation new predicates (wait_for_column_absent /
  wait_for_index{,_absent} / wait_for_projection{,_absent}) +
  _parse_operation_key recognizes index: / projection: segments +
  dispatcher routes alter_table_drop_column to column_absent, etc.
- #R5 validate.py issue codes (duplicate_column_name,
  primary_key_missing_column, order_by_missing_column,
  duplicate_object_name, refresh_every_after_mutually_exclusive,
  refresh_requires_every_or_after, refresh_depends_on_requires_every).
- #R6 snapshot round-trip via canonicalize_definition + model_dump
  (by_alias=True).
- #R7 service_claim envelope literals (already_claimed + provisioning_timeout)
  verified via source inspection.

test_sql_render_parity.py (115 tests, push to_create_sql to 10/10):
- 20 primitive types + 7 parameterized + 9 complex/nested.
- Defaults (string literal / numeric / boolean lowercase / fn: prefix
  stripping + nested calls).
- 5 codec variants + chain + nullable+codec + DEFAULT-before-CODEC
  ordering.
- Column comments with escaped quotes, nullable wrapping.
- 6 engine families, 3 PARTITION BY shapes, multi-column ORDER BY/PK.
- TTL simple + DELETE.
- SETTINGS numeric + multi-key (insertion-order preserved).
- All 5 skip-index variants + expression-arg.
- Projections simple + with ORDER BY.
- Table comments (incl. escaped quotes).
- Kitchen-sink table with every clause (22 assertions on one render).
- 25-column table + reserved-word column names (select/from/table/index
  properly backticked) + deeply nested Array(Tuple(...)).
- CREATE VIEW + CREATE MATERIALIZED VIEW (TO + REFRESH EVERY/AFTER +
  APPEND/OFFSET/RANDOMIZE/SETTINGS clause-order + DEPENDS ON +
  EMPTY).
- ALTER MODIFY REFRESH (3 variants), ALTER ADD COLUMN (6 variants),
  ALTER MODIFY COLUMN with codec + REMOVE CODEC, DROP COLUMN, ADD/DROP
  INDEX, ADD/DROP PROJECTION, MODIFY/RESET SETTING, MODIFY/REMOVE TTL.

Approach: structural assertions on the rendered SQL string (no live
ClickHouse needed). Catches every byte-level rendering regression that
would surface as drift against TS.
DRIFT.md (~700 lines): append-only ledger of every divergence from a
1:1 TS port + every audit finding's status:
- Conventions established (load-bearing decisions like the obsessiondb
  package name, --auto-deps not being portable).
- Items 'plumbed-pending-dependency' tracking.
- Refactors that touched pre-existing Python code (the cli/schema_loader
  wrapper, the mtime cache bytecode-cache bug fix).
- Bugs found while porting (status: project-scoped applied count).
- TS-only modules not ported with rationale (skill-hint, cmd-skills,
  create-chkit, the cmd-dispatch/registry/global-flags suite, etc.).
- Known limitations in ported features (backticked dotted names in pull).
- ObsessionDB plugin port status (Phase 1 → 4 narratives).
- create-chkit / rt-config / rt-exec-debug / ch/testkit explanations.
- Cross-cutting polish (gen/codegen-integration, pull/introspect-custom,
  ch/exec/* helpers, rt/user-config + rt/config-merge).
- Parity audit fixes (20-section sub-agent review) — every finding
  validated, status, fix approach.
- Round-2 audit fixes (15-section deeper-dive) — same shape.
- SQL render parity coverage push (R-l → 10/10) + post-fix score table.

PARITY-CHECKLIST.html: master tracker with three classification sets:
- PORTED_BY_DEFAULT (~150 items shipped + tested).
- DECIDED_NA (~19 items — language/ecosystem convention difference;
  each entry's rationale lives in DRIFT.md).
- DEFERRED_FUTURE_PHASE (~21 items — real work deliberately postponed).
Outstanding-work view (items in none of the three sets) is empty:
every checklist item has a decision recorded.

MISSING.md: lightweight gap notes from prior porting passes (kept for
context — most items are now resolved or tracked in DRIFT.md/HTML).
…very HTTP call

Mirrors TS `packages/plugin-obsessiondb/src/auth/api-client.ts` +
`client.ts`: replaces the bare 'chkit-cli' string with the package
version (`chkit/0.1.0`).

The ObsessionDB API forwards this header to ClickHouse, so chkit traffic
becomes attributable in `system.query_log.http_user_agent` — debugging
'what tool issued this query' goes from impossible to one filter.

Implementation:
- New module _version.py as the single source of truth for the package
  version. Lives separately to avoid the circular import that would
  happen if api_client.py imported __version__ from __init__.py (which
  re-exports api_client symbols).
- api_client.USER_AGENT = f'chkit/{_version.__version__}'.
- jobs_api + workbench_api inherit the constant automatically: they
  route HTTP through service_api._rpc_post which already sets the
  User-Agent header from USER_AGENT.

Tests verify the constant shape and observe the header on both an
`/api/auth/get-session` call and an `/rpc/services/listAll` call.
…ted ObsessionDB service

1:1 port of TS `guardRemoteExecution` in
`packages/plugin-obsessiondb/src/backfill/handler.ts` (commit bb62cd0).

The problem: when a user is logged in AND has a service selected (the
condition under which getContext hands out the remote executor),
`chkit plugin backfill {plan,run,resume}` would silently fall through
to the local backfill Phase-2 stub. The user thinks they're running
against ObsessionDB but they aren't — at best confusing, at worst
(if Phase 2 ships before the remote execution path) exfiltrating their
intended-cloud queries to whatever `CLICKHOUSE_URL` happens to be set.

Fix: add `_guard_remote_execution` to backfill_handler. It runs
BEFORE the existing remote-subcommand routing — if the command is in
{plan, run, resume} AND creds are present AND a service is selected
(either via --service flag or a stored SelectedService for the
project), refuse with a clear message that nudges the user toward
--local or to unselect the service. Emits an {ok: false, command,
error} envelope under json_mode, plain text otherwise.

Honors the existing --local early-return. `doctor` is intentionally
NOT guarded (it reads local state).
…oami envelope

1:1 port of TS `packages/plugin-obsessiondb/src/json-envelope.ts`:
centralises the shape of every JSON payload the plugin emits so
`jq` / CI consumers can rely on a stable schema across versions.

New module json_envelope.py exports:
- JSON_CONTRACT_VERSION = 1 (bump on incompatible envelope changes).
- error_envelope(command, code, message) → {command, schemaVersion,
  ok: false, error: {code, message}}.
- whoami_envelope(*, email, name?) → {command, schemaVersion,
  status: 'logged_in', email, next: null}. The TS envelope drops the
  name field; we match that. `name` is accepted for forward-compat.
- service_list_envelope(services) → {command, schemaVersion,
  status: 'ok', services}.
- Typed via TypedDicts so consumers get static-type guarantees.

Refactors:
- auth_login.run_whoami uses error_envelope + whoami_envelope instead
  of the ad-hoc dicts it built before. Public --json shape now matches
  TS exactly.
- service_commands._service_list migrated to the same helpers. The
  audit-fix-#9 envelope had emitted {status: 'error', errorCode,
  message} (non-TS-aligned); the helper aligns it to
  {ok: false, error: {code, message}}.

Updated 2 existing tests + added 14 new tests in
test_main_sync_2026_06_29.py covering all three #M1/#M2/#M3 ports.
…tive

test_main_sync_2026_06_29.py groups tests by sync finding:

#M1 (User-Agent, 3 tests):
- Constant equals chkit/<plugin-version>.
- get_session HTTP call carries the header.
- service_api RPC POST carries the header (transitive coverage for
  jobs_api + workbench_api which both route through _rpc_post).

#M2 (backfill guard, 7 tests):
- Unhandled when not authenticated.
- Unhandled when authed but no service selected (user hasn't opted in).
- Handled+exit-1 when authed + service stored on disk.
- Handled+exit-1 when --service flag is passed.
- json_mode emits {ok: false, command: 'backfill ...', error: ...}.
- --local flag bypasses the guard.
- doctor is NOT guarded (it reads local state).

#M3 (json_envelope helpers, 4 tests):
- error_envelope shape (matches TS errorEnvelope).
- whoami_envelope shape (status/email/next, no name).
- whoami_envelope ignores name for forward-compat.
- service_list_envelope shape.

DRIFT.md gets a new 'Main sync 2026-06-29' section detailing every TS
commit reviewed: 3 ports, 7 already-in-Python items, 3 decided-N/A
items. Includes the TS-reference rationale + the bonus
service_commands._service_list envelope realignment to TS shape.
Ports TS commit 6b87e6d (packages/core/src/on-cluster.ts + model
changes) to Python. Enables self-managed multi-node ClickHouse clusters:
setting `clickhouse.cluster` in the resolved config causes every DDL
statement in the migration plan to be stamped with `ON CLUSTER <name>`
as a final post-pass.

- `chkit/core/on_cluster.py`: new module mirroring on-cluster.ts.
  Two anchor tables — after-object placement (CREATE/ALTER/DROP TABLE,
  CREATE VIEW/MV/DATABASE/DICTIONARY, DROP VIEW/DICTIONARY, plus a
  forward-compat safety net for CREATE FUNCTION, DROP DATABASE,
  ATTACH/DETACH/TRUNCATE/OPTIMIZE TABLE) and trailing-anchor placement
  (RENAME TABLE/DICTIONARY/DATABASE, EXCHANGE TABLES/DICTIONARIES).
  Trailing loop runs first so RENAME appends at end, not after first
  name. Skips optional IF [NOT] EXISTS guard so the clause lands after
  the object reference either way. Idempotency is checked positionally
  (against the slice after the object reference), so user-authored
  content like a column COMMENT containing "on cluster" cannot suppress
  injection.

- `chkit/core/model.py`: adds `cluster: str | None` field to
  `ChxUserClickHouseConfig` and `ChxResolvedClickHouseConfig` (declared
  after `secure` for canonical serialization parity with TS). New
  private `_CLUSTER_NAME_PATTERN` accepts identifiers with dashes/dots
  (e.g. `prod-eu-1`, `eu.west.main`) and `{cluster}`-style macros — the
  characters legal in a `remote_servers` XML key, injection-safe inside
  single quotes. Uses `re.fullmatch` (not `re.match`) so a multi-line
  value like `"prod\nDROP TABLE x"` cannot slip past a start-only
  anchor. Error message matches the TS literal so existing test regexes
  continue to work.

- Re-exports `apply_on_cluster_to_plan` and `on_cluster_clause` from
  `chkit.core` and top-level `chkit`.

Tests: 19-case `test_on_cluster.py` mirroring on-cluster.test.ts
case-by-case, plus 2 Python-specific regression guards (empty-string
cluster short-circuit; multi-line name rejected by `fullmatch`).
…licated journal

Ports the CLI-side of TS commit 6b87e6d — the journal-store rewrite and
the 4 command call sites that pass `clickhouse.cluster` through.

- `chkit/cli/journal_store.py`: `JournalStore.__init__` takes optional
  `cluster: str | None`. When set, the `_chkit_migrations` engine
  becomes `ReplicatedReplacingMergeTree('/clickhouse/tables/{uuid}/chkit_journal',
  '{shard}_{replica}', applied_at)` — no-`{shard}` Keeper path (one
  cluster-wide replication group), `{uuid}` for drop-recreate safety,
  `{shard}_{replica}` unique cluster-wide so multi-shard layouts don't
  collide. CREATE TABLE + both ALTER TABLE ADD COLUMN IF NOT EXISTS
  statements carry `ON CLUSTER '<name>'`. Non-cluster mode unchanged.

- `chkit/cli/commands/generate.py`: calls `apply_on_cluster_to_plan`
  after `run_on_plan_created` (so plugin-injected SQL is also stamped)
  and before the empty-plan check. `migrate` never re-runs this — the
  clause is baked into the migration file at generate time and re-executed
  verbatim.

- `chkit/cli/commands/migrate.py`, `status.py`, `check.py`: each pass
  `cluster=config.clickhouse.cluster if config.clickhouse else None` to
  `JournalStore(...)`. Python has 3 journal-store sites (TS has 4; TS
  `generate` uses the journal for a schema-existence probe that Python
  does not).

Tests:
- `test_journal_store_cluster.py` (5 tests): CREATE TABLE stamping
  and engine switch under cluster mode, both ALTERs stamped, plain
  engine when cluster absent, `{cluster}` macro form.
- `test_on_cluster_generate_e2e.py` (3 tests): runs `chkit generate`
  (JSON dryrun + file write) with `clickhouse.cluster` set/unset;
  asserts every DDL line carries or omits the clause accordingly.

Overall: 970 pytest passed, 1 skipped, 0 failed. Mypy clean on all
new code; ruff clean on all new code (3 pre-existing PLR0917
warnings on run() signatures unchanged).
Records the ClickHouse ON CLUSTER port from TS commit 6b87e6d in the
same shape as the 2026-06-29 sync entry:

- #C1 apply_on_cluster_to_plan (new core module)
- #C2 clickhouse.cluster config field + validation
- #C3 journal store cluster mode (ReplicatedReplacingMergeTree)
- #C4 generate command integration

Plus:
- What tests were added and where
- What was deferred (docker-based live 2-node cluster e2e — behavior
  parity is covered by unit + integration tests)
- Design divergences (dictionary op types in tests, no debug logging)
- A follow-up parity fix surfaced by the Category C reviewer but not
  addressed here: pre-existing `generate.py` plan-transform ordering
  divergence — Python does `filter -> plugin` where TS does
  `plugin -> filter`. Predates this port (from commit ad94a16); tracked
  for a future parity pass.
- The remaining commits from main-sync 2026-07-02 not touched in this
  pass (Category A bug-fix trio, Category B Dictionary primitive,
  Category D backfill submit, Phase-2 backfill fixes, TS-only refactors).
Ports the runtime-agnostic primitives from TS
`packages/clickhouse/src/e2e-testkit.ts` +
`packages/cli/src/test/e2e-testkit.ts` to a single Python module.

- `LiveEnv` dataclass + `get_required_env` (hard-fail variant) +
  `resolve_live_env` (soft-default variant for dev docker).
- `live_env_to_client_kwargs` — bridge to `clickhouse_connect.get_client`,
  parses URL into host/port/secure.
- `quote_ident` — backtick-doubling identifier quoter.
- `create_run_tag` / `create_prefix(label)` / `create_journal_table_name(label)`
  — unique-name builders for parallel-safe live tests; prefers
  `GITHUB_RUN_ID` for CI correlation, falls back to `<ms>_<rand>`.
- `format_test_diagnostic` — structured CLI-failure message accepting any
  `exit_code`/`output` result (Protocol-typed so callers don't need to
  import typer).

`tests/conftest.py` now uses the testkit for env resolution + client
kwargs — dedupes the inline `_resolve_clickhouse_env` block that was
partially reimplementing the TS helper.

Deliberately NOT ported: TS `runCli` / `runCliWithRetry` /
`waitForCliJson` — Python convention is in-process `typer.testing.CliRunner`,
not subprocess spawn.

Tests: 17 unit cases covering env resolution (hard-fail, soft-default,
URL derivation, all-overrides), URL parsing (https default port, http
default port, explicit port), ident quoting (backtick doubling), unique
names (RNG-collision guard on 100 tags, GITHUB_RUN_ID preference,
per-call divergence), and diagnostic formatting.

`987 passed, 1 skipped, 0 failed`. Mypy + ruff clean on all new files
(the 2 pre-existing conftest mypy warnings are unrelated).
Both files were derived from a 2026-06-05 pre-port audit. 8+ port
passes have landed since then and nearly every "Critical" item they
flag as missing is present:

- `@chkit/core`: config_path, plugin_error, schema_loader, ts_import
  ✓ ported
- `drift`: drift.py + drift_compare.py + drift_diff.py + drift_payload.py
  ✓ ported (the "big missing piece" claim is stale)
- `pull`: pull.py + pull_render.py + pull_view_parser.py ✓ ported
- `query`: query.py ✓ ported
- `plugin`: plugin.py ✓ ported
- Runtime: plugin_runtime, safety_markers, migration_metadata,
  migrate_async_apply, table_scope, config_merge, user_config,
  json_output ✓ all present
- `@chkit/clickhouse`: create_table_parser, ddl_propagation, introspect
  ✓ all present
- Plugins: chkit_plugin_codegen, chkit_plugin_backfill,
  chkit_plugin_obsessiondb ✓ all present; plugin_pull merged into
  cli/commands/pull.py + on_pull_introspect hook

The remaining genuine non-parity items (`chkit skills`,
`create-chkit`, `deps.ts` auto-install, `internal-plugins/skill-hint`)
are explicitly documented as won't-port in DRIFT.md — `cmd-skills`,
`create-chkit`, `--auto-deps`, `rt/skill-hint-*`.

DRIFT.md is now the single source of truth for known divergences and
non-parity decisions. PARITY-CHECKLIST.html's per-item localStorage
state carries no meaning once items are actually ported, and the page
actively misleads (everything renders as unchecked-missing).

No incoming references outside these two files themselves.
Same disease as MISSING.md — the 2026-06-05 audit is stale enough that
90% of items it flags "Deferred" are actually ported now. The README's
"What is intentionally out of scope for this first base" bullet list
was equally stale (plugins ✓, --table ✓, --rename-table ✓, drift live
✓, per-op journal tracking ✓, ObsessionDB credentials ✓, etc.) and
existed only to anchor two PARITY.md links.

- Delete PARITY.md.
- Rewrite the README TypeScript-parity section:
  - Drop the "271 ported tests" figure (out of date; the suite is 987).
  - List the actual current coverage: all CLI commands, plugin runtime
    + all hooks by real name, first-party plugins, cluster mode.
  - Replace the stale "out of scope" list with a "Not ported by
    design" list of the 4 genuine won't-port items (skills / create-chkit
    / auto-deps / skill-hint), each cross-referenced in DRIFT.md.
  - Point to DRIFT.md as the source of truth for divergences.

CHANGELOG.md references to PARITY.md are historical (they described what
0.1.4 added on 2026-06-05) and stay as history. DRIFT.md never
referenced PARITY.md.
Closes every remaining gap between chkit-py and the TS implementation:

- Dictionary primitive (65c90d6): DSL, validation, CREATE/REPLACE/RENAME/
  DROP planning with [HIDDEN]-password handling, create-dictionary parser,
  --rename-dictionary, safety markers, drift, pull, codegen models.
- Category A trio: function expressions in primaryKey/orderBy (5a8d805),
  index-only projections (3f1db03), table-clause parsing past the column
  list + derived-PK drift fix (8296b8a).
- Phase-2 backfill engine: chunking planner/SQL/strategies, async
  execution loop with checkpointing, plan/run/resume/doctor commands,
  managed submit to ObsessionDB jobs, on_check findings. Includes
  f85f568, 3f9a246, 9ad23f9.
- CLI surface: top-level chkit codegen + chkit obsessiondb shortcuts,
  plugin dispatcher flag forwarding, function-style configs
  (ChxConfigEnv), check.failOnExtraObjects, per-table plugins field.
- Fixes surfaced by parity reviewers: JS Number()/String() fidelity,
  URL.origin env fingerprints, atomic checkpoint writes, snapshot
  exclude_none for TS interop, wheel packaging of all plugin packages,
  ClickHouseClient.submit query_id crash.

All ported 1:1 with tests (1185+ unit, e2e validated against live
ClickHouse); mypy --strict and ruff clean. Full decision log appended
to DRIFT.md.
Every page with code now documents TypeScript and Python side by side
using Starlight synced tabs (syncKey) — pick a language once and the
whole site follows: schema DSL reference, schema/config overviews,
refreshable views, tutorial, all plugin pages, ObsessionDB pages,
CI/CD guide (dual GitHub Actions/GitLab/shell pipelines),
troubleshooting, and the CLI plugin page.

New Python section (overview + core API); python/schema-dsl dissolved
into the tabbed DSL reference. Positioning updated everywhere chkit was
described as TypeScript-only: hero (removes the stale Python 'Coming
soon' badge), footer, landing tagline, llms.txt/sitemap tagline,
ai-agents runbook (adds the Python branch), CLI/guide prose, and both
READMEs. Every Python example was verified by executing it against
chkit-py.

Also: raw-markdown integration fixed for Windows (URL.pathname drive
prefix, backslash slugs) and dead 'coming soon' CSS removed.
…orkflow

Bumps chkit-py to 0.2.0 (dictionary primitive, Phase-2 backfill engine,
CLI shortcuts, dynamic configs, packaging fix — see CHANGELOG.md).

Adds a tag-triggered publish workflow (chkit-py-v*) using PyPI trusted
publishing (OIDC): build + twine check + clean-venv wheel smoke, then
publish. Requires one-time trusted-publisher setup on pypi.org.
@Lucasgvdii Lucasgvdii changed the title First python port chkit-py: full parity with TypeScript chkit + dual-language docs Aug 10, 2026
Comment on lines +14 to +52
runs-on: ubuntu-latest
defaults:
run:
working-directory: chkit_python
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Verify tag matches package version
run: |
version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
tag="${GITHUB_REF_NAME#chkit-py-v}"
if [ "$version" != "$tag" ]; then
echo "Tag $GITHUB_REF_NAME does not match pyproject version $version" >&2
exit 1
fi

- name: Build sdist and wheel
run: |
python -m pip install build twine
python -m build
python -m twine check dist/*

- name: Smoke-test the wheel
run: |
python -m venv /tmp/wheeltest
/tmp/wheeltest/bin/pip install dist/*.whl
/tmp/wheeltest/bin/python -c "import chkit, chkit_plugin_backfill, chkit_plugin_codegen, chkit_plugin_obsessiondb; print(chkit.__version__)"
/tmp/wheeltest/bin/chkit --version

- uses: actions/upload-artifact@v4
with:
name: chkit-py-dist
path: chkit_python/dist/

publish:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants