diff --git a/README.md b/README.md index 5013343..8090ee9 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ ocl repo export download CIEL CIEL v2026-03-23 --type source -o CIEL_v2026-03-23 ```bash # Search & browse ocl concept search [QUERY] [--owner OWNER] [--repo REPO] [--concept-class CLASS] +ocl concept list [--owner OWNER] [--repo REPO] [--repo-type source|collection] ocl concept get OWNER SOURCE CONCEPT_ID [--repo-version VERSION] [--include-mappings] [--include-inverse-mappings] ocl concept versions OWNER SOURCE CONCEPT_ID ocl concept names OWNER SOURCE CONCEPT_ID [--verbose] diff --git a/docs/demos/01-content-exploration.md b/docs/demos/01-content-exploration.md index 42a1946..082751c 100644 --- a/docs/demos/01-content-exploration.md +++ b/docs/demos/01-content-exploration.md @@ -9,12 +9,13 @@ Multi-step CLI scenarios that exercise real-world workflows against the producti **Steps:** 1. `ocl org get CIEL` — org profile 2. `ocl org repos CIEL` — sources and collections owned by CIEL -3. `ocl concept search malaria --owner CIEL --repo CIEL` — find a concept -4. `ocl concept names CIEL CIEL 116128` — all translations (11 names across 9 languages) -5. `ocl mapping search --owner CIEL --repo CIEL --from-concept 138041 --verbose` — outbound mappings with target source display -6. `ocl mapping get CIEL CIEL 162820` — full detail showing from/to source URLs and codes +3. `ocl concept list --owner CIEL --repo CIEL --limit 3` — browse concepts without needing to know a search term +4. `ocl concept search malaria --owner CIEL --repo CIEL` — find a concept +5. `ocl concept names CIEL CIEL 116128` — all translations (11 names across 9 languages) +6. `ocl mapping search --owner CIEL --repo CIEL --from-concept 138041 --verbose` — outbound mappings with target source display +7. `ocl mapping get CIEL CIEL 162820` — full detail showing from/to source URLs and codes -**Validates:** org lookup, org repo listing, concept search, name listing, mapping search with target source display, mapping detail. +**Validates:** org lookup, org repo listing, concept browsing, concept search, name listing, mapping search with target source display, mapping detail. --- diff --git a/docs/demos/07-agent-readiness.md b/docs/demos/07-agent-readiness.md index 7826325..68c46ce 100644 --- a/docs/demos/07-agent-readiness.md +++ b/docs/demos/07-agent-readiness.md @@ -5,6 +5,7 @@ Verify the CLI works well as a backend for AI agents and scripts. ## 7.1 JSON Output for Programmatic Consumption ```bash # Every command should produce valid, parseable JSON with -j +ocl -j concept list --owner CIEL --repo CIEL --limit 2 | python -m json.tool ocl -j concept search malaria --owner CIEL --repo CIEL --limit 2 | python -m json.tool ocl -j concept get CIEL CIEL 116128 --include-mappings | python -m json.tool ocl -j cascade Regenstrief LOINC 2345-7 --levels 1 --cascade-hierarchy --no-cascade-mappings --reverse | python -m json.tool diff --git a/src/ocl_cli/commands/concept.py b/src/ocl_cli/commands/concept.py index c1d0ebe..ef7171d 100644 --- a/src/ocl_cli/commands/concept.py +++ b/src/ocl_cli/commands/concept.py @@ -8,9 +8,14 @@ from ocl_cli.api_client import APIError from ocl_cli.main import handle_api_error from ocl_cli.output import ( - output_result, format_concept_list, format_concept_detail, - format_version_list, format_names_list, format_descriptions_list, - format_extras, format_match_results, + format_concept_detail, + format_concept_list, + format_descriptions_list, + format_extras, + format_match_results, + format_names_list, + format_version_list, + output_result, ) @@ -46,6 +51,45 @@ def search(ctx, query, owner, owner_type, repo, repo_type, repo_version, concept datatype, locale, include_retired, include_mappings, include_inverse_mappings, updated_since, sort, verbose, limit, page): """Search for concepts globally or within a repository.""" + _search_concepts( + ctx, query, owner, owner_type, repo, repo_type, repo_version, concept_class, + datatype, locale, include_retired, include_mappings, include_inverse_mappings, + updated_since, sort, verbose, limit, page, + ) + + +@concept.command("list") +@click.option("--owner", help="Filter by owner") +@click.option("--owner-type", type=click.Choice(["users", "orgs"])) +@click.option("--repo", help="Filter by source/collection") +@click.option("--repo-type", type=click.Choice(["source", "collection"]), default="source") +@click.option("--repo-version", help="Repository version") +@click.option("--concept-class", help="Filter by concept class") +@click.option("--datatype", help="Filter by datatype") +@click.option("--locale", help="Filter by locale") +@click.option("--include-retired", is_flag=True, help="Include retired concepts") +@click.option("--include-mappings", is_flag=True, help="Include mappings in results") +@click.option("--include-inverse-mappings", is_flag=True) +@click.option("--updated-since", help="Filter by update date (YYYY-MM-DD)") +@click.option("--sort", help="Sort field (prefix with - for descending)") +@click.option("--verbose", is_flag=True, help="Include extra detail") +@click.option("--limit", default=20, help="Results per page") +@click.option("--page", default=1, help="Page number") +@click.pass_context +def list_(ctx, owner, owner_type, repo, repo_type, repo_version, concept_class, + datatype, locale, include_retired, include_mappings, include_inverse_mappings, + updated_since, sort, verbose, limit, page): + """List concepts globally or within a repository.""" + _search_concepts( + ctx, None, owner, owner_type, repo, repo_type, repo_version, concept_class, + datatype, locale, include_retired, include_mappings, include_inverse_mappings, + updated_since, sort, verbose, limit, page, + ) + + +def _search_concepts(ctx, query, owner, owner_type, repo, repo_type, repo_version, concept_class, + datatype, locale, include_retired, include_mappings, include_inverse_mappings, + updated_since, sort, verbose, limit, page): client = ctx.obj["client"] try: result = client.search_concepts( diff --git a/tests/test_concept_commands.py b/tests/test_concept_commands.py new file mode 100644 index 0000000..1c290d2 --- /dev/null +++ b/tests/test_concept_commands.py @@ -0,0 +1,89 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from click.testing import CliRunner + +from ocl_cli.main import cli + + +class FakeClient: + def __init__(self): + self.search_concepts_calls = [] + self.closed = False + + def search_concepts(self, **kwargs): + self.search_concepts_calls.append(kwargs) + return {"count": 0, "results": []} + + def close(self): + self.closed = True + + +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 ConceptCommandTest(unittest.TestCase): + def test_concept_list_delegates_to_search_without_query(self): + client = FakeClient() + runner = CliRunner() + + with ( + patch("ocl_cli.main.CLIConfig.load", return_value=FakeConfig()), + patch("ocl_cli.main.OCLAPIClient", return_value=client), + ): + result = runner.invoke( + cli, + [ + "concept", + "list", + "--owner", + "openmrs", + "--repo", + "BasicLabTests", + "--repo-type", + "collection", + "--limit", + "10", + "--page", + "2", + "--verbose", + ], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + client.search_concepts_calls, + [ + { + "query": None, + "owner": "openmrs", + "owner_type": None, + "repo": "BasicLabTests", + "repo_type": "collection", + "repo_version": None, + "concept_class": None, + "datatype": None, + "locale": None, + "include_retired": False, + "include_mappings": False, + "include_inverse_mappings": False, + "updated_since": None, + "sort": None, + "verbose": True, + "limit": 10, + "page": 2, + } + ], + ) + self.assertEqual(result.output.strip(), "No concepts found.") + self.assertTrue(client.closed) + + +if __name__ == "__main__": + unittest.main()