From 8622165443ba2f4b5bd848b83dd71c86d965dc97 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 2 Oct 2026 22:11:09 -0400 Subject: [PATCH 1/5] OpenConceptLab/ocl_online#247 | repo version-create --match-algorithms, and a warning on version-update --match-algorithms - `repo version-create --match-algorithms es,llm` sets the new version's match algorithms in the create request. Without it the request leaves them out, so the server decides (a new source version is vectorized when HEAD or the latest release is). - `repo version-update --match-algorithms` warns on stderr that changing an existing version's match algorithms makes the server reindex its concepts, and points to version-create instead. - README: the vectorized-release example uses version-create. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VRGfMH9ZR6TGNMSfiPyUaF --- README.md | 11 +++-- src/ocl_cli/api_client.py | 9 +++- src/ocl_cli/commands/repo.py | 27 ++++++++++- tests/test_repo_commands.py | 92 ++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 tests/test_repo_commands.py diff --git a/README.md b/README.md index 8090ee9..5cf31f5 100644 --- a/README.md +++ b/README.md @@ -161,12 +161,17 @@ ocl repo versions OWNER REPO [--type source|collection] [--updated-since YYYY-MM # Create & update ocl repo create OWNER REPO_ID NAME --type source|collection [options] ocl repo update OWNER REPO [--name NAME] [--description DESC] -ocl repo version-create OWNER REPO VERSION_ID [--released/--no-released] +ocl repo version-create OWNER REPO VERSION_ID [--released/--no-released] [--match-algorithms es,llm] ocl repo version-update OWNER REPO VERSION_ID [--released/--no-released] [--match-algorithms es,llm] -# Enable vectorized matching on a new release -ocl repo version-update CIEL CIEL v2026-03-23 --match-algorithms es,llm +# Create a vectorized release (semantic matching). Without --match-algorithms, the server +# vectorizes a new source version when its HEAD or latest release is vectorized. +ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es,llm +``` + +Set `--match-algorithms` when you create a version rather than with `version-update` afterwards. Changing an existing version's match algorithms makes the server reindex its concepts: adding `llm` embeds them, which can take hours for a large repository, and removing it can drop their vectors. `version-update --match-algorithms` prints a warning to that effect. +```bash # Custom attributes ocl repo extras OWNER REPO ocl repo extra-set OWNER REPO KEY VALUE diff --git a/src/ocl_cli/api_client.py b/src/ocl_cli/api_client.py index 83b3d1d..8ef99ad 100644 --- a/src/ocl_cli/api_client.py +++ b/src/ocl_cli/api_client.py @@ -914,13 +914,20 @@ def create_repo_version( repo_type: str = "source", description: Optional[str] = None, released: bool = True, + match_algorithms: Optional[list[str]] = None, ) -> dict: - """Create a new repository version (snapshot).""" + """Create a new repository version (snapshot). + + Without match_algorithms, the server decides: a new source version is vectorized + (``llm``) when the source's HEAD or its latest release is. + """ self._require_auth() endpoint = _build_repo_endpoint(owner_type, owner, repo_type, repo, suffix="versions/") body: dict[str, Any] = {"id": version_id, "released": released} if description: body["description"] = description + if match_algorithms is not None: + body["match_algorithms"] = match_algorithms return self.post(endpoint, json=body) def update_repo_version( diff --git a/src/ocl_cli/commands/repo.py b/src/ocl_cli/commands/repo.py index 0e674dc..61beb78 100644 --- a/src/ocl_cli/commands/repo.py +++ b/src/ocl_cli/commands/repo.py @@ -169,6 +169,20 @@ def update(ctx, owner, repo_name, repo_type, owner_type, new_name, description, handle_api_error(e) +VERSION_UPDATE_MATCH_ALGORITHMS_WARNING = ( + "Warning: changing an existing version's match algorithms makes the server reindex its " + "concepts. Adding llm embeds them, which can take hours for a large repository; removing it " + "can drop their vectors. To vectorize a release, set it when you create the version instead: " + "repo version-create --match-algorithms es,llm" +) + + +def _split_match_algorithms(value): + if value is None: + return None + return [algorithm.strip() for algorithm in value.split(",") if algorithm.strip()] + + @repo.command("version-create") @click.argument("owner") @click.argument("repo_name") @@ -177,14 +191,22 @@ def update(ctx, owner, repo_name, repo_type, owner_type, new_name, description, @click.option("--owner-type", type=click.Choice(["users", "orgs"]), default="orgs") @click.option("--description", help="Version description") @click.option("--released/--no-released", default=True, help="Mark as released (default: true)") +@click.option( + "--match-algorithms", default=None, + help="Comma-separated match algorithms (e.g. es,llm). Omit to let the server decide: a new " + "source version is vectorized when HEAD or the latest release is.", +) @click.pass_context -def version_create(ctx, owner, repo_name, version_id, repo_type, owner_type, description, released): +def version_create( + ctx, owner, repo_name, version_id, repo_type, owner_type, description, released, match_algorithms +): """Create a new repository version (snapshot).""" client = ctx.obj["client"] try: result = client.create_repo_version( owner, repo_name, version_id, owner_type=owner_type, repo_type=repo_type, description=description, released=released, + match_algorithms=_split_match_algorithms(match_algorithms), ) output_result(ctx, result, format_repo_detail) except APIError as e: @@ -211,7 +233,8 @@ def version_update(ctx, owner, repo_name, version_id, repo_type, owner_type, des if released is not None: fields["released"] = released if match_algorithms is not None: - fields["match_algorithms"] = [a.strip() for a in match_algorithms.split(",")] + fields["match_algorithms"] = _split_match_algorithms(match_algorithms) + click.echo(VERSION_UPDATE_MATCH_ALGORITHMS_WARNING, err=True) result = client.update_repo_version( owner, repo_name, version_id, owner_type=owner_type, repo_type=repo_type, **fields, diff --git a/tests/test_repo_commands.py b/tests/test_repo_commands.py new file mode 100644 index 0000000..8740adb --- /dev/null +++ b/tests/test_repo_commands.py @@ -0,0 +1,92 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from click.testing import CliRunner + +from ocl_cli.api_client import OCLAPIClient +from ocl_cli.main import cli + + +class FakeClient: + def __init__(self): + self.calls = [] + + def create_repo_version(self, *args, **kwargs): + self.calls.append(("create_repo_version", args, kwargs)) + return {"id": args[2], "version": args[2]} + + def update_repo_version(self, *args, **kwargs): + self.calls.append(("update_repo_version", args, kwargs)) + return {"id": args[2], "version": args[2]} + + def close(self): + pass + + +class FakeConfig: + def get_server(self, server_id): + return SimpleNamespace(base_url="https://api.example.test") + + def resolve_token(self, server, token_override=None): + return token_override + + +class RepoVersionCommandTest(unittest.TestCase): + def invoke(self, *args): + client = FakeClient() + with ( + patch("ocl_cli.main.CLIConfig.load", return_value=FakeConfig()), + patch("ocl_cli.main.OCLAPIClient", return_value=client), + ): + result = CliRunner().invoke(cli, ["--json", "repo", *args]) + self.assertEqual(result.exit_code, 0, result.output) + return client, result + + def test_version_create_sends_match_algorithms(self): + client, _ = self.invoke( + "version-create", "Regenstrief", "LOINC", "2.82", "--match-algorithms", "es, llm" + ) + + [(_, args, kwargs)] = client.calls + self.assertEqual(args, ("Regenstrief", "LOINC", "2.82")) + self.assertEqual(kwargs["match_algorithms"], ["es", "llm"]) + + def test_version_create_without_match_algorithms_lets_the_server_decide(self): + client, result = self.invoke("version-create", "CIEL", "CIEL", "v2026-10-01") + + [(_, _, kwargs)] = client.calls + self.assertIsNone(kwargs["match_algorithms"]) + self.assertEqual(result.stderr, "") + + def test_version_update_match_algorithms_warns(self): + client, result = self.invoke( + "version-update", "CIEL", "CIEL", "v2026-10-01", "--match-algorithms", "es,llm" + ) + + [(_, _, kwargs)] = client.calls + self.assertEqual(kwargs["match_algorithms"], ["es", "llm"]) + self.assertIn("Warning", result.stderr) + self.assertIn("version-create --match-algorithms", result.stderr) + + def test_version_update_without_match_algorithms_does_not_warn(self): + _, result = self.invoke("version-update", "CIEL", "CIEL", "v2026-10-01", "--released") + + self.assertEqual(result.stderr, "") + + +class CreateRepoVersionClientTest(unittest.TestCase): + def create(self, **kwargs): + client = OCLAPIClient(base_url="https://api.example.test", token="t") + with patch.object(OCLAPIClient, "post", return_value={}) as post: + client.create_repo_version("CIEL", "CIEL", "v1", **kwargs) + return post.call_args.kwargs["json"] + + def test_body_includes_match_algorithms_when_given(self): + self.assertEqual( + self.create(match_algorithms=["es", "llm"]), + {"id": "v1", "released": True, "match_algorithms": ["es", "llm"]}, + ) + + def test_body_leaves_match_algorithms_out_by_default(self): + self.assertEqual(self.create(), {"id": "v1", "released": True}) From c808ed74fdb84a57d195d6438f7277fec7a28ea6 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 2 Oct 2026 23:46:20 -0400 Subject: [PATCH 2/5] OpenConceptLab/ocl_online#247 | --match-algorithms is refused for collections; the warning keeps --json output clean Codex review, pass 1: - `repo version-create` and `repo version-update` refuse `--match-algorithms` with `--type collection` (a usage error, nothing sent): collections have no match algorithms (Medium). Help and README say "sources only". - The version-update warning test now also checks stdout is the JSON response (Low). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VRGfMH9ZR6TGNMSfiPyUaF --- README.md | 2 +- src/ocl_cli/commands/repo.py | 17 +++++++++++------ tests/test_repo_commands.py | 16 ++++++++++++++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5cf31f5..d0fd428 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ ocl repo version-update OWNER REPO VERSION_ID [--released/--no-released] [--matc ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es,llm ``` -Set `--match-algorithms` when you create a version rather than with `version-update` afterwards. Changing an existing version's match algorithms makes the server reindex its concepts: adding `llm` embeds them, which can take hours for a large repository, and removing it can drop their vectors. `version-update --match-algorithms` prints a warning to that effect. +`--match-algorithms` applies to sources only. Set it when you create a version rather than with `version-update` afterwards. Changing an existing version's match algorithms makes the server reindex its concepts: adding `llm` embeds them, which can take hours for a large repository, and removing it can drop their vectors. `version-update --match-algorithms` prints a warning to that effect. ```bash # Custom attributes diff --git a/src/ocl_cli/commands/repo.py b/src/ocl_cli/commands/repo.py index 61beb78..c810aef 100644 --- a/src/ocl_cli/commands/repo.py +++ b/src/ocl_cli/commands/repo.py @@ -177,9 +177,11 @@ def update(ctx, owner, repo_name, repo_type, owner_type, new_name, description, ) -def _split_match_algorithms(value): +def _split_match_algorithms(value, repo_type): if value is None: return None + if repo_type != "source": + raise click.UsageError("--match-algorithms applies to sources only") return [algorithm.strip() for algorithm in value.split(",") if algorithm.strip()] @@ -193,8 +195,8 @@ def _split_match_algorithms(value): @click.option("--released/--no-released", default=True, help="Mark as released (default: true)") @click.option( "--match-algorithms", default=None, - help="Comma-separated match algorithms (e.g. es,llm). Omit to let the server decide: a new " - "source version is vectorized when HEAD or the latest release is.", + help="Sources only. Comma-separated match algorithms (e.g. es,llm). Omit to let the server " + "decide: a new source version is vectorized when HEAD or the latest release is.", ) @click.pass_context def version_create( @@ -202,11 +204,12 @@ def version_create( ): """Create a new repository version (snapshot).""" client = ctx.obj["client"] + match_algorithms = _split_match_algorithms(match_algorithms, repo_type) try: result = client.create_repo_version( owner, repo_name, version_id, owner_type=owner_type, repo_type=repo_type, description=description, released=released, - match_algorithms=_split_match_algorithms(match_algorithms), + match_algorithms=match_algorithms, ) output_result(ctx, result, format_repo_detail) except APIError as e: @@ -221,7 +224,9 @@ def version_create( @click.option("--owner-type", type=click.Choice(["users", "orgs"]), default="orgs") @click.option("--description", help="Version description") @click.option("--released/--no-released", default=None, help="Released status") -@click.option("--match-algorithms", default=None, help="Comma-separated match algorithms (e.g. es,llm)") +@click.option( + "--match-algorithms", default=None, help="Sources only. Comma-separated match algorithms (e.g. es,llm)" +) @click.pass_context def version_update(ctx, owner, repo_name, version_id, repo_type, owner_type, description, released, match_algorithms): """Update a repository version.""" @@ -233,7 +238,7 @@ def version_update(ctx, owner, repo_name, version_id, repo_type, owner_type, des if released is not None: fields["released"] = released if match_algorithms is not None: - fields["match_algorithms"] = _split_match_algorithms(match_algorithms) + fields["match_algorithms"] = _split_match_algorithms(match_algorithms, repo_type) click.echo(VERSION_UPDATE_MATCH_ALGORITHMS_WARNING, err=True) result = client.update_repo_version( owner, repo_name, version_id, owner_type=owner_type, diff --git a/tests/test_repo_commands.py b/tests/test_repo_commands.py index 8740adb..d29ffaf 100644 --- a/tests/test_repo_commands.py +++ b/tests/test_repo_commands.py @@ -1,3 +1,4 @@ +import json import unittest from types import SimpleNamespace from unittest.mock import patch @@ -33,14 +34,14 @@ def resolve_token(self, server, token_override=None): class RepoVersionCommandTest(unittest.TestCase): - def invoke(self, *args): + def invoke(self, *args, exit_code=0): client = FakeClient() with ( patch("ocl_cli.main.CLIConfig.load", return_value=FakeConfig()), patch("ocl_cli.main.OCLAPIClient", return_value=client), ): result = CliRunner().invoke(cli, ["--json", "repo", *args]) - self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual(result.exit_code, exit_code, result.output) return client, result def test_version_create_sends_match_algorithms(self): @@ -68,6 +69,17 @@ def test_version_update_match_algorithms_warns(self): self.assertEqual(kwargs["match_algorithms"], ["es", "llm"]) self.assertIn("Warning", result.stderr) self.assertIn("version-create --match-algorithms", result.stderr) + self.assertEqual(json.loads(result.stdout), {"id": "v2026-10-01", "version": "v2026-10-01"}) + + def test_match_algorithms_are_refused_for_collections(self): + for command in ("version-create", "version-update"): + client, result = self.invoke( + command, "CIEL", "Starter", "v1", "--type", "collection", "--match-algorithms", "es,llm", + exit_code=2, + ) + + self.assertEqual(client.calls, []) + self.assertIn("--match-algorithms applies to sources only", result.stderr) def test_version_update_without_match_algorithms_does_not_warn(self): _, result = self.invoke("version-update", "CIEL", "CIEL", "v2026-10-01", "--released") From 2d457cbe9aa3e8743d48aa6cdbb0be97a231bec0 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sat, 3 Oct 2026 00:01:26 -0400 Subject: [PATCH 3/5] OpenConceptLab/ocl_online#247 | An empty --match-algorithms is refused instead of clearing them Codex review, pass 2 (Medium): `--match-algorithms ""` (an unset shell variable, ",,", spaces) sent `[]`, which overrides inheritance on create and opts a version out on update. Both commands now refuse it as a usage error; opting out is `--match-algorithms es`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VRGfMH9ZR6TGNMSfiPyUaF --- src/ocl_cli/commands/repo.py | 6 +++++- tests/test_repo_commands.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ocl_cli/commands/repo.py b/src/ocl_cli/commands/repo.py index c810aef..0c06d16 100644 --- a/src/ocl_cli/commands/repo.py +++ b/src/ocl_cli/commands/repo.py @@ -182,7 +182,11 @@ def _split_match_algorithms(value, repo_type): return None if repo_type != "source": raise click.UsageError("--match-algorithms applies to sources only") - return [algorithm.strip() for algorithm in value.split(",") if algorithm.strip()] + algorithms = [algorithm.strip() for algorithm in value.split(",") if algorithm.strip()] + if not algorithms: + # an empty value (e.g. an unset shell variable) would otherwise clear them: opting out is `es` + raise click.UsageError("--match-algorithms needs at least one algorithm, e.g. es or es,llm") + return algorithms @repo.command("version-create") diff --git a/tests/test_repo_commands.py b/tests/test_repo_commands.py index d29ffaf..e7bbf37 100644 --- a/tests/test_repo_commands.py +++ b/tests/test_repo_commands.py @@ -81,6 +81,16 @@ def test_match_algorithms_are_refused_for_collections(self): self.assertEqual(client.calls, []) self.assertIn("--match-algorithms applies to sources only", result.stderr) + def test_empty_match_algorithms_are_refused(self): + for command in ("version-create", "version-update"): + for value in ("", " ", ",,", " , "): + client, result = self.invoke( + command, "CIEL", "CIEL", "v1", "--match-algorithms", value, exit_code=2 + ) + + self.assertEqual(client.calls, []) + self.assertIn("--match-algorithms needs at least one algorithm", result.stderr) + def test_version_update_without_match_algorithms_does_not_warn(self): _, result = self.invoke("version-update", "CIEL", "CIEL", "v2026-10-01", "--released") From bef582ecf7e878ce40cd3603093647649eec1ca7 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Mon, 5 Oct 2026 23:26:54 -0400 Subject: [PATCH 4/5] OpenConceptLab/ocl_online#247 | A new version is vectorized when HEAD is; README shows opting out on create The server now decides a new version's match algorithms from HEAD alone, not HEAD or the latest release. Update the --match-algorithms help, the client docstring and the README to say so, and show opting out with --match-algorithms es next to the es,llm example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VRGfMH9ZR6TGNMSfiPyUaF --- README.md | 7 ++++--- src/ocl_cli/api_client.py | 2 +- src/ocl_cli/commands/repo.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d0fd428..52c2f5c 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,10 @@ ocl repo update OWNER REPO [--name NAME] [--description DESC] ocl repo version-create OWNER REPO VERSION_ID [--released/--no-released] [--match-algorithms es,llm] ocl repo version-update OWNER REPO VERSION_ID [--released/--no-released] [--match-algorithms es,llm] -# Create a vectorized release (semantic matching). Without --match-algorithms, the server -# vectorizes a new source version when its HEAD or latest release is vectorized. -ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es,llm +# Without --match-algorithms, a new source version is vectorized (semantic matching) +# when its HEAD is. Opt in or out when you create it: +ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es,llm # opt in: vectorized +ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es # opt out: not vectorized ``` `--match-algorithms` applies to sources only. Set it when you create a version rather than with `version-update` afterwards. Changing an existing version's match algorithms makes the server reindex its concepts: adding `llm` embeds them, which can take hours for a large repository, and removing it can drop their vectors. `version-update --match-algorithms` prints a warning to that effect. diff --git a/src/ocl_cli/api_client.py b/src/ocl_cli/api_client.py index 8ef99ad..6c805d7 100644 --- a/src/ocl_cli/api_client.py +++ b/src/ocl_cli/api_client.py @@ -919,7 +919,7 @@ def create_repo_version( """Create a new repository version (snapshot). Without match_algorithms, the server decides: a new source version is vectorized - (``llm``) when the source's HEAD or its latest release is. + (``llm``) when the source's HEAD is. """ self._require_auth() endpoint = _build_repo_endpoint(owner_type, owner, repo_type, repo, suffix="versions/") diff --git a/src/ocl_cli/commands/repo.py b/src/ocl_cli/commands/repo.py index 0c06d16..3400ca4 100644 --- a/src/ocl_cli/commands/repo.py +++ b/src/ocl_cli/commands/repo.py @@ -200,7 +200,7 @@ def _split_match_algorithms(value, repo_type): @click.option( "--match-algorithms", default=None, help="Sources only. Comma-separated match algorithms (e.g. es,llm). Omit to let the server " - "decide: a new source version is vectorized when HEAD or the latest release is.", + "decide: a new source version is vectorized when HEAD is.", ) @click.pass_context def version_create( From 76d98de32d473d6c5b919e4b19cf575dc5a15096 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Mon, 5 Oct 2026 23:30:30 -0400 Subject: [PATCH 5/5] OpenConceptLab/ocl_online#247 | The repo command tests capture stderr separately on Click 8.1 too click>=8.1.0 allows Click 8.1, whose CliRunner mixes stderr into stdout by default, so the tests that read result.stderr raised ValueError there. Ask for mix_stderr=False where it exists; Click 8.2 dropped it and always keeps them apart. Codex review pass 4, [P2]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VRGfMH9ZR6TGNMSfiPyUaF --- tests/test_repo_commands.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_repo_commands.py b/tests/test_repo_commands.py index e7bbf37..873f02c 100644 --- a/tests/test_repo_commands.py +++ b/tests/test_repo_commands.py @@ -9,6 +9,15 @@ from ocl_cli.main import cli +def make_runner(): + # Click 8.1 mixes stderr into stdout unless told not to; 8.2 dropped mix_stderr and always + # keeps them apart. Both satisfy click>=8.1.0. + try: + return CliRunner(mix_stderr=False) + except TypeError: + return CliRunner() + + class FakeClient: def __init__(self): self.calls = [] @@ -40,7 +49,7 @@ def invoke(self, *args, exit_code=0): patch("ocl_cli.main.CLIConfig.load", return_value=FakeConfig()), patch("ocl_cli.main.OCLAPIClient", return_value=client), ): - result = CliRunner().invoke(cli, ["--json", "repo", *args]) + result = make_runner().invoke(cli, ["--json", "repo", *args]) self.assertEqual(result.exit_code, exit_code, result.output) return client, result