Skip to content
Open
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

`--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
ocl repo extras OWNER REPO
ocl repo extra-set OWNER REPO KEY VALUE
Expand Down
9 changes: 8 additions & 1 deletion src/ocl_cli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
38 changes: 35 additions & 3 deletions src/ocl_cli/commands/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,26 @@ 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, repo_type):
if value is None:
return None
if repo_type != "source":
raise click.UsageError("--match-algorithms applies to sources only")
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")
@click.argument("owner")
@click.argument("repo_name")
Expand All @@ -177,14 +197,23 @@ 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="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(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"]
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=match_algorithms,
)
output_result(ctx, result, format_repo_detail)
except APIError as e:
Expand All @@ -199,7 +228,9 @@ def version_create(ctx, owner, repo_name, version_id, repo_type, owner_type, des
@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."""
Expand All @@ -211,7 +242,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, 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,
repo_type=repo_type, **fields,
Expand Down
114 changes: 114 additions & 0 deletions tests/test_repo_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import json
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, 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, exit_code, 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)
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_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")

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})