From 35d731292683211050a9b5a905a96fe236b51d40 Mon Sep 17 00:00:00 2001 From: mogoodrich Date: Fri, 6 Nov 2026 11:24:20 -0500 Subject: [PATCH 1/2] ocl: general release check (ocl_release_check.py) for the skill and CI (UHM-9643, UHM-9656) Replaces check_clone_mappings.py with ocl_release_check.py: named, pluggable checks (name-uuids, clone-mappings), error/warning severities, --export/--baseline for CI, --fail-on, --summary-md and exit codes. Adds unit tests and a workflow that runs plugin script tests. Docs updated; plugin 0.4.0. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-scripts.yml | 30 ++ plugins/ocl/.claude-plugin/plugin.json | 2 +- plugins/ocl/README.md | 4 +- plugins/ocl/skills/ocl-search/SKILL.md | 36 +- .../__pycache__/check_update.cpython-312.pyc | Bin 0 -> 5726 bytes .../ocl_release_check.cpython-312.pyc | Bin 0 -> 38608 bytes .../test_check_update.cpython-312.pyc | Bin 0 -> 9311 bytes .../test_ocl_release_check.cpython-312.pyc | Bin 0 -> 13263 bytes .../scripts/check_clone_mappings.py | 388 -------------- .../ocl-search/scripts/ocl_release_check.py | 484 ++++++++++++++++++ .../scripts/test_ocl_release_check.py | 150 ++++++ 11 files changed, 687 insertions(+), 407 deletions(-) create mode 100644 .github/workflows/test-scripts.yml create mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/check_update.cpython-312.pyc create mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/ocl_release_check.cpython-312.pyc create mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/test_check_update.cpython-312.pyc create mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/test_ocl_release_check.cpython-312.pyc delete mode 100644 plugins/ocl/skills/ocl-search/scripts/check_clone_mappings.py create mode 100644 plugins/ocl/skills/ocl-search/scripts/ocl_release_check.py create mode 100644 plugins/ocl/skills/ocl-search/scripts/test_ocl_release_check.py diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml new file mode 100644 index 0000000..ac320d1 --- /dev/null +++ b/.github/workflows/test-scripts.yml @@ -0,0 +1,30 @@ +# Runs the unit tests for plugin scripts (plugins/*/skills/*/scripts/test_*.py) on every pull request. +# The ocl release check (ocl_release_check.py) is also run by the PIH/pihemr "Update OCL export" Action, +# straight from main, so its tests guard that workflow too. +name: Test plugin scripts + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Run tests + run: | + set -e + found=0 + for dir in plugins/*/skills/*/scripts; do + if ls "$dir"/test_*.py >/dev/null 2>&1; then + found=1 + echo "== $dir" + python3 -I -m unittest discover -s "$dir" -p 'test_*.py' -v + fi + done + [ "$found" = 1 ] || { echo "No tests found"; exit 1; } diff --git a/plugins/ocl/.claude-plugin/plugin.json b/plugins/ocl/.claude-plugin/plugin.json index af1d860..17bce48 100644 --- a/plugins/ocl/.claude-plugin/plugin.json +++ b/plugins/ocl/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ocl", - "version": "0.3.1", + "version": "0.4.0", "description": "Search and maintain the PIH concept dictionary in Open Concept Lab (OCL): look up concepts, check set membership, clone from CIEL, and create or edit PIH concepts and mappings.", "author": { "name": "Partners In Health" diff --git a/plugins/ocl/README.md b/plugins/ocl/README.md index 75c2d24..7bc7458 100644 --- a/plugins/ocl/README.md +++ b/plugins/ocl/README.md @@ -84,7 +84,9 @@ This catches the problems we know about, not every possible import error. If you Each check also watches for OCL fixing the clone bugs it works around (ocl_issues#3012 and #3013). If it looks like OCL has fixed them, Claude will tell you, and suggest updating the plugin to drop checks that are no longer needed. It never makes that change without asking. -Developers can run the same check directly: `python3 -I plugins/ocl/skills/ocl-search/scripts/check_clone_mappings.py --unreleased`. Add `--since ` to check against an older release, e.g. `--since 1.8.46` reproduces the 1.8.47 failure. +**The same check also runs automatically** when the PIH/pihemr "Update OCL export" GitHub Action puts a new release into the distro. If the release has errors, the Action stops and doesn't open a pull request, so the release never reaches servers. + +Developers can run the check directly: `python3 -I plugins/ocl/skills/ocl-search/scripts/ocl_release_check.py --unreleased`. Add `--since ` to check against an older release (e.g. `--since 1.8.46` reproduces the 1.8.47 failure), or use `--export --baseline ` to check an export the way the Action does. `--list-checks` lists the checks; the script's header explains how to add or retire one. ## Staying up to date diff --git a/plugins/ocl/skills/ocl-search/SKILL.md b/plugins/ocl/skills/ocl-search/SKILL.md index 61d88a6..bf8b1a3 100644 --- a/plugins/ocl/skills/ocl-search/SKILL.md +++ b/plugins/ocl/skills/ocl-search/SKILL.md @@ -135,39 +135,39 @@ The response is keyed by expression, each with a `status` and a `bundle` listing SNOMED, IMO and other codes that aren't concepts in OCL (`to_concept_url` is null) aren't affected. The clone doesn't add a separate, proper `SAME-AS PIH:` self-mapping. The `SELF` mapping in the first row stands in for it. -**After every clone, check it:** +**After every clone, check it** with the release check (see workflow 5): ```bash -python3 -I /scripts/check_clone_mappings.py --concept --name-uuids +python3 -I /scripts/ocl_release_check.py --concept ``` -The script is read-only and reads the token from the file. `--name-uuids` also checks the new concepts' name UUIDs (see "Other clone behavior" below). The first time, it downloads a PIH database snapshot of about 33 MB to `~/.cache/ocl/`. -- **`NAME-CLASH` / `DUP-NAME`:** must be fixed before the next release, because the OpenMRS import will fail. Propose giving that name a new UUID (workflow 3). -- **`EXTERNAL`:** must be fixed. Tell the user, and propose retiring the bad mapping and adding the correct one with the same map type (workflow 4). The script shows two codes: - - **"CIEL … has":** CIEL's mapping *today*. - - **"PIH … is SAME-AS":** the code that was lost in the clone. +It's read-only and reads the token from the file. It runs every check: `name-uuids` compares the new concepts' name UUIDs against a PIH database (see "Other clone behavior" below; the first run downloads a snapshot of about 33 MB to `~/.cache/ocl/`), and `clone-mappings` looks for the mapping bugs above. Each finding is an `ERROR` (fix before releasing) or a `WARNING` (report only): +- **`name-uuids/name-clash` / `name-uuids/dup-name-uuid` (errors):** must be fixed before the next release, because the OpenMRS import will fail. Propose giving that name a new UUID (workflow 3). A `name-clash-handled` warning means the clash is already fixed on servers (e.g. by a distro Liquibase changeset); it's listed in `HANDLED_NAME_CLASHES` in the script. +- **`clone-mappings/external` (error):** must be fixed. Tell the user, and propose retiring the bad mapping and adding the correct one with the same map type (workflow 4). The script shows two codes: + - **"Lost code":** the code that was lost in the clone. + - **"CIEL … today":** CIEL's mapping *today*. Right after a clone these match. For older clones, CIEL may have changed its mapping since, so if they differ, show the user both and let them choose. Also check whether someone already re-added the correct mapping by hand; if so, only retire the bad one. -- **`SELF` / `NO-SELF` / `INTERNAL`:** leave them alone, and just mention them. OpenMRS already reads a `SELF` mapping as the usual `SAME-AS PIH:`, and `INTERNAL` mappings point at the right concept. Cleaning these up is on hold until OCL fixes #3012/#3013. Retiring and re-adding them gives each mapping a new ID, which makes every server delete and recreate it on the next import, only to fix fields OpenMRS doesn't use. +- **`clone-mappings/self` / `no-self-mapping` / `internal` (warnings):** leave them alone, and just mention them. OpenMRS already reads a `SELF` mapping as the usual `SAME-AS PIH:`, and `INTERNAL` mappings point at the right concept. Cleaning these up is on hold until OCL fixes #3012/#3013. Retiring and re-adding them gives each mapping a new ID, which makes every server delete and recreate it on the next import, only to fix fields OpenMRS doesn't use. Don't try to fix a mapping in place with `PUT`. It hasn't been tested on these inconsistent records. -**Watch for OCL fixing these bugs.** When run on live concepts (`--concept`, `--unreleased`), the script ends with an **"OCL fix watch"** section, but only if there's something to report. It shows: +**Watch for OCL fixing these bugs.** When run on live concepts (`--concept`, `--unreleased`), the release check ends with an **"OCL fix watch"** section, but only if there's something to report. It shows: - **Issue state:** whether ocl_issues#3012 and #3013 are open or closed, from GitHub. - **Fresh clones:** for clones made after the issues were filed, whether the bugs still show up. If not, the copied mappings have a PIH `from_source_url`, there are no `SELF` or `EXTERNAL` findings, and the clones get fresh name UUIDs. -If it prints **"POSSIBLE OCL FIX"**, tell the user. Then *suggest*, without doing it unprompted, reviewing whether the skill still needs the related guidance and checks: +If it prints **"POSSIBLE OCL FIX"**, tell the user. Then *suggest*, without doing it unprompted, reviewing whether the skill still needs the related guidance, and whether the `clone-mappings` check can be retired: - **#3012:** the from-source note. - **#3013:** the `SELF`/`INTERNAL`/`EXTERNAL` handling, and the on-hold cleanup (UHM-9636 step 3), which OCL's fix might also cover. -- **Name UUIDs:** the name-UUID caveat. Keep `--name-uuids` anyway, as any manual name edit can still clash. +- **Name UUIDs:** the name-UUID caveat. Keep the `name-uuids` check anyway, as any manual name edit can still clash. One clean clone is weak evidence, since that concept may simply not have triggered the bug. So before recommending a change, confirm with a clone that *would* have triggered it, e.g. preview a CIEL concept with an ICD-10 mapping that PIH already has, or check OCL's release notes. Any change to the skill goes through a PR, like other plugin changes. -To audit the whole source, download a released export (workflow 6, steps 1–2) and run the script with `--export export.json`. On 1.8.46 it found 29 external, 932 self and 282 internal problems. The script doesn't report the wrong `from_source_url` (#3012), which affects about 3,850 mappings, nearly every mapping a clone has copied. +To audit the whole source, download a released export (workflow 6, steps 1–2) and run the release check with `--export `. On 1.8.46 it found 29 external, 932 self and 282 internal problems. It doesn't report the wrong `from_source_url` (#3012), which affects about 3,850 mappings, nearly every mapping a clone has copied. **Other clone behavior (expected; not a bug, confirmed with the dictionary maintainers):** - Names keep their CIEL name UUIDs (`…BBBB`). PIH's auto-UUID for names isn't applied. - **But this can break the OpenMRS import.** A PIH server may already hold that UUID on another concept's name, often one voided years ago that OCL no longer shows. The import then fails with `Duplicate entry '…BBBB' for key 'concept_name_uuid_index'`. The failed concept's mappings fail too, Initializer aborts the OCL load, and pihcore won't start. - **It has happened.** Releases 1.8.47–1.8.50 broke this way (Jira UHM-9656): PIH 21797, cloned from CIEL 81457, reused CIEL name UUIDs that PIH servers had on 13277. - - **OCL can't detect it.** `--name-uuids` checks against a 2023 snapshot of a PIH database (the pihcore test DB), which catches most cases. + - **OCL can't detect it.** The release check's `name-uuids` check compares against a 2023 snapshot of a PIH database (the pihcore test DB), which catches most cases. - **Possible prevention:** give cloned names fresh UUIDs right after cloning. Nothing at PIH is known to rely on names keeping CIEL's UUIDs, but that's a convention for the dictionary maintainers to decide, so only offer it. - All CIEL locales come along (nl, vi, ru, pt_BR, …), not only en/es/fr/ht. @@ -210,15 +210,17 @@ Before creating, search PIH and CIEL for an existing equivalent. If CIEL has one ### 5. Release a new PIH source version Changes reach OpenMRS servers through released source versions, not the live source. -- **Check before releasing:** run the checker on everything changed since the last release: +- **Check before releasing:** run the release check on everything changed since the last release: ```bash - python3 -I /scripts/check_clone_mappings.py --unreleased + python3 -I /scripts/ocl_release_check.py --unreleased ``` - It finds every PIH concept changed since the latest release (directly or through one of its mappings), runs the mapping checks, and checks name UUIDs against the PIH database snapshot. - - **`NAME-CLASH`, `DUP-NAME` or `EXTERNAL`:** the release would break the OpenMRS import, or lose a code. Fix these first. + It finds every PIH concept changed since the latest release (directly or through one of its mappings) and runs every check on them (`--list-checks` lists them). + - **Any `ERROR`:** the release would break the OpenMRS import, or lose a code. Fix these first. `WARNING`s are report-only. - **Testing:** `--since ` checks against an older release instead, e.g. `--since 1.8.46` reproduces the 1.8.47 failure. This catches the problems we know about, not every possible import error. Once the release is out, loading its export on a test server is still the real test. +- **The same check runs in GitHub.** PIH/pihemr's "Update OCL export" Action downloads `ocl_release_check.py` from this repo's `main` branch and runs it on the new export with `--export --baseline --fail-on error`, so only concepts that changed are checked. If there's an error, it doesn't open the PR. So a release with errors never reaches the distro, even if nobody ran the check before releasing. +- **Adding or retiring a check:** write a function that returns findings and register it in `CHECKS` in `ocl_release_check.py`, with a test in `test_ocl_release_check.py` (run `python3 -I -m unittest discover -s -p 'test_*.py'`). Remove a check from `CHECKS` once it's no longer needed (e.g. when OCL fixes the bug behind it). The pihemr Action picks up changes automatically. Keep the command-line options and exit statuses it uses working (`--export`, `--baseline`, `--fail-on`, `--summary-md`; exit 0 OK, 1 errors, 2 couldn't run), or change that workflow in the same release. - **Check the latest:** `GET /orgs/PIH/sources/PIH/versions/?limit=5&verbose=true` lists recent versions. They're numbered sequentially (`1.8.x`), each with a short description of what changed. - **Create the next one:** `POST /orgs/PIH/sources/PIH/versions/` with `{"id": "", "description": "", "released": true}`. - **Verify it:** `GET …/PIH/latest/` should return it, and `GET …/PIH//concepts//` reads that version's snapshot. diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/check_update.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/check_update.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3743264bcb2f5533c5bef860b2a4c484c7579f2 GIT binary patch literal 5726 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 literal 0 HcmV?d00001 diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/ocl_release_check.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/ocl_release_check.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0dca8ca2487bfec86427e45a49f44ff82d6b11f3 GIT binary patch literal 38608 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⁢C@4L1;w-`?!|ru`EYKpMPnpP%gv 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 KDb&(Y)c-$dv2WS{ literal 0 HcmV?d00001 diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_check_update.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_check_update.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21f81206690f84a672952f0df96eff5711bf32ae GIT binary patch literal 9311 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 Qe{lW>7oJJP#&?_l7pEHv3jhEB literal 0 HcmV?d00001 diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_ocl_release_check.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_ocl_release_check.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49679071b7ee85a88c03572d1d3beb280d27a5ad GIT binary patch literal 13263 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" becomes "CIEL:" pointing at the - concept itself. OpenMRS reads it as SAME-AS PIH: (the usual self-mapping), - so it's only wrong in OCL. - internal Q-AND-A / CONCEPT-SET / other concept-to-concept mappings now point at the - right PIH concept, but `to_source_url` still says CIEL. Cosmetic. - external An ICD-10 / LOINC / ... mapping is rewritten to point at whichever PIH concept - is SAME-AS that code. The real external code is LOST, and OpenMRS gets e.g. - "BROADER-THAN PIH:10034" instead of "BROADER-THAN ICD-10-WHO:L28.0". - -It also reports concepts >= 20000 that have no proper SAME-AS PIH: self-mapping. - -With --name-uuids it also checks concept NAME uuids. Clones keep CIEL's name uuids -(…BBBB). PIH servers can already hold one of those uuids, e.g. on a name voided years -ago, and then the OpenMRS import fails with "Duplicate entry … for key -'concept_name_uuid_index'" (UHM-9656). OCL can't see voided server names, so this compares -against a snapshot of a PIH database: the pihcore test DB (openmrs.sql, from 2023). It's -downloaded once and cached in ~/.cache/ocl/. It reports: - name-clash a name uuid that the snapshot has on a DIFFERENT concept (active or voided) - dup-name-uuid the same name uuid on two PIH concepts in what's being checked - -Usage: - check_clone_mappings.py --concept 21768 21760 ... # live, from the OCL API - check_clone_mappings.py --export export.json # a whole released export - add --name-uuids to also check name uuids, and --json for machine-readable output. - -For "external" findings it looks up the CIEL source concept and suggests the correct code. -The token is read from ~/.config/ocl/token. Run with `python3 -I`. -""" -import argparse -import csv -import io -import json -import os -import re -import sys -import urllib.parse -import urllib.request - -API = 'https://api.openconceptlab.org' -PIH = '/orgs/PIH/sources/PIH/' -CIEL = '/orgs/CIEL/sources/CIEL/' -INTERNAL_TYPES = {'Q-AND-A', 'CONCEPT-SET'} -DB_SNAPSHOT_URL = ('https://raw.githubusercontent.com/PIH/openmrs-module-pihcore/master/' - 'api/src/test/resources/event/openmrs.sql') -DB_SNAPSHOT_CACHE = os.path.expanduser('~/.cache/ocl/pihcore-openmrs.sql') -UUID_RE = re.compile(r'[0-9A-Za-z-]{36}') -_cache = {} - - -def get(path): - if path not in _cache: - with open(os.path.expanduser('~/.config/ocl/token')) as f: - tok = f.read().strip() - req = urllib.request.Request(API + path, headers={'Authorization': 'Token ' + tok}) - with urllib.request.urlopen(req) as r: - _cache[path] = json.load(r) - return _cache[path] - - -def source_of(url): - m = re.match(r'(/orgs?/[^/]+/sources/[^/]+/)', url or '') - return m.group(1) if m else None - - -def short(url): - return (url or '').rstrip('/').split('/')[-1] - - -def concept_path(cid, extra=''): - return f'{PIH}concepts/{urllib.parse.quote(cid)}/?includeMappings=true{extra}' - - -def load_live(ids): - concepts, mappings = {}, [] - for cid in ids: - c = get(concept_path(cid, '&verbose=true')) - concepts[cid] = c - mappings.extend(c.get('mappings') or []) - return concepts, mappings - - -def changed_since(version): - """PIH concept ids changed (themselves or via one of their mappings) after release `version`.""" - cutoff = get(f'{PIH}{urllib.parse.quote(version)}/')['created_on'] - ids = set() - for kind, key in (('concepts', 'id'), ('mappings', 'from_concept_code')): - page = 1 - while True: - items = get(f'{PIH}{kind}/?sortDesc=last_update&limit=200&page={page}') - fresh = [i for i in items if (i.get('version_updated_on') or '') > cutoff] - ids.update(i[key] for i in fresh) - if len(fresh) < len(items) or len(items) < 200: - break - page += 1 - return cutoff, sorted(ids, key=lambda x: (not x.isdigit(), int(x) if x.isdigit() else 0, x)) - - -def load_db_snapshot(refresh=False): - """{name_uuid: (db_concept_uuid, name, locale, voided)} from the pihcore test DB dump.""" - if refresh or not os.path.exists(DB_SNAPSHOT_CACHE): - os.makedirs(os.path.dirname(DB_SNAPSHOT_CACHE), exist_ok=True) - print(f'Downloading PIH DB snapshot to {DB_SNAPSHOT_CACHE} (about 33 MB)…', file=sys.stderr) - urllib.request.urlretrieve(DB_SNAPSHOT_URL, DB_SNAPSHOT_CACHE) - with open(DB_SNAPSHOT_CACHE, encoding='utf-8', errors='replace') as f: - sql = f.read() - - def rows(table): - for blk in re.findall(r'INSERT INTO `%s` VALUES (.*?);\n' % table, sql, re.S): - cur, depth, quoted, esc = '', 0, False, False - for ch in blk: - if quoted: - cur += ch - if esc: - esc = False - elif ch == '\\': - esc = True - elif ch == "'": - quoted = False - continue - if ch == "'": - quoted = True - cur += ch - elif ch == '(' and depth == 0: - depth, cur = 1, '' - elif ch == ')' and depth == 1: - depth = 0 - yield next(csv.reader(io.StringIO(cur), quotechar="'", escapechar='\\')) - elif depth: - cur += ch - - concept_uuid = {r[0]: r[-1] for r in rows('concept')} - names = {} - # concept_name columns: concept_name_id, concept_id, name, locale, locale_preferred, creator, - # date_created, concept_name_type, voided, voided_by, date_voided, void_reason, uuid, ... - for r in rows('concept_name'): - uuid = next((x for x in r[12:] if UUID_RE.fullmatch(x or '')), None) - if uuid: - names[uuid] = (concept_uuid.get(r[1]), r[2], r[3], r[8] == '1') - return names - - -def check_name_uuids(concepts, db_names): - findings, seen = [], {} - for cid, c in concepts.items(): - for n in c.get('names') or []: - u = n.get('external_id') - if not u: - continue - if u in seen and seen[u] != cid: - findings.append({'kind': 'dup-name-uuid', 'concept': cid, 'other_concept': seen[u], - 'name': n['name'], 'name_uuid': u}) - seen.setdefault(u, cid) - hit = db_names.get(u) - if hit and hit[0] != c.get('external_id'): - findings.append({'kind': 'name-clash', 'concept': cid, 'name': n['name'], 'locale': n.get('locale'), - 'name_uuid': u, 'db_concept_uuid': hit[0], 'db_name': hit[1], - 'db_voided': hit[3]}) - return findings - - -# OCL bugs these checks work around. Once OCL fixes them, the matching checks (and the skill's -# guidance about them) can probably go. The fix watch below spots signs of that. -OCL_ISSUES = { - 3012: 'copied mappings keep the original from_source_url', - 3013: 'mappings retargeted to an equivalent PIH concept (incl. CIEL self-mapping, external codes)', -} -FIX_WATCH_SINCE = '2026-11-05' # only clones made after the issues were filed tell us about a fix -CLONE_WINDOW_SECONDS = 300 # a clone creates its concept and mappings within moments of each other - - -def _ts(s): - from datetime import datetime - return datetime.fromisoformat((s or '').replace('Z', '+00:00')) if s else None - - -def ocl_issue_states(): - """{issue number: (state, closed_at)} from GitHub's public API; {} if it can't be reached.""" - states = {} - for n in OCL_ISSUES: - try: - req = urllib.request.Request(f'https://api.github.com/repos/OpenConceptLab/ocl_issues/issues/{n}', - headers={'Accept': 'application/vnd.github+json'}) - with urllib.request.urlopen(req, timeout=10) as r: - d = json.load(r) - states[n] = (d.get('state'), (d.get('closed_at') or '')[:10]) - except Exception: # offline, rate-limited… the watch is best-effort - pass - return states - - -def fix_watch(concepts, by_from, findings, since=FIX_WATCH_SINCE): - """Look at clones made since `since` for signs that OCL has fixed the clone bugs.""" - self_bug = {f['concept'] for f in findings if f['kind'] == 'self'} - ext_bug = {f['concept'] for f in findings if f['kind'] == 'external'} - clones, ev = [], {'3012': set(), '3013': set(), 'name_uuids': set()} - for cid, c in concepts.items(): - created = _ts(c.get('created_on')) - if not created or c['created_on'][:10] < since: - continue - near = [m for m in by_from.get(cid, []) - if m.get('created_on') and abs((_ts(m['created_on']) - created).total_seconds()) < CLONE_WINDOW_SECONDS] - equiv = [m for m in near if m['map_type'] == 'SAME-AS' and source_of(m.get('to_concept_url')) == CIEL - and not m.get('to_source_url')] - if not equiv: - continue # not a $clone - clones.append(cid) - copied = [m for m in near if m not in equiv] - if copied: - stale = any(m.get('from_source_url') not in (None, PIH) for m in copied) - ev['3012'].add('still happening' if stale else 'looks fixed') - ev['3013'].add('still happening' if (cid in self_bug or cid in ext_bug) else 'looks fixed') - ciel_style = any((n.get('external_id') or '').endswith('BBBBBBBB') for n in c.get('names') or []) - ev['name_uuids'].add('still CIEL name uuids' if ciel_style else 'fresh name uuids') - return {'kind': 'ocl-fix-watch', 'clones_checked': clones, 'since': since, - 'evidence': {k: sorted(v) for k, v in ev.items()}, 'issues': ocl_issue_states()} - - -def print_fix_watch(w): - issues = w['issues'] - closed = [n for n, (state, _) in issues.items() if state == 'closed'] - signs = [k for k, v in w['evidence'].items() if v == ['looks fixed'] or v == ['fresh name uuids']] - if not (w['clones_checked'] or closed): - return - print() - print('OCL fix watch:') - for n, why in OCL_ISSUES.items(): - state = issues.get(n) - print(f" ocl_issues#{n} ({why}): " + (f"{state[0]}" + (f" on {state[1]}" if state[1] else '') if state - else 'state unknown (GitHub not reachable)')) - if w['clones_checked']: - print(f" Clones made since {w['since']} that were checked: {' '.join(w['clones_checked'])}") - for k, v in w['evidence'].items(): - if v: - print(f" {k}: {', '.join(v)}") - if closed or signs: - said = {'3012': 'new clones suggest ocl_issues#3012 is fixed', - '3013': 'new clones suggest ocl_issues#3013 is fixed', - 'name_uuids': 'new clones get fresh name uuids instead of keeping CIEL\'s'} - print(' POSSIBLE OCL FIX: ' + '; '.join( - [f'ocl_issues#{n} is closed' for n in closed] + [said[k] for k in signs]) + '.') - print(' Suggest to the user (do not do it unprompted) reviewing whether the skill\'s clone-bug guidance ' - 'and the matching checks are still needed.') - - -def load_export(path): - with open(path) as f: - d = json.load(f) - return {c['id']: c for c in d['concepts']}, d['mappings'] - - -def mappings_of(cid, by_from, live): - if cid in by_from or not live: - return by_from.get(cid, []) - return get(concept_path(cid)).get('mappings') or [] - - -def name_of(c): - names = (c or {}).get('names') or [] - pref = [n['name'] for n in names if n.get('locale') == 'en' and n.get('locale_preferred')] - return (pref or [n['name'] for n in names] or [''])[0] - - -def suggest(m, by_from, live): - """Correct external code: what the CIEL source concept maps to, with the same map type and source.""" - tgt = m['to_source_url'] - ciel_ids = [x['to_concept_code'] for x in by_from.get(m['from_concept_code'], []) - if x['map_type'] == 'SAME-AS' and source_of(x.get('to_concept_url')) == CIEL and not x.get('retired')] - codes = [] - for ciel_id in ciel_ids: - for x in get(f'{CIEL}concepts/{urllib.parse.quote(ciel_id)}/?includeMappings=true').get('mappings') or []: - if x.get('to_source_url') == tgt and x['map_type'] == m['map_type'] and not x.get('retired'): - codes.append(x['to_concept_code']) - # Fallback: the code the wrongly targeted PIH concept is SAME-AS in that source. - via = [x['to_concept_code'] for x in mappings_of(m['to_concept_code'], by_from, live) - if x['map_type'] == 'SAME-AS' and x.get('to_source_url') == tgt and not x.get('retired')] - return ciel_ids, sorted(set(codes)), sorted(set(via)) - - -def main(): - p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - g = p.add_mutually_exclusive_group(required=True) - g.add_argument('--concept', nargs='+', metavar='PIH_ID') - g.add_argument('--export', metavar='EXPORT_JSON') - g.add_argument('--unreleased', action='store_true', - help='check every PIH concept changed since the latest release (a pre-release check)') - p.add_argument('--since', metavar='VERSION', - help='with --unreleased: compare against this release instead of the latest, e.g. 1.8.46') - p.add_argument('--name-uuids', action='store_true', - help='also check name uuids against a PIH DB snapshot (implied by --unreleased)') - p.add_argument('--refresh-snapshot', action='store_true', help='re-download the PIH DB snapshot') - p.add_argument('--fix-watch-since', default=FIX_WATCH_SINCE, metavar='YYYY-MM-DD', - help=argparse.SUPPRESS) # for testing the fix watch on older clones - p.add_argument('--json', action='store_true') - a = p.parse_args() - - if a.unreleased: - version = a.since or get(f'{PIH}latest/')['id'] - cutoff, ids = changed_since(version) - print(f'Checking {len(ids)} PIH concept(s) changed since {version} ({cutoff[:16]}Z): ' - f'{" ".join(ids) or "(none)"}', file=sys.stderr) - a.concept, a.name_uuids = ids, True - live = a.concept is not None - concepts, mappings = load_live(a.concept) if live else load_export(a.export) - by_from = {} - for m in mappings: - by_from.setdefault(m['from_concept_code'], []).append(m) - - findings = [] - for m in mappings: - if m.get('retired'): - continue - tsu, tcu = m.get('to_source_url'), m.get('to_concept_url') - if not (tsu and tcu) or source_of(tcu) != PIH or tsu == PIH: - continue - f = {'mapping': m['id'], 'concept': m['from_concept_code'], 'map_type': m['map_type'], - 'to_source_url': tsu, 'to_concept_code': m['to_concept_code']} - if m['to_concept_code'] == m['from_concept_code']: - f['kind'] = 'self' - elif tsu == CIEL or m['map_type'] in INTERNAL_TYPES: - f['kind'] = 'internal' - else: - f['kind'] = 'external' - f['ciel_ids'], f['correct_from_ciel'], f['pih_target_sameas'] = suggest(m, by_from, live) - f['pih_target_name'] = name_of(concepts.get(m['to_concept_code'])) - findings.append(f) - - bogus_self = {f['concept'] for f in findings if f['kind'] == 'self'} - for cid, c in concepts.items(): - if not cid.isdigit() or int(cid) < 20000 or c.get('retired'): - continue - has_self = any(x['map_type'] == 'SAME-AS' and x.get('to_source_url') == PIH and x['to_concept_code'] == cid - and not x.get('retired') for x in by_from.get(cid, [])) - if not has_self: - findings.append({'kind': 'no-self-mapping', 'concept': cid, 'name': name_of(c), - 'has_bogus_self': cid in bogus_self}) - - if a.name_uuids: - findings.extend(check_name_uuids(concepts, load_db_snapshot(a.refresh_snapshot))) - - watch = fix_watch(concepts, by_from, findings, a.fix_watch_since) if live else None - - if a.json: - json.dump(findings + ([watch] if watch else []), sys.stdout, indent=1) - print() - return - - counts = {} - for f in findings: - counts[f['kind']] = counts.get(f['kind'], 0) + 1 - print('Summary:', ', '.join(f'{k}={v}' for k, v in sorted(counts.items())) or 'no problems found') - order = {'name-clash': 0, 'dup-name-uuid': 1, 'external': 2, 'self': 3, 'no-self-mapping': 4, 'internal': 5} - for f in sorted(findings, key=lambda f: (order[f['kind']], int(f['concept']) if f['concept'].isdigit() else 0)): - if f['kind'] == 'name-clash': - print(f"NAME-CLASH PIH {f['concept']} name '{f['name']}' ({f['locale']}) uuid {f['name_uuid']} is already on " - f"another concept ({f['db_concept_uuid']}) in PIH databases, as '{f['db_name']}'" - f"{' (voided)' if f['db_voided'] else ''}. The OpenMRS import WILL FAIL.") - elif f['kind'] == 'dup-name-uuid': - print(f"DUP-NAME PIH {f['concept']} name '{f['name']}' uuid {f['name_uuid']} is also used by PIH " - f"{f['other_concept']}. The OpenMRS import will fail.") - elif f['kind'] == 'external': - print(f"EXTERNAL mapping {f['mapping']}: PIH {f['concept']} {f['map_type']} {short(f['to_source_url'])} " - f"rewritten to PIH {f['to_concept_code']} {f['pih_target_name']}; " - f"CIEL {'/'.join(f['ciel_ids']) or '?'} has {'/'.join(f['correct_from_ciel']) or '(none)'}; " - f"PIH {f['to_concept_code']} is SAME-AS {'/'.join(f['pih_target_sameas']) or '(none)'}") - elif f['kind'] == 'self': - print(f"SELF mapping {f['mapping']}: PIH {f['concept']} SAME-AS " - f"{short(f['to_source_url'])}:{f['to_concept_code']} (target is the concept itself; source should be PIH)") - elif f['kind'] == 'internal': - print(f"INTERNAL mapping {f['mapping']}: PIH {f['concept']} {f['map_type']} -> PIH {f['to_concept_code']} " - f"(to_source_url says {short(f['to_source_url'])})") - else: - print(f"NO-SELF PIH {f['concept']} {f['name']}" - + (' (has the bogus CIEL self-mapping instead)' if f['has_bogus_self'] else '')) - - if watch: - print_fix_watch(watch) - - -if __name__ == '__main__': - main() diff --git a/plugins/ocl/skills/ocl-search/scripts/ocl_release_check.py b/plugins/ocl/skills/ocl-search/scripts/ocl_release_check.py new file mode 100644 index 0000000..2c262c6 --- /dev/null +++ b/plugins/ocl/skills/ocl-search/scripts/ocl_release_check.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""PIH OCL release check: find problems in the PIH source that would break, or quietly damage, OpenMRS. Read-only. + +It runs a set of named checks, each written for a problem we've actually hit. To add a check, write a function +that returns findings and register it in CHECKS. To retire one (e.g. once OCL fixes the bug behind it), remove it +from CHECKS. Each finding has a severity: + error the OpenMRS import will fail, or data reaching servers will be wrong. Fix before releasing / merging. + warning wrong or odd in OCL, but harmless to servers. Report only. + +Checks (see --list-checks): + name-uuids UHM-9656. Concept name uuids already used by another concept in PIH databases (incl. voided names, + which OCL can't see), or by two PIH concepts. Compared against a PIH database snapshot: the pihcore + test DB (openmrs.sql, from 2023), downloaded once and cached in ~/.cache/ocl/. + clone-mappings UHM-9636, OCL ocl_issues#3012/#3013. Mappings that OCL's $clone rewrote: external codes (ICD-10, + LOINC...) replaced by a PIH concept (error); CIEL self-mappings and answer/set mappings with stale + source urls, and concepts >= 20000 with no SAME-AS PIH self-mapping (warnings). On live runs it + also watches for OCL fixing these bugs ("OCL fix watch"). + +What to check (pick one): + --unreleased [--since VERSION] everything changed in the live PIH source since the latest (or given) release. + This is the PRE-RELEASE check. + --export NEW [--baseline OLD] a released export (export.json or the OCL zip). With --baseline, only concepts + that changed between OLD and NEW are checked. This is what CI runs on a new + export before it goes into the distro. + --concept ID [ID ...] specific PIH concepts, live (e.g. right after cloning). + +Output and exit status: + --fail-on error|warning|never exit 1 if there's a finding at or above this severity (default: never). + --summary-md FILE also write a Markdown summary (for a PR description or a GitHub job summary). + --json machine-readable findings instead of text. + Exit status: 0 = OK, 1 = findings at or above --fail-on, 2 = the check couldn't run. + +The OCL token is read from $OCL_API_TOKEN, else ~/.config/ocl/token. Run with `python3 -I`. +This script is also run by the PIH/pihemr "Update OCL export" GitHub Action, which downloads it from this repo's +main branch. Keep these options and exit statuses working, or update that workflow at the same time. +""" +import argparse +import csv +import io +import json +import os +import re +import sys +import urllib.parse +import urllib.request +import zipfile + +API = 'https://api.openconceptlab.org' +PIH = '/orgs/PIH/sources/PIH/' +CIEL = '/orgs/CIEL/sources/CIEL/' +INTERNAL_TYPES = {'Q-AND-A', 'CONCEPT-SET'} +DB_SNAPSHOT_URL = ('https://raw.githubusercontent.com/PIH/openmrs-module-pihcore/master/' + 'api/src/test/resources/event/openmrs.sql') +DB_SNAPSHOT_CACHE = os.path.expanduser('~/.cache/ocl/pihcore-openmrs.sql') +UUID_RE = re.compile(r'[0-9A-Za-z-]{36}') +SEVERITY = {'warning': 1, 'error': 2} +_cache = {} + + +# ---------------------------------------------------------------- OCL access + +def token(): + tok = os.environ.get('OCL_API_TOKEN') + if tok: + return tok.strip() + path = os.path.expanduser('~/.config/ocl/token') + if os.path.exists(path): + with open(path) as f: + return f.read().strip() + return None + + +def get(path): + if path not in _cache: + tok = token() + if not tok: + raise RuntimeError('No OCL token: set OCL_API_TOKEN or create ~/.config/ocl/token (/ocl:setup)') + req = urllib.request.Request(API + path, headers={'Authorization': 'Token ' + tok}) + with urllib.request.urlopen(req, timeout=60) as r: + _cache[path] = json.load(r) + return _cache[path] + + +def source_of(url): + m = re.match(r'(/orgs?/[^/]+/sources/[^/]+/)', url or '') + return m.group(1) if m else None + + +def short(url): + return (url or '').rstrip('/').split('/')[-1] + + +def name_of(c): + names = (c or {}).get('names') or [] + pref = [n['name'] for n in names if n.get('locale') == 'en' and n.get('locale_preferred')] + return (pref or [n['name'] for n in names] or [''])[0] + + +def sort_key(cid): + return (not str(cid).isdigit(), int(cid) if str(cid).isdigit() else 0, str(cid)) + + +# ---------------------------------------------------------------- loading what to check + +class Context: + """What's being checked. `concepts`/`mappings` hold everything loaded; `scope` is the concept ids to report on.""" + + def __init__(self, concepts, mappings, scope, live, label, refresh_snapshot=False): + self.concepts, self.mappings, self.scope, self.live, self.label = concepts, mappings, set(scope), live, label + self.refresh_snapshot = refresh_snapshot + self.by_from = {} + for m in mappings: + self.by_from.setdefault(m['from_concept_code'], []).append(m) + self.extra_reports = [] # extra sections a check wants printed (e.g. the OCL fix watch) + + def mappings_of(self, cid): + if cid in self.by_from or not self.live: + return self.by_from.get(cid, []) + return get(f'{PIH}concepts/{urllib.parse.quote(cid)}/?includeMappings=true').get('mappings') or [] + + +def load_live(ids, label, refresh): + concepts, mappings = {}, [] + for cid in ids: + c = get(f'{PIH}concepts/{urllib.parse.quote(cid)}/?includeMappings=true&verbose=true') + concepts[cid] = c + mappings.extend(m for m in c.get('mappings') or [] if m['from_concept_code'] == cid) + return Context(concepts, mappings, ids, True, label, refresh) + + +def changed_since(version): + """PIH concept ids changed (themselves, or via one of their mappings) after release `version`.""" + cutoff = get(f'{PIH}{urllib.parse.quote(version)}/')['created_on'] + ids = set() + for kind, key in (('concepts', 'id'), ('mappings', 'from_concept_code')): + page = 1 + while True: + items = get(f'{PIH}{kind}/?sortDesc=last_update&limit=200&page={page}') + fresh = [i for i in items if (i.get('version_updated_on') or '') > cutoff] + ids.update(i[key] for i in fresh) + if len(fresh) < len(items) or len(items) < 200: + break + page += 1 + return cutoff, sorted(ids, key=sort_key) + + +def read_export(path): + if zipfile.is_zipfile(path): + with zipfile.ZipFile(path) as z: + name = next(n for n in z.namelist() if n.endswith('.json')) + return json.loads(z.read(name)) + with open(path) as f: + return json.load(f) + + +def load_export(path, baseline, refresh): + d = read_export(path) + concepts = {c['id']: c for c in d['concepts']} + scope = set(concepts) + label = f"export {d.get('id', '?')}" + if baseline: + b = read_export(baseline) + old_c = {c['id']: (c.get('version'), c.get('retired')) for c in b['concepts']} + old_m = {m['id']: (m.get('version'), m.get('retired')) for m in b['mappings']} + scope = {cid for cid, c in concepts.items() if old_c.get(cid) != (c.get('version'), c.get('retired'))} + scope |= {m['from_concept_code'] for m in d['mappings'] if old_m.get(m['id']) != (m.get('version'), m.get('retired'))} + label += f" (concepts changed since {b.get('id', '?')})" + return Context(concepts, d['mappings'], scope, False, label, refresh), d.get('id') + + +# ---------------------------------------------------------------- check: name-uuids + +def load_db_snapshot(refresh=False): + """{name_uuid: (db_concept_uuid, name, locale, voided)} from the pihcore test DB dump.""" + if refresh or not os.path.exists(DB_SNAPSHOT_CACHE): + os.makedirs(os.path.dirname(DB_SNAPSHOT_CACHE), exist_ok=True) + print(f'Downloading PIH DB snapshot to {DB_SNAPSHOT_CACHE} (about 33 MB)...', file=sys.stderr) + urllib.request.urlretrieve(DB_SNAPSHOT_URL, DB_SNAPSHOT_CACHE) + with open(DB_SNAPSHOT_CACHE, encoding='utf-8', errors='replace') as f: + sql = f.read() + + def rows(table): + for blk in re.findall(r'INSERT INTO `%s` VALUES (.*?);\n' % table, sql, re.S): + cur, depth, quoted, esc = '', 0, False, False + for ch in blk: + if quoted: + cur += ch + if esc: + esc = False + elif ch == '\\': + esc = True + elif ch == "'": + quoted = False + continue + if ch == "'": + quoted = True + cur += ch + elif ch == '(' and depth == 0: + depth, cur = 1, '' + elif ch == ')' and depth == 1: + depth = 0 + yield next(csv.reader(io.StringIO(cur), quotechar="'", escapechar='\\')) + elif depth: + cur += ch + + concept_uuid = {r[0]: r[-1] for r in rows('concept')} + names = {} + # concept_name columns: concept_name_id, concept_id, name, locale, locale_preferred, creator, + # date_created, concept_name_type, voided, voided_by, date_voided, void_reason, uuid, ... + for r in rows('concept_name'): + uuid = next((x for x in r[12:] if UUID_RE.fullmatch(x or '')), None) + if uuid: + names[uuid] = (concept_uuid.get(r[1]), r[2], r[3], r[8] == '1') + return names + + +# Clashes with the DB snapshot that servers no longer have, because a distro change frees the uuid first. +# These are reported as warnings, with the reason. Add an entry when you fix a clash that way. +HANDLED_NAME_CLASHES = { + '7159BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB': + 'freed on servers by pihemr liquibase changeset 20261105-free-pas-name-uuid-for-ocl-import (UHM-9656)', +} + + +def check_name_uuids(ctx): + db = load_db_snapshot(ctx.refresh_snapshot) + owner = {} # name uuid -> concept id, across everything loaded + for cid, c in ctx.concepts.items(): + for n in c.get('names') or []: + if n.get('external_id'): + owner.setdefault(n['external_id'], set()).add(cid) + findings = [] + for cid in sorted(ctx.scope, key=sort_key): + c = ctx.concepts.get(cid) + if not c: + continue + for n in c.get('names') or []: + u = n.get('external_id') + if not u: + continue + others = sorted(owner.get(u, set()) - {cid}, key=sort_key) + if others: + findings.append({'check': 'name-uuids', 'kind': 'dup-name-uuid', 'severity': 'error', 'concept': cid, + 'message': f"PIH {cid} name '{n['name']}' uuid {u} is also used by PIH {', '.join(others)}. " + 'The OpenMRS import will fail.'}) + hit = db.get(u) + if hit and hit[0] != c.get('external_id'): + where = (f"PIH {cid} name '{n['name']}' ({n.get('locale')}) uuid {u} is already on another concept " + f"({hit[0]}) in PIH databases, as '{hit[1]}'{' (voided)' if hit[3] else ''}.") + if u in HANDLED_NAME_CLASHES: + findings.append({'check': 'name-uuids', 'kind': 'name-clash-handled', 'severity': 'warning', + 'concept': cid, 'message': f'{where} Handled: {HANDLED_NAME_CLASHES[u]}.'}) + else: + findings.append({'check': 'name-uuids', 'kind': 'name-clash', 'severity': 'error', 'concept': cid, + 'message': f'{where} The OpenMRS import WILL FAIL.'}) + return findings + + +# ---------------------------------------------------------------- check: clone-mappings + +OCL_ISSUES = { + 3012: 'copied mappings keep the original from_source_url', + 3013: 'mappings retargeted to an equivalent PIH concept (incl. CIEL self-mapping, external codes)', +} +FIX_WATCH_SINCE = '2026-11-05' # only clones made after the issues were filed tell us about a fix +CLONE_WINDOW_SECONDS = 300 # a clone creates its concept and mappings within moments of each other + + +def suggest(ctx, m): + """For a corrupted external mapping: CIEL's current code, and the code the swapped-in PIH concept is SAME-AS.""" + tgt = m['to_source_url'] + ciel_ids = [x['to_concept_code'] for x in ctx.mappings_of(m['from_concept_code']) + if x['map_type'] == 'SAME-AS' and source_of(x.get('to_concept_url')) == CIEL and not x.get('retired')] + codes = [] + if token(): + for ciel_id in ciel_ids: + try: + for x in get(f'{CIEL}concepts/{urllib.parse.quote(ciel_id)}/?includeMappings=true').get('mappings') or []: + if x.get('to_source_url') == tgt and x['map_type'] == m['map_type'] and not x.get('retired'): + codes.append(x['to_concept_code']) + except Exception: + pass + via = [x['to_concept_code'] for x in ctx.mappings_of(m['to_concept_code']) + if x['map_type'] == 'SAME-AS' and x.get('to_source_url') == tgt and not x.get('retired')] + return ciel_ids, sorted(set(codes)), sorted(set(via)) + + +def check_clone_mappings(ctx): + findings = [] + for m in ctx.mappings: + if m.get('retired') or m['from_concept_code'] not in ctx.scope: + continue + tsu, tcu = m.get('to_source_url'), m.get('to_concept_url') + if not (tsu and tcu) or source_of(tcu) != PIH or tsu == PIH: + continue + cid = m['from_concept_code'] + f = {'check': 'clone-mappings', 'concept': cid, 'mapping': m['id']} + if m['to_concept_code'] == cid: + f.update(kind='self', severity='warning', + message=f"mapping {m['id']}: PIH {cid} SAME-AS {short(tsu)}:{cid} (points at itself; source should be PIH)") + elif tsu == CIEL or m['map_type'] in INTERNAL_TYPES: + f.update(kind='internal', severity='warning', + message=f"mapping {m['id']}: PIH {cid} {m['map_type']} -> PIH {m['to_concept_code']} " + f"(to_source_url says {short(tsu)})") + else: + ciel_ids, now, lost = suggest(ctx, m) + f.update(kind='external', severity='error', + message=f"mapping {m['id']}: PIH {cid} {m['map_type']} {short(tsu)} was rewritten to PIH " + f"{m['to_concept_code']} {name_of(ctx.concepts.get(m['to_concept_code']))}; the {short(tsu)} " + f"code is lost. Lost code: {'/'.join(lost) or '?'}; CIEL {'/'.join(ciel_ids) or '?'} today: " + f"{'/'.join(now) or '(none)'}") + findings.append(f) + bogus_self = {f['concept'] for f in findings if f['kind'] == 'self'} + for cid in sorted(ctx.scope, key=sort_key): + c = ctx.concepts.get(cid) + if not c or not cid.isdigit() or int(cid) < 20000 or c.get('retired'): + continue + if not any(x['map_type'] == 'SAME-AS' and x.get('to_source_url') == PIH and x['to_concept_code'] == cid + and not x.get('retired') for x in ctx.by_from.get(cid, [])): + findings.append({'check': 'clone-mappings', 'kind': 'no-self-mapping', 'severity': 'warning', 'concept': cid, + 'message': f"PIH {cid} {name_of(c)} has no SAME-AS PIH:{cid} self-mapping" + + (' (it has the bogus CIEL self-mapping instead)' if cid in bogus_self else '')}) + if ctx.live: + ctx.extra_reports.append(fix_watch_report(ctx, findings)) + return findings + + +def ocl_issue_states(): + states = {} + for n in OCL_ISSUES: + try: + req = urllib.request.Request(f'https://api.github.com/repos/OpenConceptLab/ocl_issues/issues/{n}', + headers={'Accept': 'application/vnd.github+json'}) + with urllib.request.urlopen(req, timeout=10) as r: + d = json.load(r) + states[n] = (d.get('state'), (d.get('closed_at') or '')[:10]) + except Exception: # offline, rate-limited... the watch is best-effort + pass + return states + + +def fix_watch_report(ctx, findings): + """Look at clones made since FIX_WATCH_SINCE for signs that OCL has fixed the clone bugs. Returns text or ''.""" + from datetime import datetime + ts = lambda s: datetime.fromisoformat(s.replace('Z', '+00:00')) # noqa: E731 + self_bug = {f['concept'] for f in findings if f['kind'] == 'self'} + ext_bug = {f['concept'] for f in findings if f['kind'] == 'external'} + clones, ev = [], {'3012': set(), '3013': set(), 'name_uuids': set()} + for cid in ctx.scope: + c = ctx.concepts.get(cid) or {} + if not c.get('created_on') or c['created_on'][:10] < FIX_WATCH_SINCE: + continue + created = ts(c['created_on']) + near = [m for m in ctx.by_from.get(cid, []) + if m.get('created_on') and abs((ts(m['created_on']) - created).total_seconds()) < CLONE_WINDOW_SECONDS] + equiv = [m for m in near if m['map_type'] == 'SAME-AS' and source_of(m.get('to_concept_url')) == CIEL + and not m.get('to_source_url')] + if not equiv: + continue # not a $clone + clones.append(cid) + copied = [m for m in near if m not in equiv] + if copied: + ev['3012'].add('still happening' if any(m.get('from_source_url') not in (None, PIH) for m in copied) + else 'looks fixed') + ev['3013'].add('still happening' if (cid in self_bug or cid in ext_bug) else 'looks fixed') + ev['name_uuids'].add('still CIEL name uuids' if any((n.get('external_id') or '').endswith('BBBBBBBB') + for n in c.get('names') or []) else 'fresh name uuids') + issues = ocl_issue_states() + closed = [n for n, (state, _) in issues.items() if state == 'closed'] + if not (clones or closed): + return '' + lines = ['OCL fix watch:'] + for n, why in OCL_ISSUES.items(): + st = issues.get(n) + lines.append(f" ocl_issues#{n} ({why}): " + ((st[0] + (f' on {st[1]}' if st[1] else '')) if st + else 'state unknown (GitHub not reachable)')) + if clones: + lines.append(f" Clones made since {FIX_WATCH_SINCE} that were checked: {' '.join(sorted(clones, key=sort_key))}") + lines += [f' {k}: {", ".join(sorted(v))}' for k, v in ev.items() if v] + said = {'3012': 'new clones suggest ocl_issues#3012 is fixed', '3013': 'new clones suggest ocl_issues#3013 is fixed', + 'name_uuids': "new clones get fresh name uuids instead of keeping CIEL's"} + signs = [k for k, v in ev.items() if sorted(v) in (['looks fixed'], ['fresh name uuids'])] + if closed or signs: + lines.append(' POSSIBLE OCL FIX: ' + '; '.join([f'ocl_issues#{n} is closed' for n in closed] + + [said[k] for k in signs]) + '.') + lines.append(" Suggest to the user (do not do it unprompted) reviewing whether the release check's clone-mappings " + 'check and the skill guidance about it are still needed.') + return '\n'.join(lines) + + +# ---------------------------------------------------------------- registry and main + +CHECKS = { + # name: (function, one-line description). Add new checks here; remove ones that are no longer needed. + 'name-uuids': (check_name_uuids, 'concept name uuids that clash with PIH databases or other PIH concepts (UHM-9656)'), + 'clone-mappings': (check_clone_mappings, "mappings OCL's $clone rewrote (UHM-9636, ocl_issues#3012/#3013)"), +} + + +def markdown(ctx, findings, checks): + errors = [f for f in findings if f['severity'] == 'error'] + warnings = [f for f in findings if f['severity'] == 'warning'] + out = [f"### PIH OCL release check: {'❌ ' + str(len(errors)) + ' error(s)' if errors else '✅ no errors'}", '', + f"Checked {len(ctx.scope)} concept(s): {ctx.label}. Checks: {', '.join(checks)}.", ''] + if errors: + out += ['**Errors** (the OpenMRS import will fail, or wrong data will reach servers):', ''] + out += [f"- `{f['check']}` / {f['kind']}: {f['message']}" for f in errors] + [''] + if warnings: + counts = {} + for f in warnings: + counts[(f['check'], f['kind'])] = counts.get((f['check'], f['kind']), 0) + 1 + out += ['**Warnings** (wrong in OCL, harmless to servers): ' + + ', '.join(f'{k[0]}/{k[1]} {v}' for k, v in sorted(counts.items())), ''] + return '\n'.join(out) + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + g = p.add_mutually_exclusive_group() + g.add_argument('--unreleased', action='store_true', help='check everything changed since the latest release') + g.add_argument('--export', metavar='NEW', help='a released export: export.json or the OCL zip') + g.add_argument('--concept', nargs='+', metavar='PIH_ID', help='specific PIH concepts (live)') + g.add_argument('--list-checks', action='store_true') + p.add_argument('--since', metavar='VERSION', help='with --unreleased: compare against this release, e.g. 1.8.46') + p.add_argument('--baseline', metavar='OLD', help='with --export: only check concepts changed since this export') + p.add_argument('--checks', default=','.join(CHECKS), help=f'comma-separated (default: {",".join(CHECKS)})') + p.add_argument('--fail-on', choices=['error', 'warning', 'never'], default='never') + p.add_argument('--summary-md', metavar='FILE') + p.add_argument('--refresh-snapshot', action='store_true', help='re-download the PIH DB snapshot') + p.add_argument('--json', action='store_true') + a = p.parse_args(argv) + + if a.list_checks: + for name, (_, desc) in CHECKS.items(): + print(f'{name:<16} {desc}') + return 0 + if not (a.unreleased or a.export or a.concept): + p.error('choose what to check: --unreleased, --export or --concept') + checks = [c.strip() for c in a.checks.split(',') if c.strip()] + unknown = [c for c in checks if c not in CHECKS] + if unknown: + p.error(f'unknown check(s): {", ".join(unknown)}. Known: {", ".join(CHECKS)}') + + try: + if a.unreleased: + version = a.since or get(f'{PIH}latest/')['id'] + cutoff, ids = changed_since(version) + print(f'Checking {len(ids)} PIH concept(s) changed since {version} ({cutoff[:16]}Z): ' + f'{" ".join(ids) or "(none)"}', file=sys.stderr) + ctx = load_live(ids, f'changed in the live PIH source since {version}', a.refresh_snapshot) + elif a.export: + ctx, _ = load_export(a.export, a.baseline, a.refresh_snapshot) + else: + ctx = load_live(a.concept, 'PIH ' + ' '.join(a.concept), a.refresh_snapshot) + findings = [] + for name in checks: + findings.extend(CHECKS[name][0](ctx)) + except Exception as e: # network, auth, bad file... + print(f'ERROR: the release check could not run: {e}', file=sys.stderr) + return 2 + + if a.summary_md: + with open(a.summary_md, 'w') as f: + f.write(markdown(ctx, findings, checks) + '\n') + if a.json: + json.dump(findings, sys.stdout, indent=1) + print() + else: + counts = {} + for f in findings: + counts[f"{f['severity']} {f['kind']}"] = counts.get(f"{f['severity']} {f['kind']}", 0) + 1 + print(f'Checked {len(ctx.scope)} concept(s): {ctx.label}') + print('Summary:', ', '.join(f'{k}={v}' for k, v in sorted(counts.items())) or 'no problems found') + for f in sorted(findings, key=lambda f: (-SEVERITY[f['severity']], f['check'], sort_key(f['concept']))): + print(f"{f['severity'].upper():<7} {f['check']}/{f['kind']}: {f['message']}") + for r in ctx.extra_reports: + if r: + print('\n' + r) + worst = max((SEVERITY[f['severity']] for f in findings), default=0) + return 1 if a.fail_on != 'never' and worst >= SEVERITY[a.fail_on] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/plugins/ocl/skills/ocl-search/scripts/test_ocl_release_check.py b/plugins/ocl/skills/ocl-search/scripts/test_ocl_release_check.py new file mode 100644 index 0000000..3e9295c --- /dev/null +++ b/plugins/ocl/skills/ocl-search/scripts/test_ocl_release_check.py @@ -0,0 +1,150 @@ +"""Tests for ocl_release_check.py. No network. Run: python3 -I -m unittest discover -s -p 'test_*.py'""" +import io +import json +import os +import sys +import tempfile +import unittest +import zipfile +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import ocl_release_check as rc # noqa: E402 + +PIH, CIEL = rc.PIH, rc.CIEL + + +def concept(cid, uuid, names, version='1', retired=False): + return {'id': cid, 'external_id': uuid, 'version': version, 'retired': retired, 'concept_class': 'Misc', + 'names': [{'name': n, 'locale': 'en', 'locale_preferred': i == 0, 'external_id': u} + for i, (n, u) in enumerate(names)]} + + +def mapping(mid, frm, map_type, to_code, to_source_url, to_concept_url=None, version='1', retired=False): + return {'id': mid, 'from_concept_code': frm, 'map_type': map_type, 'to_concept_code': to_code, + 'to_source_url': to_source_url, 'to_concept_url': to_concept_url, 'version': version, 'retired': retired} + + +def self_map(mid, cid): + return mapping(mid, cid, 'SAME-AS', cid, PIH, f'{PIH}concepts/{cid}/') + + +class Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db = {} # fake DB snapshot: name uuid -> (concept uuid, name, locale, voided) + self._orig_db, self._orig_tok = rc.load_db_snapshot, rc.token + rc.load_db_snapshot = lambda refresh=False: self.db + rc.token = lambda: None # no network: suggest() skips CIEL lookups + + def tearDown(self): + rc.load_db_snapshot, rc.token = self._orig_db, self._orig_tok + self.tmp.cleanup() + + def export(self, name, version, concepts, mappings, as_zip=False): + d = {'id': version, 'concepts': concepts, 'mappings': mappings} + path = os.path.join(self.tmp.name, name) + if as_zip: + with zipfile.ZipFile(path, 'w') as z: + z.writestr('export.json', json.dumps(d)) + else: + with open(path, 'w') as f: + json.dump(d, f) + return path + + def run_check(self, *argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = rc.main(list(argv)) + return code, out.getvalue(), err.getvalue() + + +class NameUuids(Base): + def test_clash_with_db_snapshot_is_an_error(self): + self.db['NAME-U-1'] = ('OTHER-CONCEPT', 'Old name', 'en', True) + e = self.export('e.json', '1.0', [concept('20001', 'C1', [('New', 'NAME-U-1')])], [self_map('m1', '20001')]) + code, out, _ = self.run_check('--export', e, '--checks', 'name-uuids', '--fail-on', 'error') + self.assertEqual(code, 1) + self.assertIn('name-clash', out) + self.assertIn('(voided)', out) + + def test_same_concept_in_db_is_fine(self): + self.db['NAME-U-1'] = ('C1', 'New', 'en', False) + e = self.export('e.json', '1.0', [concept('20001', 'C1', [('New', 'NAME-U-1')])], []) + code, out, _ = self.run_check('--export', e, '--checks', 'name-uuids', '--fail-on', 'error') + self.assertEqual(code, 0) + + def test_handled_clash_is_only_a_warning(self): + u = next(iter(rc.HANDLED_NAME_CLASHES)) + self.db[u] = ('OTHER', 'Old', 'en', True) + e = self.export('e.json', '1.0', [concept('20001', 'C1', [('New', u)])], []) + code, out, _ = self.run_check('--export', e, '--checks', 'name-uuids', '--fail-on', 'error') + self.assertEqual(code, 0) + self.assertIn('name-clash-handled', out) + + def test_same_name_uuid_on_two_concepts(self): + e = self.export('e.json', '1.0', [concept('20001', 'C1', [('A', 'DUP')]), concept('20002', 'C2', [('B', 'DUP')])], []) + code, out, _ = self.run_check('--export', e, '--checks', 'name-uuids', '--fail-on', 'error') + self.assertEqual(code, 1) + self.assertIn('dup-name-uuid', out) + + +class CloneMappings(Base): + def test_external_code_swapped_for_pih_concept_is_an_error(self): + icd = '/orgs/WHO/sources/ICD-10-WHO/' + concepts = [concept('20001', 'C1', [('Lichen', 'N1')]), concept('10034', 'C2', [('LSC', 'N2')])] + maps = [self_map('m0', '20001'), mapping('m1', '20001', 'BROADER-THAN', '10034', icd, f'{PIH}concepts/10034/'), + mapping('m2', '10034', 'SAME-AS', 'L28.0', icd, f'{icd}concepts/L28.0/')] + e = self.export('e.json', '1.0', concepts, maps) + code, out, _ = self.run_check('--export', e, '--checks', 'clone-mappings', '--fail-on', 'error') + self.assertEqual(code, 1) + self.assertIn('clone-mappings/external', out) + self.assertIn('L28.0', out) # the lost code is reported + + def test_self_and_missing_self_mapping_are_warnings(self): + e = self.export('e.json', '1.0', [concept('20001', 'C1', [('X', 'N1')])], + [mapping('m1', '20001', 'SAME-AS', '20001', CIEL, f'{PIH}concepts/20001/')]) + code, out, _ = self.run_check('--export', e, '--checks', 'clone-mappings', '--fail-on', 'error') + self.assertEqual(code, 0) + self.assertIn('clone-mappings/self', out) + self.assertIn('clone-mappings/no-self-mapping', out) + code, _, _ = self.run_check('--export', e, '--checks', 'clone-mappings', '--fail-on', 'warning') + self.assertEqual(code, 1) + + +class BaselineAndOutput(Base): + def test_baseline_limits_scope_to_changed_concepts(self): + self.db['OLD-CLASH'] = ('OTHER', 'Old', 'en', True) + unchanged = concept('20001', 'C1', [('Known problem', 'OLD-CLASH')]) + old = self.export('old.json', '1.0', [unchanged], []) + new = self.export('new.zip', '1.1', [unchanged, concept('20002', 'C2', [('New one', 'N2')])], + [self_map('m2', '20002')], as_zip=True) + md = os.path.join(self.tmp.name, 'summary.md') + code, out, _ = self.run_check('--export', new, '--baseline', old, '--fail-on', 'error', '--summary-md', md) + self.assertEqual(code, 0, out) # 20001's clash is old, so not checked + self.assertIn('Checked 1 concept(s)', out) + with open(md) as f: + self.assertIn('no errors', f.read()) + + def test_changed_mapping_brings_its_concept_into_scope(self): + c = concept('20001', 'C1', [('X', 'N1')]) + old = self.export('old.json', '1.0', [c], [self_map('m1', '20001')]) + new = self.export('new.json', '1.1', [c], [self_map('m1', '20001'), self_map('m2', '20001')]) + _, out, _ = self.run_check('--export', new, '--baseline', old) + self.assertIn('Checked 1 concept(s)', out) + + def test_errors_and_usage(self): + code, _, err = self.run_check('--export', os.path.join(self.tmp.name, 'missing.json')) + self.assertEqual(code, 2) + self.assertIn('could not run', err) + with self.assertRaises(SystemExit): + self.run_check('--export', 'x.json', '--checks', 'no-such-check') + code, out, _ = self.run_check('--list-checks') + self.assertEqual(code, 0) + for name in rc.CHECKS: + self.assertIn(name, out) + + +if __name__ == '__main__': + unittest.main() From 80556211fcc0050b3d26fbb0ea7e2a3a82e6ab83 Mon Sep 17 00:00:00 2001 From: mogoodrich Date: Fri, 6 Nov 2026 11:24:44 -0500 Subject: [PATCH 2/2] Remove committed __pycache__ files and ignore them Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 ++ .../__pycache__/check_update.cpython-312.pyc | Bin 5726 -> 0 bytes .../ocl_release_check.cpython-312.pyc | Bin 38608 -> 0 bytes .../test_check_update.cpython-312.pyc | Bin 9311 -> 0 bytes .../test_ocl_release_check.cpython-312.pyc | Bin 13263 -> 0 bytes 5 files changed, 2 insertions(+) create mode 100644 .gitignore delete mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/check_update.cpython-312.pyc delete mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/ocl_release_check.cpython-312.pyc delete mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/test_check_update.cpython-312.pyc delete mode 100644 plugins/ocl/skills/ocl-search/scripts/__pycache__/test_ocl_release_check.cpython-312.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/check_update.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/check_update.cpython-312.pyc deleted file mode 100644 index d3743264bcb2f5533c5bef860b2a4c484c7579f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5726 zcmbstZ)_9S{XO5Eeg2o&`46RVDHLKLHk1^f7@YbH(GKCB-mWdW%jmA3uf*>_G- z)_vHM-|s!Y_j~XC-uwN(-+R3-1m($p|2@&`LFg~^k6GAC=UxyabQOtc1c_L*$#?`C zt!>0+w9E)&wCo6LwA_dtT1Ivx`4Ju?ksWc`ki;h%%#Ewe+Tc9Jlj9uNvu0GN8IvVkdUVwFC4ZwP_7GQ(u z1K23~0XB(s0Gp*2v3{ZrA<_a=Fo`F=zCVmNo9CM}VwAOIZGnbphYg`&NtOj|ToN*> zM1=U55EW9=By`V9M2#ycK^ZfqPxqe`UR7f9t3q1NoQ z8HGbfNi-E3r!)aQp$)sh&MD#BaqVPgG$=eTMa6a{C1>};Z6r|@Qi@AcCZyDUA(|3} zSTr_nkSYm4az@sKF@?a*#b{Q5Q!a*r21W?(8_J}`s3x_;g4l#XB3$+~iKpmQQj#FY z0nk08@?r;mq+ zPYB@`!~Fx{V*@8TfeKZN%5-h_g`WL_3?!(U(4Fl8>`YqF6rfd;gf^HBz)*A?E^M+Y zof(zm>Nu=5pOB&nJ#=|MFp3mB8wB!z6riHL!iRP-PK20}&KhSJ`4h)gJ*$(nNccEF(su*DJ>XNlA*8jD?~Y3 z;k{}+^?w(OoW0BuodK5Q{9ofqeEKicv1ND?Jrwk;a*$3A%Pu5pf=w*i$%<$pOu2qKLNFQ64A2W^67ZbKA5W-pzAh z7g%%lINGemLtyQOP=FiEHNULxJ%6df)#AD+j5#(G}(n(&aF+XhuV1z;MjxEp7q@76E6A6>4kU*TO>JeNF$ z#?ZXK#2=atf7;dk=3$`lrvGQnKW@I||H;;+)`P{?gY!E}tsRTLBPI8d0^4_Y7l3=P z9g11v3V$3>4YB?!bT*2$$=ruAe2ST}})+pP7LBIGUcwM3uI z+eBNQeb~AjIPx5<&B4xjW)qgR(|W8jRe5FtZb)n*GlcR$;ly#{9}6nLfeP3Mbw?&O zkpj;Wz=XbqZXcD#<0(<+48y29Vi`g}90QEO2%SkrFX)Ucr2?Gpv`npTFPm8%M|C`^ zO}yQnJsf$O75NSzcSxZdc1S`+;n)^!B1yr!?XR% zwJj^W`_0~2e7WzP-Z}hsUx|MN=G~qdZT8%|hvv>*?|3U$tbMHLe(YxW7CVnW>|MYg z^!>_JZ0joS>M}NKZkapy!_3T61vY%^xq1IDUbwjjx(iMKgoY0>s8(U2XYl_;=&a{| zV!f19&^3x}lmwNVO=Yvm5ivatUfc#RUQxOLtFzQ`>zu4aMOEjY2@m%7ofsO{o!~6Q zh$daoevS+>%a7={-rLk$5`RT5c)fi@AWxoG(h43E4M9YiXgv|)4xtTn`J=mK*FfhFb!Kc|-fbL`OK zNlD3QIWD7(wLe855<~%Bw`Yhf$47Msk#yW4<>p#%?7p`9dfScAwa~3SrL9ktY7fo#7i)XIU;yn4 zin~U^1C?6NpB@fB&V2MZ7Y;f$I53ON{{p0|$hpp8CtzfAG1&l2vTRuu$DB=A@?V{! z_NOWmhUMA){|JJ`>WcO_ft5RvR%4#1`K(@@)w2?jF?JUs3>AVbnlwegHb@==)PP%O zsR-ySHB36MYLTSz-`gARJ$WKBbfWj!!Q(?!Ruigay5rfQ6ND&4cfA;uGg7(FpOUiW zo^v>xE)Rk%aOF^Wojf=q#*j#k!(NA>suKXF(W;H*yi|3czV!6vuDQk=Td!@szHMns zun0eQ@WV$xardm$H(%|1t8-4epuA}#?h!HFX1K2JD+qVweZD4!e zvm9;((`#VXufwb{Fl*_)qUSxXN?n#&hvfq-o9G8D+s4yD$Ss!xLL8i07ZiwCvm8#| zqB+5vx30EuHiQ!!Z*~T3lBRGVHmy?i~LOHM$_r@JOeT$2n>ZI}w36En%QN{#}hMywOClUC^KoZ$*cFCC}$N5_(m zRh3$1BTA}x42EPV-+pPPZB>1 z$XPp+o!edFTaAEg=HzQrE4;Vh+kflKtyA;ayG?ibLQVf7|DDwuS*W{>=O{cbk4^znd%6Jio{fK|J^6{hi3SWewS!jVm61p<(ZxC+G3)BTM_biu<|> zd%G8oEu34373z*HdU{vt8@^iQ0Q0N+9^`8V6X*1Q>hY~I&^}P9XTIV%){U-)ncfcc zn{H?CA?9OlS8tH{ILOg{hx53N`7Oo(V}NWG8W&JAc<&~FjXu&caY%E+q){Nzg%4`w zQuRl-D~6PYAXNpfMv7J6IL3gAwLk({O>`mg1y!{&1?smzMe#+Pg%F3fRaFfzgSp^0 z2{;W)DqoU}4=)u34e&<5SQgK30rezI!FMR2QfI^{x~uF0`}zk?1Z;-*lILM^LypGM zFA(G<7^YPn08sX0{*{KuOAUKwocF;Yfsnns|BkEVK2%^2eR`Ss{i5c(84OTT52cKV+NsYW>XfbUK!Vn&vFNEF6E zw~_h*01#b_|G~O3d#?py&u7T_8FGDw+@GPwKcbdZ7S|q`cKylQG&6kV%%wAzzdQF- z(Yy1eRP^pE1j9vdc-pn>+cxd})LlEvU+!6Qw-(*4kWwwV_Z1=b4?_S^I0!I&_nD&m zxoLjQ&f;(5wHiAY2`0|T?U>hLVV7D5V`VVgIF;oBm diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/ocl_release_check.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/ocl_release_check.cpython-312.pyc deleted file mode 100644 index 0dca8ca2487bfec86427e45a49f44ff82d6b11f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38608 zcmbWg3sf6to+ql(8zh0m8~hRm46-dS#xF3o!_&sbU<`g?J6NU!2=j7D7^4s!rEP4`B*8j*W!?dWPvRw|s@?gHsobjZht%JJ>O6eKzi{1;pmRrp!hLyugTakmuU>~bHWNoafw+hZ4r^D^Ews$$(FO^w4Tz$NC zK*Yb0)6+9-v5-pxA*t;3>U`7*LkeMV4vISu)3WdYk!Be&(Z7TrE=X{txiXK zmvu;Rc|6WOYX>rQ$knh8IrFbLt&VojprZ$+bh$i*)|P&&gXa;6)q5*Ti8$7d!M=8n ztG_RvRa9_#1_hK!t#b8sqHwfMaCW-f9;aY+d8mO^hia;hpsvUf(OrVm+TZ827B~w# z3-N>|Q!jP6t~%YUFD?#tS}!`gsGD3K+g7CM?MLlgp7;)-zc-v)4K*iJthLJ_HT#Ma z?HewOw_2TofF|Oi?hSR9)zv%DFQ9i_Jw4VAhpUJ6E${F+PzbU^1JHE>GI2|BE;`Zd z{yx;R)^!z?La#XymBy3e7Fm0pLMNUIQQ(k6=%bfX3L3P2bRU|J)@`+39Q2@)LT``L zjaK%{#n1q zqD!^9`y2!Au6|D$8>azRS39~mMyVbr(pFbm3;Hqhdj)r)`!Yt4O^EH=wwIs=eE(2i zPrrle&L$O2#C8lSexs=`6&11{Mg8qPMKoOPJ(z^{UdO-y1`Yiw^<~K}tSvMRvD#d2 z_n_0AQ?jjidl7{th1R2TbT;=9G2d;?WjSG1V5WGSWme}^%=|t_4?3EsUahODwij=U z)syv&bq!U8g@raG9_Vqjqoy=Y(LIP&Li54?;sTIlF=4>^S*TJYhv z47ogAR=3B|6RRUbP-al*0Z3qdB+Uo6b^ku=_HDQ@^^v}>-`ZSpw8mc1%<24Yh$0a$A#s|AP?5)>gi&&I(ppwSREb=1>giG3Ux4+J1rK>NI*uccy!dp(0eKF zMjIg7DT0MqEo==~3kF>6mk4&S#bCD&_Ju$zZ#`?bV?_d#oUA$CT-Vre9@TJS9S?ih zqHgbU^mR%TfxrQ)a1_XulCm!jHqi=D%TBbakA{XpZ<;dSdbM=90MR8VcIda=c+&>qco*^f82ULg64XP$quhal14-g(|;Sdc>Rh=~I zsF=gr=N$TdiVL+W1WK_#boOIbxcV^2)G{6rPw1DrN(NMQ)z-84!$M(QB#(l7z}fEV zaJ5G-7ffk3w$k1rxH`K4a5`uwqD6`cN0X{?&@%vNOmp9P6*a(0@eH~NzS`}yDPTL5 zb`M`@n)~`_Dp9#CezDcnL6sGpSpC=!rJc`;4#Ds^F4A6wS?9)Lm3AX*0q^W^4E6xV zv5Zg$D#|_B+v^a9?Y+FUwywTLZkBwPRtQa>HnJu$emmR?%!gH>!S3 zRL5DjS@&5Rk8HIT;~v2SqPip8uUoC#5r+m^TG8!<=Flbr=z)=G3C(R!|0PU9bRpG6 z0`j?-^KBK!>e^ZwkJK~(ndt%CAfVpg*Wv19P@Sd1hC0|ML9h!0SpEHdC02Xgg+k;_ zL&Ca>a%SC&QrWJBVgU_`u=_ZB1?$EW1K1R$RVGc>jdG_}Fw6(I5ZeRg9S1$+k!s1t z9l*Rl-yPqJ{SFwMqp!WI(0at_9H4fi(*10#Bt$7{{$Uji^$V9UN7-gNDAgf^3<{Sz zdisZGic#OWu{B#gu3l$h{Fj7)Hh9-}c{~H|vZ5l#fUA&p$k5v9aa=6y7dpLZMF`zR z)JO^bOV8d6={@Z&;ScPGSJz~(XsEVVcnwvJ4OKPATI|g=E#4lv9KkVE*a_G(coFD@ zfYN|ddN3Hhtc=heZSU>p2Ya0M(5_z8>ma0%>R99!+KUL%770$dLRc+*o}#Gj-Mezr z4&`Exj$uB#&0bnzZ*|zc_VeE<*){Sdktkmh^6z!=3z0)33ZFtS`xM=}XJg3(LbD>O1pTw|x((01}YStUIXcbb5jsYzVG_ zpnAaJ=?cbUJpyjioC|8Pc-$U$P)!pes3uThQw3Fk0s?hMkn3>M00YoHbr*H@_d1Ju zJNx^20oZF1kIB~4KhR4vuD!=G$YYZB3<8sP1N^c5UP8NzLT?#-abE)2;HF2r+vyOH zjRa2EtF}zl_nUoRXloZ1kGw~bbPu8Utz zOu9ZiKK#meM19r+gX#M2@!d;?b$-J-aeYa^uw%6PfiCWcLw_*z!q7+>mpfIwoSbrF*W|8O_uSY&xj&Gc^IH{Oe?{NEFeLs~Bi*06ji|ugz3VG; zRUhSQD($-V(EP<~g(AEj1hyW0QS=C?AW|11Nz{hpOHtt^tdhtb%OIUXd+KgcRci~3C9Sk zi43!;*)kNUi-Rhp3vz;+7+r*U2q}0F2MKG#Z))`*g<=16CNJxIAb4>Ml zV$y@8_2Pz0futVM*z+JE<$B+E-%>)ZKOy&>=9wLDx6T#a+asPl^XY!Ev{g*V4J4c$ zQ#~*xUw?7@#U)dY-;^_5HJc=wass9uv|I}2xCd)Cyj6O$bh_#F@@usdJI3p2y%hY$ zfF(6vE~?Y7?Fv26(usN>T0w>DD)UqyuH^@al@Sqwt(hag}oe zQUc0{b{tw!g2l(h+-<_MvKjNRSz%K*c-LXXv8x{_I{RYL`7Po7Tl!@SDg>$xK%!D` z1~t7ze+4z2LO-U5GN{6c1-V`~6&)ImV4SoQxAk`jMR?tUKlfGy09yL^>-urMXepj8 z`H!V@8w2`%qZOZLW{njSsG2B#rA$=kPOA|-MtsEzz(SQ^-Es^MH?UQPn$2~GJBZRq zS7giUrZlUr$X8w^EiZI~U_-2@T+x^Rf(tFv3etw5aS!x>eZ&cNe0Nh>(yYUVf`#D` zcHzw#`jn3V^Uj#~a^1vs3G7a_2MimR3)x)n>xs~3wuz>AP3+< z(N_YBppMefynF$X&g0KbI|ByIl<@MYiH+Bfjvt-M4;VKt7z?H^j@B%1$h~iPaVmR; z`?2PhW~S@C#JL@Tf&=pvpVZu~5f7jHtor;|#aPEHb)wHVUY00h#w6Sd4+c5VavkQuINJ5CKP#_Z=z#5@mxl7C@b_ zaDqCQn|A@T52`?u+qj^r9o)vK)uVS~@w8oX4hywtQ#=0LG;)9u=H!X0SM^I~tKV!5 zm^X^Xjqj^wtA3(i+V+Bf+Y5`^D&MJ^QN45M9{0l==bCG zcy&YJR6quzAo&Mm9~xc>-n^Pufm5#;R@<~eT@{fYS3TYyVikcNzvv{k5<|j>UvVKs z2)Qp5U6A=>7pz_VV6GE&9%2tkY{R|Q3+{GoV;5u=PAILF_^sei5M2l6aerT7do-pW z1_A%fD(vHY%Bao`!)>41r|Hm~!<6tT+QQ2g&vJw5eHiHBSuJ-7p2Jv{Pm5n+?8-%H z!gcW}s}<(~p+9s&Si>S~V83=;|J+uB@xE>ByV*IXq{s6zQo52bhs0l>}S<6_IX#TOmo#m>NsU1E2i zI4~$49`x%Ej;XJiCOof=O!Mz_&*r{wn``-L>#}v*n0isaZaF;@pN-3w%tt^#4s&A_ zfH&9c$Lp7jIeuf#bcML7)Sp{6R~axKSTff7jkWWaKQq=n1XgmG`(oXOG4*HqENP*H z=L+gWyGX!{Ucs@q(@z)`;^n|hf z?(po0)TjF!sZ^v_z8T)SV%32UuO)f=WO&fiIC*{*Jxwb*Ktc@Ab4Np*`h6Z@(8=b5 zO(z^jWuzHFsAo_Geu%Jvo-~kAxG@ng4}yhnQwk?3Vhw^72$DgbzSxSq$?`;rdP&&5 zy8{8{iXmYl_r})At$xG$Y5rZ;pShNk({GedmM+N@&Ljnrw~EHC4-%7JYo9nV zWqYUg-TJrdmvVRcb9c?U1G#&}oN}@JxR~4=NNib3Jnc_BEuLu)B=QT1-C|d_sPATT zqTv=-3qXh&%r?okV0>Gf#CK_H3mV(nE<>aj`l4%Vy# zrND(hw;90?6rZcFJXUKp*2iX*<`O4GrYP-UNQECX>TD(gBT)QMdjlB&wNa0#d{9`%D|ijT{P#lj$DGtQ2!vUBb7IfNP1txY z_Pd>IYOcT+s1r-@_jm__KjMxkIEGW62U$y)BF~7*ab*bqGy1qc=5BDPltrx==Kj43 zj25ME7)cuh9wDLrh1(d&ljbsIZ?%rH(S90N-s$WEJ4M*-Qu?5$H z6~{=tvq;U>nQ)Gt2!SK+4OoUen{PH9unpeLz%-GVY!`?EiAgK=pk@d9w}6s^Bf36W8!Ni6L}NE@1(uE_U*Mx*(Lt$ zlG&<2_8!q%DwduT&F2Hg7st5ejLb3pMDn<4S#Q3cIGz|tKQ~?c?#{P&&SV90b}ZzS z%_$et&wZvp_fSU}f2~&-lPC5}ot}2jRL|zkr3VZZqPl`DmlXpXhS?&m6n!*G7`-k$ z|LRo{LF#B#y%~m-ypneJ5za#c!7h1DkATC1)%l7-aSkhTMD?BT5%v=v?1bU9dgn7v@zFKc+E;{i`V(MOVt=E?%Gs;R z#`IXXdaPSrtQ8r>+YDnVQSUVJ+P#EUx)Wl3;p6C=z+L;HPtB{nSluJ)Yd=tfK#FjR zfYXI}MYrQ+ioHYGER#XnA%)H0JxfBx(00VoMp>{{0KR=M@RKW$sS_XPipxQQOeRw4 za=L`D@YWWRSB2P#)(Zd@Zm@7J6bjohe!+N&1H`xW_t}(K?u70Hl`bBS4C$fe&j^3- zrlJFct5-ulw66!8_clqSu&Kw@>+QC4k5Oz1c!t!eDMNdX9C(bCW(y!g{~o% zn^>tKD3oTO&YrkDmHbCn{AuNh6yYgH3F-$;9F?aj4InOppsTV@UhGIxj>J7=3j%kF?_ z&)izSsdlvXLA>deywR%V)cQvXy(VouZcIJazMN1Dk6WHXpf{bni;QcH&1(V#>*}8U)~h zTt7U1IDpO_Tuies8tp(%CU(B!eWPkx^ZMb1#7(zzW}NSBo^>y5E?YLI0*#%h7_S?v zerQr8CO_1mN)MA1#-!JFP363^XA$U)VXLU#`s9&8k$Q@g_|JLks&cd+sniHpOfcF# zX_7s@0>~H*7h_?r5ojm0M=*=-Fw{gop(_#-oL|mE@RwaY_XDi&SonnuAD@La;65AV z^xa1JDRM`CG5OCj1d{KuWG7LOCd?XOyy{4aVfYhOVi?ZH%5ydE`C5s@YhL|6ETvI4 ztQZpNPKJ#DQY*r^NQSiHV}ki`)dAR3!`Mky1UvC+m>xp|7z=vr{%+DVxus;#LD-K{ zqS0*$GOQ=LPMai#jR6;kvEp8XAcUM8oN~+-e^7R(Y_9XOl3Kz{4oaBG_tLD~O`Vkk zpIZmji4GT-RPio%oBXV6bq&bTi4-ST=*AbHDgHAwIkrTSW{ zh))>fU&#WmTeEiJ@`J==k}OSGUh#p;tyv4HkY?>~b%@CddHP!|ezUkw-56QLdll-+ z9Mwna)XE~&M>(3xt-6njG?jaFEAV{+anf2v7`;{_s|~n6R8T1bFYG3h`hAC?FKA5nte!A)?&o(Lp}NI6IJ zKK(QGz#bM|kCj7FqgJ!I{_%KG(p+q}2qaAU`Q!lYe(dPJ2o2jgLUwo*|@iFs? zlok8?vt=2Or{hTqy7mv&=@bJzCz zj6YId+mC+Hj!?N;%m>ComimU(>j3K;L(Ce5dMsS8*gX@eSM2XmJr}N5Jg=vDiP}{# zCU?9y1@+K^6Bg&w$Epitkx}!FkAG9ea73LaOq~+tDYee0lmqeYJBzd}CR#p=(Q4t(3FuIHoP>Iz>T(%&0F{lA|wXL1e zozio!M7$B%EMrFOE|G8sM#4!g5D1>Xfj{?O0SzF*Q}RT0K%Y5Uv7DPX-Tn5_`-$79 zE>Cb1xs$pn%VfNmxP9~x0?>66`d5yE^`DSB;ke>4%OY;{Hf`_`Q&`@gk@sKWcpO`TRU&=oX!la*)r4g;}f?|%$5f1`^2pM ze#?H*d~!^K;D0k!&X#xgzr8<@Q?g|4yO}nXj16k=gW)^F^T|JX>0b7)@;=QIkDUn| zZWYg+UpnXTp96F8Qs7*VSl1ia-TR|t(cCwt@tgaW^`_bE*~542_bo5YG|lh)r1Wm- zy{3DfPfrD^PKd2%ms;EWt!?53SD>|9?CD+Vx$N(`EDGNV^!UUxBY~=siEJ??Kafpsxryv_KpM3IbbKjGon<`J3N8e32 z#j5MJMy4{6OBna-2CQ0rb*Ha>gL=_s&aOzQQTY+ZwCXDvh1}M^NBcT1!j^&0f~H@6 zy%&1%GT^Mxz_>lRiWJwJ;*QvBSB!mOcUL)f10pQfM4hW zHo(#*MADwGq%-zx|Wj4J3rVvP}ssyX3}_S>%ZjPP#scsCt(-y z9H1F5OHD{r<+WG~pot1C*OC(J(MlV1XQAt%Cal-18T546_s|x>bj~3%2PzJE4YC4P zj?_KeE9n5*<`^At4KP`z{i7BV7U}AX)Em4Y>Be>u7KR}--N1-?4Z{qZr*nuk zqJ*4U4fYabC*@*UNxfWOT>1orh&q54SwtJTg!ffxdkT9 zhVXAazN;fST-%B-Iu_G_DWGRhT0TxCWBmfU-57ZF*rdz6W)r<_lAyAlEF6(F;|(`wCPqK&wGBPwDCp z>YJgq*VkFs2*I2N7AByqR2O?LA>yDA)bOxX=?ZFD8GKNM0)k4Yr>J1m5iOmQSVYk6 z>mPD^*E2i;t4W^d5ps~e|0h(#{eL5nI7APUQ;AFx*H_Hf_}ACnD_J~teCb$=|5!^P z`NShdYTWvo}RV5KIf*2 zxXGSB!ShDx+5IP(%h{Wzw96@Lmy=Vcl7CdXye@m9Hk57>Jeu^Wk{_L!-Z@k8XJv>v zd3d7cX35I8Yu8QGJjh!AhWjn=P4DfEKi+a{%WQjKbJ={mc(QeI!`VRAxj@RfU}{!4 z*RS$X!LKx^{S(Gc`F`cDDmC{}Svc@2(iy5RRiXd?!<|@>bB!G!MKjWo*t%&1a@U7 zvJ7%ppC$Qoow*|?ayGmx@tKIJog z?E~P8=Ay-I6=G4PSlKKlv@D)|ap|nnf7bb#zT<&D;d;V&!jgWYU%&C41B*Lq7Lg>* zw3x7ACV$Sh7*|b9hV*;fazf(uj`5CHy2ez*kVyYpo2FU+)i%ZE!Wq|l8}28UPFGA+ zOgJX1r?RIiZ{|%N5tBk%v)6Gm&k(kc>?CJFGFSXz%G~97%ZHwM_lMsR zvyP0_Oys_NgoHWu+(QU+ETi?mJ;dc7P?;_Fcur+jiP1 zoqfRJw$o%|#(#F$e%MJ1Ib_XPAepZ1++|y_!l}A6zaMYKRA0%ZiG@MSTh9mwEKCW_ zfEe5LH**}$pO_k&d1Rxf%1v>=@7y=yqjLEx_o+h9wLtE%{V^cjWCagR2E~`UrsX; z=Ziy&Cw*~zB4z%ZJ`K;R5Krh95 z&w`TAfC^qFzGuCe2+p0(lO;F%ibB}*+#ZB!J2VX9k#1cig^zL3SYGOty)(gGOL%Tx z>uEGtUK`$2K9dgoOQhyO<7lD3U~P~#7z4@Z$&|GXTh0|!`DuI@2)Vr`esCZHlmrss zJXI4d;?+rJ$oY&d?$zZpgJi2WHEee!nBX?*_6ge7>1>;Vn&2!*u!tABqX; zWz#z0C-hSXOJz4?at!hDs`9M`LMh&Rb+%B+R;gq{Plq9Ph3Vf)Q|JP3hK-DbsXk$Q zXjsR(t@#qNFpcblzo0zhtOb&Rw2h%RPq{*G66+M0k+{3;5M%N^;JXoe<&C!<=W0;N6PIqN52#8FrDKddlD`D? zvi)9=8w_e$FS&y%_yY)PhREX4O|^((;z=w#GaDO80f~?jxA0#O?eFpDc7i$tA1^L* zDrYIH(4SQph$|YcWSjYNQYtA(EhXjplX3$|n`ld~;>NXOsxf^egXH0Kh{*9XT3C(eJCm;(uqGIZ@4C1@0?ytEPSj;)0~A~2uVJfLf%@xaqUzy;}U+J zXkIp^tggsoMN%A6R4kh0;Ay z@mRCi+$Fktmt2D1C5Z0ffXgd(eFx}>j{}OL;OzC>S1M)pQSNJn($c^^)G9#mY?|CO zWqH*$RuzQY);(1`d1c!2ZtB~q(<6cGoq_b7%WF1FtEL904+10VxK%f^ceZ^l`)7F{ z=FRK>vPdjFd@o---Yhn?ApaBGy5)7&Y0J%%Q-{Rd^0{VUODE?J%~yygpoZ4EaN_LU zOQNG)tl>r8301|cPHsZ;MRLkw##VpwR!~KRKF;)r)u+Xd{^e~Y^E>DB?`18vpi$la zmP=xD54e=#3w>XyxV!=GpA}r{Woks~Iw{?Jz4)S26b6?|_RQvsrKiMgrzvJDi+PrN zltZKNFpfs=VWGm9GTO+LZM?-$D|G?Sl9lqTmqLb@P`ZFxGnwTxJ=Kt@1CVoXYd9|O zbcX<&Go%L(#Q`f|;AK~!&fe#VHZqnSB|Kx153AG_w*NMp()BSGjO(uvc;gABvlkcJ zx9xOo1q$9C%{L($gduBX2*L1##x`vo3=u=z)fc`qq(|P2Bo-U9A*f)82>2tEKExf_ z9VK9kEm8|_vT}|Qwjtwvq&-KF5f-qx!y*u<^HfRfJRnq>2$$r&P$Pt!K6QxSqlLN= zK}vQ-O2p=-_Wm?&MiE=V62QB#V=8jkp>i9$r{q@IfzKWw?-leOL(x{x!=}PP&C>FLt^0Z}SWfIG^f5{xgCJh$b^)h#hbpy%bE0dP!w6 zl~JJ;pP10jW@HMw%o2<71%hgVH_SM1l`f^U?Z?$zB@ZxA6_c=nBghH7g!_~sX}!ZT zw$mfb(UZR2rJ@RdQN?_Ipy;qzctkwn5Z7G{WVSD5 zcKS0r#V#R`>0Zbj7Kes`Rao{;R4k`w+!&l3oN5cC+h>yl>BJ!@`*7X8?0bhlwFj{I zTyQL1aQZJev6X#xVPH99-CM?+#_1+8e~&+}^s|gI;2D;^4>hRJ!vw`<`;Rx=+AwDk z_tyJMj|OZF=m~?9o2~%sV1R8*Hfd9;Q)>~~)$DN@$K z+Q5_=G^b#9Hl_ZrM{3H)U5pWYqe5G&Qs3QGv9?yP{CTp0!VMM*i_;K(yrHx`gsw-j_@SN{qs{*Ezt`gQkoUkUs$oGZ6N9-3EWfO&GL9P%nj%zqggb!u3G8d) zn{l_zvY3Da`0E2b7m*;c&4==2(k)L|o(TGK_j78I6tfn|avmr-MQ#tCQ=e12v^NdW z_3Hae4>2y;<%1MZ=8>GkxIq&{2cALfK1t^@V)RLLZIYi;gUqN}CP%gxY?*0tO(OSL z^G8lEZ$9!Jtz@-hjIKn*-SC{a;SDkr*9yI-l<0m3XCy4s zjkGqh{nCuN_6g{FGB?$~hTFB^XUaTpIvj7eikC>l3(XUq9U?qM`?gC|FR8%fFMUIJPTXc8Z5 zq$BVKEBTlwPfkB(faE$48s>`bP4c_QJyuTRhKM~>L<%j>9OVAp*dqe2*d{)H+`#9v zRUJKF!>$uCXA0O?+nYK-m^iFqCBGRQq%EY|!*7k|h+X>$##9l@6{C=c_av-ab;X(| z9=jX~YnGEoP;L$J-NNXY1`=+214CgLz9W2rq{2TUc$%=%rJ6vhNj8=6D~b@}(?~{H z!f)u+NeT%2_sV90RY7dl|0i&64m&I2P*Zcf)yNJwo=X%*yokg@21oy}FPaH`Kw?&pU?8 ztWUKCedN$gsy6i!W3;)fVCt;cb=CM(g1^Uqia-{^`xNJ^v4+7DkW7Pkj@Iy1PT0kG zrNGfvUl9}fh;>IO&00z$kus8r5%NiTZa(2X)2Ijut83MYVGT1v(z0F`{sDzc(}RsS zVZ9Ad_N5(BIEw&-MKVB!lRj&pB&q-AOPnP(v)W`rJtFuG%LDhLt<3UHlKukqNp9qp zw-p1CN=cRQPk*uu>H_4tNhy=*-NqIgm|*k5B5Y)zrUrQy<1+h zHmnsUrf+$yLSpF8BI74+;&y(&BEG`Jc@z8k;SVO_mrwX5zIjuu}^v(>=eQnH} zU&!av)< z!@ptYT=UP)e0avcp_cf1@R_r7awmN7B->_+f4uY7PO-Qmu%&XW8BRyc$v0K+#LeXV zdBXcS3)Y=;$seo!B8w6wB#P!7xE=w=Fm2Op$$asA%iZ06;}KRK@O8go!*t1lVbikF zbp6oyp{Y$XrOO-gXSkW%Te|7tS?#QQ);U-D@ripSKW_z#>sXU`{DjzaQat(HhYH2| z3MHniDHZgBsZ#j`tU{_^K08%0)qV5m?HB!P_sW@Wo*8~N)8e_NIq!$(=NjP+B|Mj# zU7#Aexg8I7l+NY+np($MLGYZP@1a9b}eV~OwP>ZKQHndH;f&cXrI~< zDQW1NN-F;439LdRt=Zzn62EnaxB+G>?+?wnf9Ctpw{)PpZ3#Xh&_84C1W6?=zKO)povTw4CKj1M#BbN_PvLTSUj5^+!Cy&>^b z>(Z$<|0(D;b_Gtk7ETR_y#wOO%i`tXWoyp6`nUBn$$xHIu$Indh#Sfv!d+j^TKBa= zZQA!RUJ+*@1HLKlzCP=r9a97BLR;~D%c1Gw*^1eYJ9YDEfgJ}YvM1_Z-6~oRjUGm@ zl1EIYHs5(Zk9bP;mf@!14b$!9ndbL0W@{E~rE^U`JMrO(`O?LGM=@z&L-SYyM?3d? zaQ4pG58Cdu%{SlM7}$CAYZXD0e*%%)$bDfDz#z2^6IXbh7kMg8-{Bsb zsOqrE(Hnkv^?O&B^@b&VreB}Aq__I@*2V1N*__#{f$SF+^%cv8bdcB((wtV7t2^%lN#c*3$%JoaJ*lCAGZl&>gRkqeByFncqmOq6n2bwO#2BYl4I z{j|#K(>Lc8OTPN7*QYQg7K@=6p}0oWtNlEER~?Jp$cJ%qNEs7XH3dKUup~2tv&SJ{ zO_rhqvUMLv6R+Ur|L@`uI>@TgaP} zK$@gq5;s;L3WNNy!2?w=4qGxD3%5BuOb!NP>mPxTZ>H`d{0L8OLJrxM5Rx*5ThJCtq{9q6MEk&hMoer2rbO7t znbK*+TE@7CN?n}gK~lzz!pXv=qym3Zfw;vbc3%=*J)*65d3%{yenu>A1++-E(F#4s zjaB`|l73_T zR_e{vsiD``OV)L>hQQ|Xh0T@o%K57KVgX66{e^10DWJ|8Sk-GsLPn+jcJq4I7@TIEK~-OW4j{Lw~D<(9aQ^6C9Yg>jW- znvY63gewFIq-4)l=L>ppXV$=(JUNnA^eSFn!zsqrfX;yhJj4n_2SO`1Ob3#^j+)Vd znlK%xl6V^-I*{!7pPz~;Q-b|%L_Ulo5=x~RQFSUJ;s=>}`+eTK1)5OH=dBtnkQbQg$UlBI|UCWz7mrB_WF%W@oA$#fyw9Vi%#7YrWyTXV?GpmO|s;gWNUp#4V#1b~hBO%9(=$Ny1 zDSd{JAjgPW7%^Mqdc7dki)T9Ayh)xdQJk09bq?!vhf56eXF_=tKBu*batgOpE9mQt zm{2Q|TM4cPY>U2`7Xun5y8hTLs9$}iD;(ylp0(3}924+1L8yftkeSv&OgcHt2cg3Y zdlITn9CV0N;lD}h)n6F!|C$+1YAkrMt1~W6I zYRgEH&%$SgMmo`F#_S^QQu2sp#EfrRx5}67=7zyp>*PjKJR9XgzPZHnhvZU5Qc=5D zn1ESs*+`l%CDu&g*ZIuEr}L#E-0-HxmnNk8(j);a?pB%&f}YQop9x377_T924DK80 zE|&q3-C^wNT4D4#W#vTR2TIH3^I{#f;emdabosO%Q1sGud(&#p3~Cal!!grRe7bM$ zt?TL6Gui= zDg0)B3%`}Y@XUWjz0lcmy`p*{Tqd6kS9F=3T!#|thFsvKqvSQ1M_D7(l4L-LEaCif z`rghL#%h}{nPzSX>SEU%G4n-iMr?aih1}zrm7v*|g;?pKSioLK(tVjerr(7)lDoY0 z=$Syzd~x&}8ljl;Z@b)+aJ5$wvxX^hj4&S#mL?IgXXwcge)sH?dL)TfTG*Zt;}qjr z8IGZ(@3tM@?)v`zOT>3$EEqQwD;`M=PPd(^XsJ5X){JuuYM5((x2qF}fiSPt zKnEFXgAVq9LIsId>Bt44+UFjsSB?o z7rf=%LL4EPM1;uRbM)msj^2yBW50LfYJNx*cPbR;45g;61FVXPJxj?pKX?w=KepYn z%~s821U6R&lB-5To|qW>lCee^Gw?B0K^0WfArveu2{KKxHc8E@51-+`5BW#2-2M?S zCB9oCKCI|OH9nQ#?=dlr!dm~R8ngC7?9~?^hHUWe6{fEYIyD9wmnR2=MaT`zS=| zaWSW0SnD*zF9p0DRxy65S|Iv`(NRIYtVj;S35ihpK7IQEg8#kF>!Z%wiR7&Fz}PH5 z$1PV>g6i3SfUzm|1@<*9>^mi%IJH#n@R!5>v}dWj7mQ9s40UDUHkj8V3va5_pG@v6SX1r!7sk?DxNcauhrZDo5waJ@6pa5MC9y*-R5*IZ3e3hq4Yk^l~1EZW4u2)S8 zn%-PS;aLa!E`e{@XDtXFY5=`WIuihH6QAVP^iawI!>73 z36GFD8X)1A9ZcMn_70PvAMafvc87-ga)jI5~*^p)oTb-!R{Prn4)H(u3n(VykWIA zOS&4Q!bU)se5Jz(u^+r>56;M;qY4K5aQsv+DaX+9CRbd}Au1XAoro_TFd{@BSw*Tc zD-X+Iy~+;Ipc0s0bZaN{K5^CoPOD&chbV>)4w2?dA50(Mjq@oNZ%bvcRI)Q?oNLC0 zHlXgo#8n%LaGc(%92ecBXoKTyz%qs!4b)ars_GjXYT8cKHB>jAYHO~+5e3!F5)Yi1 z-V#GQKu^D;fY=1WOZ23nC&u$0rk}Kx*w#ka*FmMn&3M+NVcgTvhI47)jsB{KJ--xmnBXt~;4?-GSmm zW0xl~U-5|vb)yFnEL)P_Xn);#yZOBnALsl$&!2i|ta7wAR(|Uvw@|^kQI3 z+v3G53tO%{B5fkDT99~5E#1FZYejCB;)%=C735T4_Dmpm|Hm+=|J8|4!O?CH9OVO5 z&P7*mpsIH{#NhevVrCJtN=il+mf|rDyZwD)%IA;@!1l>BZklLc)Mvp6@V;fo6gS;8 z74k#cc!rHCvahcJP8tMbL1P zF;cque!JUZUbU~OZv5b{o0?ZBq+UHd+-zIUFW@nKkr+p zZ52_? zQ{CjziHb?@lxFJk?BKjUu=~)<4!j+$T`<*sZcLi&fS6_3v`#dxhZa`S+NGq8{-lk8 zq+HV5c9PyUH&(I?&+Dz@tyA19=f{+AF>u{DZk!mIZkn!|79eCuooN?SxBH!)v%*~V zok8(9_}M2Hj-Q&(ofqz9-yNLqyqh4NZ(G=XVY&Pw_}QSsz#Wfsat}3B##efUJ|UDV zByzVK#Vv4=c?=rMOHF6|O=lKcFNluL#ooci)+>RgA@SH%F?D!R??saNq)#&LX8b&B z$Ljz-*Vk_-PX=relISte!IA?WFcusz|!Q`H^EIpN`-jj z>T=cz)W&#<8?F9g&APYt+}!he*^=?a$sH5L6OP+yv)p@`bEetvigjnjL+8X@=ND|} zUv3hOFOJsujW2$#Gfk$-wNBkK!~fWI%O$?Z-{0eeCiUFj`OEX|_p%mBTNd^>myaB~ zU)nV9n9H5J{FnI)rA;Wd^f(9hWKlDR(N1gTmQRo>blMpj-?&!p3#pSaAJZDyt79Ba zeYjpaUOI6iU|1un*F1T2f>W3e!aGAk-IGTNv=W{`8_ZHnUK^5jKKa_LNI1+rd7Pq1 zI*yxGVS<Nh|3Zi0=W8ppjXSg-7nmALwIA=w zM)=nmsR*Gr*0^8$H%V%Q_sY%3RQgX%YJ|Tu#2<^-|1xbO!oN+=JZ9GZZLu2R-=&+5 zr6v5`X5+CnW^WVV`|5M-*k;ZQC!}M2Rv2jz=^)v1(CbiYoFx`EiSr(irN*?6Vk=vD zT$>z|&V7s8SR-e?L5ExT1p(Io55WpANVI1W#`1UqcrmGLgFXaDl~LE=!ZJSxOo^M9 zRffSE1+AC}YBh?N8Y8~!;5`x_TvQ%*VnPG!uT!XnBLU!`c5@7+}{RM`ULz^GQI%3Gm!ds zxTnAaMB5U`84V)c^T^~L zewGi%Ml9S*d}ho(*2?L>kqh_K*{v6tMv+&20mpAjYBM;@ zAf)X>6sklNiFySK=W|1wyf%ai=(v zph!A5n5CqD!*o~itnw)_S2ZCTFB3GLn$dU~$tr)lTtP;UNbWIjh4f&^C`7Om?nYn* zs2&r0NqR7Aa8w*S0WONWAd|;$QW86oNaXRFmOCx8O>^ATWmxZcraFM*IA*xF`RS80 z#WPJaRf}u4`Pa0BD7$qVI3W}1p(#!c6|Pdi@bY8y#871dbdq`q;n1>9`R8~BipvIE z((`ze-st6{A%iNoA0ZqV=g7EY=#)e0OKOo⁢C@4L1;w-`?!|ru`EYKpMPnpP%gv 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 KDb&(Y)c-$dv2WS{ diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_check_update.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_check_update.cpython-312.pyc deleted file mode 100644 index 21f81206690f84a672952f0df96eff5711bf32ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9311 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 Qe{lW>7oJJP#&?_l7pEHv3jhEB diff --git a/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_ocl_release_check.cpython-312.pyc b/plugins/ocl/skills/ocl-search/scripts/__pycache__/test_ocl_release_check.cpython-312.pyc deleted file mode 100644 index 49679071b7ee85a88c03572d1d3beb280d27a5ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13263 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