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
12 changes: 12 additions & 0 deletions packages/python/src/synapt/extract/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -128,4 +139,5 @@
"BatchUnit",
"BatchUnitResult",
"extract_batch",
"__version__",
]
2 changes: 2 additions & 0 deletions packages/ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
18 changes: 18 additions & 0 deletions packages/ts/src/version.ts
Original file line number Diff line number Diff line change
@@ -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";
58 changes: 58 additions & 0 deletions packages/ts/tests/test_version.ts
Original file line number Diff line number Diff line change
@@ -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+$/);
});
});
125 changes: 125 additions & 0 deletions scripts/bump-version.sh
Original file line number Diff line number Diff line change
@@ -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 <version|patch|minor|major>"
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"
86 changes: 86 additions & 0 deletions tests/python/test_version.py
Original file line number Diff line number Diff line change
@@ -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__
Loading