diff --git a/packages/python/src/synapt/extract/__init__.py b/packages/python/src/synapt/extract/__init__.py index 1394933..8bcfd04 100644 --- a/packages/python/src/synapt/extract/__init__.py +++ b/packages/python/src/synapt/extract/__init__.py @@ -1,5 +1,16 @@ """synapt-extract: SynaptExtraction IL v1 schema, validation, and finalization.""" +#: The version of this package, available at runtime. +#: +#: Consumers that record which extractor produced a document should read this +#: rather than hand-copying a version string, so the recorded value is evidence +#: of what ran instead of a claim about it. +#: +#: Kept in step with ``packages/python/pyproject.toml`` and the TypeScript +#: package; ``scripts/bump-version.sh`` updates all three and +#: ``tests/python/test_version.py`` fails if any one of them drifts. +__version__ = "0.6.0" + from synapt.extract.schema import ( SynaptExtraction, SynaptEntity, @@ -128,4 +139,5 @@ "BatchUnit", "BatchUnitResult", "extract_batch", + "__version__", ] diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 6dede24..0ed2da8 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -19,6 +19,8 @@ export type { } from "./schema.js"; export { EXTRACTION_CAPABILITIES } from "./schema.js"; +export { VERSION } from "./version.js"; + export { validateExtraction } from "./validate.js"; export type { ValidationResult, ValidationError } from "./validate.js"; diff --git a/packages/ts/src/version.ts b/packages/ts/src/version.ts new file mode 100644 index 0000000..c923774 --- /dev/null +++ b/packages/ts/src/version.ts @@ -0,0 +1,18 @@ +/** + * The version of this package, available at runtime. + * + * Consumers that record which extractor produced a document should read this + * rather than hand-copying a version string, so the recorded value is evidence + * of what ran instead of a claim about it. + * + * Deliberately a literal and not a read of `package.json`: the default entry + * has to stay importable in browser and WASM hosts, and + * `scripts/check-ts-universal-entry.mjs` fails the build if it reaches for a + * Node built-in. `tests/test_version.ts` ties this constant to the manifest and + * to the Python distribution, so a one-sided edit fails there rather than + * shipping. + * + * Keep in step with `packages/ts/package.json` and + * `packages/python/pyproject.toml`; `scripts/bump-version.sh` updates all three. + */ +export const VERSION = "0.6.0"; diff --git a/packages/ts/tests/test_version.ts b/packages/ts/tests/test_version.ts new file mode 100644 index 0000000..bff41f2 --- /dev/null +++ b/packages/ts/tests/test_version.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { VERSION } from "../src/index.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); + +function packageJsonVersion(...parts: string[]): string { + const pkg = JSON.parse(readFileSync(resolve(...parts), "utf-8")) as { version?: string }; + if (typeof pkg.version !== "string") throw new Error(`no version in ${resolve(...parts)}`); + return pkg.version; +} + +/** The `version = "x.y.z"` line from a pyproject, without adding a TOML dependency. */ +function pyprojectVersion(path: string): string { + const match = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(path, "utf-8")); + if (match === null) throw new Error(`no version line in ${path}`); + return match[1]; +} + +/** + * WHY THIS FILE EXISTS + * + * A consumer that records which extractor produced a document needs to obtain + * the version FROM the package. Before `VERSION` existed there was no way to, + * so the only option we offered was hand-copying a string into a constant -- + * and a hand-copied version is a claim about the runtime, not evidence of it. + * A downstream consumer did exactly that and its copy went stale (declared + * 0.5.0 against a 0.6.0 runtime) with nothing able to detect it. + * + * `VERSION` is a literal rather than a read of package.json on purpose: the + * default entry must stay importable in browser/WASM hosts, and + * `scripts/check-ts-universal-entry.mjs` fails the build if it reaches for a + * Node built-in. That is the same trade the embedded prompt fragments make, so + * it carries the same obligation -- an embedded copy needs a test that fails + * when it drifts from its source. That is what this is. + */ +describe("VERSION", () => { + test("matches the TypeScript package manifest", () => { + expect(VERSION).toBe(packageJsonVersion(REPO_ROOT, "packages", "ts", "package.json")); + }); + + test("matches the Python distribution version", () => { + // The two language surfaces ship as one product at one version. Nothing + // enforced that before this test: they were two hand-edited numbers that + // happened to agree. + expect(VERSION).toBe(pyprojectVersion(resolve(REPO_ROOT, "packages", "python", "pyproject.toml"))); + }); + + test("is a bare semver triple, not a range or a specifier", () => { + // Guards the shape a consumer stamps into provenance. "^0.6.0" or + // "@synapt-dev/extract@0.6.0" would each be a plausible thing to paste + // here and each would corrupt the recorded value. + expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..8662850 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Bump the synapt-extract version across every place it is written. +# +# Usage: +# ./scripts/bump-version.sh 0.7.0 +# ./scripts/bump-version.sh patch # 0.6.0 -> 0.6.1 +# ./scripts/bump-version.sh minor # 0.6.0 -> 0.7.0 +# ./scripts/bump-version.sh major # 0.6.0 -> 1.0.0 +# +# WHY THIS EXISTS +# +# The version is written in FOUR places: the two package manifests and the two +# runtime constants that let a consumer read the version instead of hand-copying +# it. Four hand-edited numbers is precisely the shape that drifts, and a stale +# version is not a cosmetic problem here -- it is recorded into downstream +# provenance as evidence of what produced a document. +# +# `tests/python/test_version.py` and `packages/ts/tests/test_version.ts` fail +# when any one of the four drifts. This script is how you avoid tripping them. +# +# It deliberately does NOT commit, tag, or push. Under the dev/main branching +# model a tag belongs to the release ceremony on `main`, not to whatever branch +# happens to be checked out when someone bumps a number. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +PYPROJECT="$REPO_ROOT/packages/python/pyproject.toml" +INIT_PY="$REPO_ROOT/packages/python/src/synapt/extract/__init__.py" +TS_PKG="$REPO_ROOT/packages/ts/package.json" +TS_VERSION="$REPO_ROOT/packages/ts/src/version.ts" + +for f in "$PYPROJECT" "$INIT_PY" "$TS_PKG" "$TS_VERSION"; do + [ -f "$f" ] || { echo "Error: missing $f" >&2; exit 1; } +done + +# --- Read current version (pyproject is the reference) --- +CURRENT=$(grep '^version = ' "$PYPROJECT" | head -1 | sed 's/version = "\(.*\)"/\1/') +if [ -z "$CURRENT" ]; then + echo "Error: could not read version from $PYPROJECT" >&2 + exit 1 +fi + +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + +ARG="${1:-}" +if [ -z "$ARG" ]; then + echo "Current version: $CURRENT" + echo "" + echo "Usage: $0 " + echo " $0 patch -> $MAJOR.$MINOR.$((PATCH + 1))" + echo " $0 minor -> $MAJOR.$((MINOR + 1)).0" + echo " $0 major -> $((MAJOR + 1)).0.0" + echo " $0 0.7.0 -> 0.7.0" + exit 0 +fi + +case "$ARG" in + patch) NEW="$MAJOR.$MINOR.$((PATCH + 1))" ;; + minor) NEW="$MAJOR.$((MINOR + 1)).0" ;; + major) NEW="$((MAJOR + 1)).0.0" ;; + *) + if ! echo "$ARG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Error: '$ARG' is not a bare semver triple (x.y.z) or patch/minor/major." >&2 + echo "A range or a specifier here would be written into consumer provenance." >&2 + exit 1 + fi + NEW="$ARG" + ;; +esac + +echo "Bumping $CURRENT -> $NEW" + +# --- Apply. Each pattern is anchored so it cannot match a dependency's version. +python3 - "$NEW" "$PYPROJECT" "$INIT_PY" "$TS_PKG" "$TS_VERSION" <<'PY' +import re +import sys + +new, pyproject, init_py, ts_pkg, ts_version = sys.argv[1:6] + +def sub(path, pattern, replacement): + text = open(path, encoding="utf-8").read() + updated, n = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if n != 1: + raise SystemExit(f"Error: expected exactly 1 version match in {path}, found {n}") + open(path, "w", encoding="utf-8").write(updated) + +sub(pyproject, r'^version = "[^"]+"', f'version = "{new}"') +sub(init_py, r'^__version__ = "[^"]+"', f'__version__ = "{new}"') +sub(ts_pkg, r'^( "version": )"[^"]+"', rf'\g<1>"{new}"') +sub(ts_version, r'^export const VERSION = "[^"]+"', f'export const VERSION = "{new}"') +PY + +# --- Verify by fruit. The point of this script is that four numbers agree, so +# --- it checks that they do rather than reporting success for having run. +echo "" +echo "Verifying all four locations:" +FAIL=0 +check() { + local label="$1" actual="$2" + printf " %-34s %s" "$label" "$actual" + if [ "$actual" = "$NEW" ]; then echo " ok"; else echo " MISMATCH (expected $NEW)"; FAIL=1; fi +} +check "pyproject.toml" "$(grep '^version = ' "$PYPROJECT" | head -1 | sed 's/version = "\(.*\)"/\1/')" +check "python __init__.py" "$(grep '^__version__ = ' "$INIT_PY" | head -1 | sed 's/__version__ = "\(.*\)"/\1/')" +check "ts package.json" "$(grep '^ "version": ' "$TS_PKG" | head -1 | sed 's/.*: "\(.*\)".*/\1/')" +check "ts src/version.ts" "$(grep '^export const VERSION = ' "$TS_VERSION" | sed 's/.*"\(.*\)".*/\1/')" + +if [ "$FAIL" -ne 0 ]; then + echo "" + echo "Error: the four locations do not agree. Nothing was committed; fix before proceeding." >&2 + exit 1 +fi + +echo "" +echo "All four agree at $NEW. Next:" +echo " 1. REINSTALL the python package first: pip install -e packages/python" +echo " (test_installed_distribution_agrees... compares importlib.metadata against" +echo " the source constant, so it correctly reports RED until the installed" +echo " distribution is rebuilt at $NEW. That is the check doing its job, not a bug.)" +echo " 2. run the suites (pytest tests/python && cd packages/ts && npm test)" +echo " 3. PR the bump into dev" +echo " 4. release ceremony merges dev -> main, then 'gh release create v$NEW' cuts the tag" diff --git a/tests/python/test_version.py b/tests/python/test_version.py new file mode 100644 index 0000000..09d8534 --- /dev/null +++ b/tests/python/test_version.py @@ -0,0 +1,86 @@ +"""The runtime version is evidence, not a claim. + +WHY THIS FILE EXISTS + +A consumer that records which extractor produced a document needs to obtain the +version FROM the package. Before ``__version__`` existed there was no way to, so +the only option we offered was hand-copying a string -- and a hand-copied +version is a claim about the runtime rather than evidence of it. A downstream +consumer did exactly that and its copy went stale (declared 0.5.0 against a +0.6.0 runtime) with nothing able to detect the drift. + +These tests tie every place the version is written to every other place, so any +single-sided edit fails here instead of shipping. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages" / "python" / "src")) + +import synapt.extract +from synapt.extract import __version__ + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "packages" / "python" / "pyproject.toml" +TS_PACKAGE_JSON = REPO_ROOT / "packages" / "ts" / "package.json" + +_SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def _pyproject_version() -> str: + match = re.search(r'^version\s*=\s*"([^"]+)"', PYPROJECT.read_text(), re.MULTILINE) + assert match is not None, f"no version line in {PYPROJECT}" + return match.group(1) + + +def _ts_package_version() -> str: + import json + + return json.loads(TS_PACKAGE_JSON.read_text())["version"] + + +def test_version_matches_the_distribution_metadata(): + assert __version__ == _pyproject_version() + + +def test_version_matches_the_typescript_package(): + """The two language surfaces ship as one product at one version. + + Nothing enforced this before: they were two hand-edited numbers that + happened to agree.""" + assert __version__ == _ts_package_version() + + +def test_version_is_exported_from_the_package_root(): + """A consumer must reach it without knowing the internal module layout.""" + assert synapt.extract.__version__ == __version__ + + +def test_version_is_a_bare_semver_triple(): + """Guards the shape stamped into provenance. A range (">=0.6.0") or a full + specifier ("@synapt-dev/extract@0.6.0") are each a plausible thing to paste + into this constant, and each would corrupt the recorded value.""" + assert _SEMVER.match(__version__), f"not a bare semver triple: {__version__!r}" + + +def test_installed_distribution_agrees_when_the_package_is_installed(): + """importlib.metadata reads what pip actually installed, which is a + genuinely different source than the literal in __init__.py -- so this + catches a stale editable install or a version-bump that never got reinstalled, + which the other assertions here cannot see. + + Skipped rather than failed when the package is imported from a source tree + with no installed distribution, since that is a legitimate way to use it. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + installed = version("synapt-extract") + except PackageNotFoundError: + pytest.skip("synapt-extract is not installed as a distribution") + assert installed == __version__