diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd7bf3..5ee4734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ ## Unreleased +## v5.10.1 (2026-08-07) + +M0–M2 of the summarize/integrity plan. All additive; `stats.json` keeps its existing shape and every current reader is unaffected. + +### Bug Fixes + +- **summarize**: `stats.json` could not distinguish *measured, empty* from *nothing measured* — it returned an identical `{"values": {}}` whether a dataset genuinely had no rows or no parquet file was found. It now records `parquet_files_found` and `materialised`. This is the same class of mistake that told visitors 586 collections were mirrored when 4 held data. +- **summarize**: `--force` no longer rewrites README/stats objects whose content is unchanged. Measured before the fix: byte-identical content, new mtime, on every object in the mirror. A rewritten object invalidates a long-lived fsspec dircache ETag, and until v5.9.0 a stale ETag was enough to turn a whole listing into an empty success — so the tool describing the mirror could break reading it. `stats.json` is compared ignoring `computed_at` only, so the churn fix does not defeat itself, and an unreadable existing object is rewritten rather than assumed unchanged. + +### Features + +- **summarize**: `stats.json` gains `declared` (from the upstream sidecar) alongside `observed` (from the store) plus `materialised`. `fileCount`/`objectCount`/`size` describe the *upstream* dataset and survive a transfer that failed after the sidecar was written, so a dataset holding nothing looked identical to one that completed. Costs one listing — what `summarize` already does. +- **summarize**: `stats.json` gains provenance — `computed_at` and `owilix_version` — present even when nothing was found, which is exactly when a consumer needs to know the run happened. + +### Notes + +- `observed` reports `0`, not `null`, when the listing succeeded and found nothing: zero is a measurement. `declared` reports `null` when the sidecar carries no such field. +- Verified on a three-dataset fixture including the 586-vs-4 shape: the dataset declaring 567 files over an empty directory now records `observed: {files: 0}` and `materialised: false`, and a second `--force` pass performs zero writes. + + ## v5.10.0 (2026-08-07) Workflow monitoring: `owi workflow` and four HTTP endpoints. Minor rather than patch — this adds commands and endpoints rather than fixing behaviour. diff --git a/Readme.md b/Readme.md index 12b32cf..4f0b091 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,6 @@ # OWILIX - Open Web Index CLI -[![Version](https://img.shields.io/badge/version-5.10.0-blue.svg)](https://openwebsearcheu-public.pages.it4i.eu/owi-cli/) +[![Version](https://img.shields.io/badge/version-5.10.1-blue.svg)](https://openwebsearcheu-public.pages.it4i.eu/owi-cli/) [![Python](https://img.shields.io/badge/python-3.11+-green.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-Apache_2.0-orange.svg)](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/docs/changes.md b/docs/changes.md index 2478065..97e74ce 100644 --- a/docs/changes.md +++ b/docs/changes.md @@ -24,6 +24,25 @@ Brief description of what was accomplished. ## Unreleased +### main @ v5.10.1 - 2026-08-07 + +#### Observed vs declared, provenance, and an end to write churn + +**Summary**: M0–M2 of the summarize/integrity plan, in the order the plan set. All additive. + +**Changes**: +- **M0 provenance**: `stats.json` records `computed_at` and `owilix_version`. It had none — the fields the plan credited it with are `remote doctor` output from a different command. Provenance was pulled ahead of the index because index rows that cannot say when they were computed reintroduce the ambiguity below at a larger scale. +- **M0 honest empties**: `parquet_files_found` plus `materialised` separate *measured, empty* from *nothing measured*. `_generate_stats_json` returned the same literal for both. +- **M1 observed vs declared**: `declared` from the sidecar, `observed` from the store. `observed` reports `0` rather than `null` when a listing succeeded and found nothing, because zero is a measurement; `declared` reports `null` for fields the sidecar lacks. +- **M2 content-addressed writes**: `_write_if_changed` skips a write when bytes would not change. `stats.json` is compared ignoring `computed_at` alone — otherwise the timestamp would defeat the fix — and an unreadable existing object is written rather than assumed unchanged. + +**Verified** on the same three-dataset fixture used to schedule the plan: the dataset declaring 567 files and 250k objects over an empty directory now records `observed: {files: 0, bytes: 0}`, `materialised: false`; a second `--force` pass writes nothing where it previously rewrote every object. + +**Breaking Changes**: None. `statistics[]` keeps its shape and every new key is additive. + +**Still open from the plan**: M3 (README front matter), M4 (`_index.parquet` + `remote reindex`, where the risk concentrates), M5 (HTTP exposure), M6 (verify tiers, checksums, cost controls). + + ### main @ v5.10.0 - 2026-08-07 #### Workflow monitoring, and the reason it had never worked diff --git a/docs/review/owilix_improvement_brief_2026-08-07.md b/docs/review/owilix_improvement_brief_2026-08-07.md index e9a8bdb..1273c7b 100644 --- a/docs/review/owilix_improvement_brief_2026-08-07.md +++ b/docs/review/owilix_improvement_brief_2026-08-07.md @@ -136,34 +136,89 @@ concurrent writers. ## Part 3 — The HTTP server -We drive it from a Kubernetes CronJob and a FastAPI service, so these are the things that -have actually bitten us: - -1. **The job store is in-memory and unbounded.** `JobManager._jobs` is a plain dict that is - never evicted, and each job keeps its full console log as a string. A restart loses every - job — including ones still running, since a delegated `POST /remote/pull` **outlives the - caller that submitted it** and its handle becomes unresolvable. We have had a multi-hour - transfer become unobservable this way. Wanted: persistence (SQLite on the local volume is - exactly right here), a TTL, and a bounded log. -2. **`max_workers=2`, and nothing says so.** A third concurrent submission queues invisibly; - from outside it looks like a hung job. Report queue position, or at least - `status: "queued"` distinct from `"running"`. -3. **No caller authentication at all.** `require_auth` only checks that the *server* holds - its own LEXIS token, so anything that can reach the port can drive it under one shared - identity. We keep it cluster-internal for exactly this reason, which also means we cannot - let a browser near it. Even a static bearer token would let us relax that. -4. **Errors arrive as HTTP 200 with an empty payload** (part 1 is the sharp case). A `partial` - flag and a non-2xx for upstream failures would let monitoring do its job. -5. **`/remote/ls` has no cache validators.** An `ETag`/`Last-Modified` on a listing would let - us poll cheaply instead of re-listing. -6. **Long operations have no progress over HTTP.** `cb_progress` exists internally; exposing - it on the job record (even as "n of m datasets") would remove most of our need to guess - from object counts. - -**Do not break:** the current submit/poll shape is fine and our callers depend on it. All of -the above is additive. +**Updated 2026-08-07 after the listing incident, and after reading your F2 list.** Durable +jobs, an auth model, listing performance, an HTTP-side cache and integration tests are all +already on F2 — we agree with all five and will not restate them. What follows is the part +our outage taught us that F2 does not yet cover, plus one warning about an F2 item itself. ---- +### 3.1 `/health` is a literal and cannot fail + +```python +@app.get("/health") +def health() -> dict[str, Any]: + return {"ok": True, "service": "owilix-http", ...} +``` + +It returned `{"ok": true}` continuously through four days in which **every mirror listing +answered zero**. Kubernetes kept the pod in the Service throughout, because from the +outside nothing was wrong. + +This is the same defect as `status()` reporting `connected` — which you have now fixed by +reading a sidecar. The same fix applies here, and it buys more than diagnostics: + +- **Split liveness from readiness.** The current literal is a perfectly good *liveness* + answer: the process is up. *Readiness* should mean "the configured repositories are + readable", which after your ask-4 change is one cheap sidecar read. +- **Then the orchestrator self-heals this class of bug.** A failing readiness probe takes + the pod out of the Service; a failing liveness probe restarts it. Restarting is exactly + our current manual workaround, so a real probe would have turned a four-day silent outage + into an automatic recovery nobody had to notice. + +### 3.2 A warning about F2's "HTTP-side listing cache" + +F2 proposes returning cached `remote/ls` immediately and refreshing in the background. **We +built precisely that in front of OWILIX** (`owi_repository_cache`: TTL, retry interval, +lease, stale-while-revalidate) and would offer the scar tissue before you build it: + +- **Three states, not two** — *no data yet*, *stale*, and *refresh failed* are different, + and a UI that cannot tell them apart shows an empty page for the third. +- **A failed refresh must never evict or return empty.** Serving stale data with + `stale: true` and `last_error` is honest; serving nothing because the refresh failed is + the same bug as the one being fixed, one layer up. +- **You need a lease**, or every caller refreshes at once when the entry expires. Ours had a + real bug here: a *released* lease read as held-by-anyone, so a stalled refresher could + overwrite a fresher entry. +- Adding this cache **on top of the fsspec cache in 3.3** means two layers of silent + staleness. Do 3.3 first. + +### 3.3 The long-lived process has no filesystem lifecycle policy — this is the root cause + +Not on F2, and it is the thing that actually bit us. `fsspec.filesystem()` memoises +instances, and `dircache` **never expires by default**. The CLI never notices because it is +short-lived; the server accumulates. + +Your ask-3 fix (explicit `size=`) removes the 412 specifically. It does **not** remove the +general problem: after another process writes to the mirror — which is exactly what a +`remote pull` CronJob does — the server's cached listing can be stale indefinitely, and may +simply not see new datasets. That is a wrong answer with no error at all, which is worse +than the 412 we could at least find in a log. + +You already call `invalidate_cache()` in the iRODS filesystems (`irodsfsspec.py`, +`http2irods.py`) after writes. Nothing equivalent exists for the s3a path. Options, cheapest +first: pass `listings_expiry_time` in `get_fsspec_from_config`; `invalidate_cache()` at job +boundaries; or `skip_instance_cache=True` for a per-job filesystem. + +### 3.4 Pagination costs a full backend listing per page + +`_page()` slices `records[offset:end]` **after** `list()` has already fetched everything, so +`GET /remote/ls?offset=600&limit=200` performs a complete listing and throws away 600 rows. + +F2 names this; the multiplier may be worth knowing. Our catalogue walk of 786 datasets at +`limit=200` issues four requests, and each one lists all 1864 LEXIS datasets — four full +listings to read one. It is also the strongest argument for the Parquet index in part 2: +with an index, paging is a range read. + +### 3.5 Job durability: the delegation case, not just the restart case + +F2 has durable jobs. One detail worth building for specifically: our mirror worker holds no +LEXIS token, so it **delegates** to `POST /remote/pull` — and the job **outlives the caller**. +With an in-memory store, a server restart during a multi-week transfer loses the handle to +something still running, and nothing prevents starting a second one over the same data. So +alongside persistence: an **idempotency key on submit**, and a way to enumerate what is +running after a restart. + +**Do not break:** the submit/poll shape is fine and our callers depend on it. Everything +here is additive. ## Part 4 — Two things we would integrate, if you want them diff --git a/owilix/_version.py b/owilix/_version.py index 97d770d..72937b3 100644 --- a/owilix/_version.py +++ b/owilix/_version.py @@ -1,3 +1,3 @@ # Version is set here and imported elsewhere -__version__ = "5.10.0" -__version_tuple__ = (5, 10, 0) +__version__ = "5.10.1" +__version_tuple__ = (5, 10, 1) diff --git a/owilix/core/tasks/remote.py b/owilix/core/tasks/remote.py index d5ca5c7..c4dcbb0 100644 --- a/owilix/core/tasks/remote.py +++ b/owilix/core/tasks/remote.py @@ -2418,6 +2418,93 @@ def _generate_hosts_counter( return counter +def _write_if_changed(dataset, name: str, content: str, console: Optional[Console] = None) -> bool: + """Write `name` only when its bytes would differ. Returns True if written. + + `--force` used to rewrite every README and stats object unconditionally, + with byte-identical content. That is not merely wasted I/O: a rewritten + object invalidates a long-lived fsspec dircache ETag, and until v5.9.0 a + stale ETag was enough to turn a whole listing into an empty success. The + tool describing the mirror should not be the thing that breaks reading it. + + Comparison ignores nothing: if the existing object cannot be read, the write + goes ahead, because "unknown" must not be treated as "unchanged". + """ + if _stats_computed_at_only_diff(dataset, name, content): + if console: + console.print(f" {name} unchanged, not rewritten") + return False + if console: + console.print(f" Writing {name}...") + dataset.repository.writelines(dataset, name, content) + return True + + +def _stats_computed_at_only_diff(dataset, name: str, content: str) -> bool: + """Whether the existing object is equivalent to `content`. + + For `stats.json`, `computed_at` changes on every run by design, so a plain + byte comparison would never match and the churn fix would do nothing. The + comparison therefore ignores that one field -- and only that one. + """ + try: + existing_lines = dataset.repository.readlines(dataset, name) + except Exception: + return False + if not existing_lines: + return False + existing = "".join(existing_lines) + + if existing == content: + return True + if not name.endswith(".json"): + return False + try: + old = json.loads(existing) + new = json.loads(content) + except (ValueError, TypeError): + return False + if not isinstance(old, dict) or not isinstance(new, dict): + return False + return {k: v for k, v in old.items() if k != "computed_at"} == \ + {k: v for k, v in new.items() if k != "computed_at"} + + +def _declared_from_metadata(dataset) -> Dict[str, Any]: + """What the upstream sidecar claims this dataset contains. + + A claim, not a measurement: `fileCount`/`objectCount`/`size` describe the + *upstream* dataset and survive a transfer that failed after the sidecar was + written. Recorded so it can be compared with `observed`, never as a + substitute for it. + """ + metadata = getattr(dataset, "metadata", None) + get = metadata.get if metadata is not None else (lambda k, d=None: d) + + def number(key): + value = get(key, None) + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + return {"files": number("fileCount"), "objects": number("objectCount"), "bytes": number("size")} + + +def _observed_from_files(file_details) -> Dict[str, Any]: + """What is actually in the store, from the listing that just ran. + + Costs nothing beyond the listing `_generate_stats_json` already does. `None` + rather than `0` would be wrong here -- the listing succeeded, so zero is a + measurement. + """ + details = file_details or [] + return { + "files": len(details), + "bytes": sum((f.get("info", {}) or {}).get("size", 0) or 0 for f in details), + } + + def _generate_stats_json( dataset, console: Optional[Console] = None, @@ -2430,22 +2517,49 @@ def _generate_stats_json( Generate statistics JSON by analyzing parquet files in the dataset. Groups by language and counts files, sizes, and objects. """ + import owilix + stats = { "statistics": [ { "kind": "language", "values": {} } - ] + ], + # Provenance. Without it, a consumer cannot tell when these numbers were + # taken or by what -- and an index built on rows that cannot say that + # reintroduces the ambiguity below at a larger scale. + "computed_at": datetime.now(timezone.utc).isoformat(), + "owilix_version": owilix.__version__, + # Measured vs not measured, kept apart. + # + # This used to return the same empty `values: {}` whether the dataset + # genuinely had no rows or no parquet file was found at all, so a + # consumer could not distinguish "measured, empty" from "nothing to + # measure". That is the same mistake that let a catalogue report 586 + # collections mirrored when 4 held data. + "parquet_files_found": 0, + # Declared comes from the upstream sidecar; observed comes from the + # store. They are equal only when the transfer actually completed. + "declared": _declared_from_metadata(dataset), + "observed": {"files": None, "bytes": None}, + "materialised": None, } try: # Get all parquet files with details (including row counts) file_details = dataset.files_details("**/*.parquet", count_rows=True) + stats["parquet_files_found"] = len(file_details or []) + stats["observed"] = _observed_from_files(file_details) + stats["materialised"] = bool(file_details) if not file_details: if console: - console.print(f"[yellow]No parquet files found in dataset[/yellow]") + console.print( + "[yellow]No parquet files found in dataset — recording " + "observed 0 against declared " + f"{stats['declared'].get('files')} file(s)[/yellow]" + ) return stats # Group by language @@ -3146,13 +3260,10 @@ def remote_readme( if create_readme: readme_content = _generate_readme_markdown(d, stats) - # Write files - console.print(f" Writing stats.json...") - d.repository.writelines(d, "stats.json", json.dumps(stats, indent=2)) - + # Write files, but only if they would actually change. + _write_if_changed(d, "stats.json", json.dumps(stats, indent=2), console=console) if create_readme and readme_content is not None: - console.print(f" Writing README.md...") - d.repository.writelines(d, "README.md", readme_content) + _write_if_changed(d, "README.md", readme_content, console=console) console.print(f"[green] ✓ Completed {title}[/green]") processed += 1 diff --git a/pyproject.toml b/pyproject.toml index 23ef246..5dfae2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "owilix" -version = "5.10.0" +version = "5.10.1" description = "OWILIX - the Command Line Interface for slicing and consuming the Open Web Index. " readme = "Readme.md" license = { text = "MIT" } diff --git a/tests/owilix/core/tasks/test_summarize_integrity.py b/tests/owilix/core/tasks/test_summarize_integrity.py new file mode 100644 index 0000000..902b05f --- /dev/null +++ b/tests/owilix/core/tasks/test_summarize_integrity.py @@ -0,0 +1,187 @@ +"""M0-M2 of the summarize/integrity plan. + +Three defects, all measured on a real collection before being fixed: + +- `stats.json` carried no provenance at all, so a consumer could not tell when a + number was taken or by what. (The plan credited it with `owilix.version`; + those fields are `remote doctor` output.) +- It returned an identical `{"values": {}}` whether a dataset genuinely had no + rows or no parquet file was found, so *measured empty* and *nothing measured* + were the same value -- the mistake that told visitors 586 collections were + mirrored when 4 held data. +- `--force` rewrote every README and stats object with byte-identical content. + A rewritten object is what invalidates a long-lived fsspec dircache ETag, and + until v5.9.0 a stale ETag was enough to empty a whole listing. +""" +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from owilix.core.tasks.remote import ( + _declared_from_metadata, + _generate_stats_json, + _observed_from_files, + _write_if_changed, +) + + +def _dataset(declared=None, files=None): + metadata = {"fileCount": 567, "objectCount": 250000, "size": 14748809896} + if declared is not None: + metadata = declared + dataset = MagicMock() + dataset.metadata.get.side_effect = lambda k, d=None: metadata.get(k, d) + dataset.files_details.return_value = files if files is not None else [] + return dataset + + +def _file(size, relpath="language=deu/part-0.parquet", objects=5): + return {"relpath": relpath, "info": {"size": size}, "objects": objects} + + +class TestProvenance: + def test_stats_record_when_and_by_what(self): + import owilix + + stats = _generate_stats_json(_dataset(files=[_file(100)]), hosts_topk=0) + + assert stats["owilix_version"] == owilix.__version__ + assert stats["computed_at"].endswith("+00:00") + + def test_provenance_is_present_even_when_nothing_was_found(self): + """The empty case is exactly where a consumer needs to know it ran.""" + stats = _generate_stats_json(_dataset(files=[]), hosts_topk=0) + + assert stats["computed_at"] + assert stats["owilix_version"] + + +class TestMeasuredEmptyIsNotNothingMeasured: + def test_no_parquet_found_is_distinguishable(self): + stats = _generate_stats_json(_dataset(files=[]), hosts_topk=0) + + assert stats["parquet_files_found"] == 0 + assert stats["materialised"] is False + assert stats["observed"] == {"files": 0, "bytes": 0} + + def test_parquet_found_reports_what_it_measured(self): + stats = _generate_stats_json( + _dataset(files=[_file(100), _file(200, "language=eng/part-0.parquet")]), + hosts_topk=0, + ) + + assert stats["parquet_files_found"] == 2 + assert stats["materialised"] is True + assert stats["observed"] == {"files": 2, "bytes": 300} + + def test_the_two_cases_differ(self): + """Before this, both produced the identical empty document.""" + empty = _generate_stats_json(_dataset(files=[]), hosts_topk=0) + full = _generate_stats_json(_dataset(files=[_file(100)]), hosts_topk=0) + + assert empty["materialised"] != full["materialised"] + assert empty["parquet_files_found"] != full["parquet_files_found"] + + +class TestObservedVersusDeclared: + def test_the_586_vs_4_case(self): + """Sidecar claims 567 files; the store holds none.""" + stats = _generate_stats_json(_dataset(files=[]), hosts_topk=0) + + assert stats["declared"] == {"files": 567, "objects": 250000, "bytes": 14748809896} + assert stats["observed"] == {"files": 0, "bytes": 0} + assert stats["materialised"] is False + + def test_a_completed_transfer_agrees(self): + stats = _generate_stats_json( + _dataset(declared={"fileCount": 2, "objectCount": 8, "size": 300}, + files=[_file(100), _file(200)]), + hosts_topk=0, + ) + + assert stats["declared"]["files"] == stats["observed"]["files"] == 2 + assert stats["materialised"] is True + + def test_declared_survives_missing_or_unparseable_metadata(self): + """A sidecar without counts must not crash the summary.""" + assert _declared_from_metadata(_dataset(declared={})) == { + "files": None, "objects": None, "bytes": None + } + assert _declared_from_metadata(_dataset(declared={"fileCount": "many"}))["files"] is None + + def test_observed_counts_zero_as_a_measurement_not_a_gap(self): + """The listing succeeded, so 0 is a fact; None would claim ignorance.""" + assert _observed_from_files([]) == {"files": 0, "bytes": 0} + + def test_observed_tolerates_entries_without_size(self): + assert _observed_from_files([{"relpath": "x"}, _file(50)]) == {"files": 2, "bytes": 50} + + +class TestWriteIfChanged: + def _dataset_holding(self, content): + repo = MagicMock() + repo.readlines.return_value = [content] if content is not None else None + return SimpleNamespace(repository=repo) + + def test_identical_content_is_not_rewritten(self): + dataset = self._dataset_holding("same") + + assert _write_if_changed(dataset, "README.md", "same") is False + dataset.repository.writelines.assert_not_called() + + def test_different_content_is_written(self): + dataset = self._dataset_holding("old") + + assert _write_if_changed(dataset, "README.md", "new") is True + dataset.repository.writelines.assert_called_once() + + def test_a_missing_object_is_written(self): + dataset = self._dataset_holding(None) + + assert _write_if_changed(dataset, "README.md", "new") is True + + def test_an_unreadable_object_is_written_rather_than_assumed_unchanged(self): + """Unknown must not be treated as unchanged.""" + dataset = self._dataset_holding("x") + dataset.repository.readlines.side_effect = OSError("Precondition Failed") + + assert _write_if_changed(dataset, "README.md", "new") is True + + def test_stats_differing_only_by_computed_at_are_not_rewritten(self): + """computed_at changes every run; a byte compare would never match.""" + old = json.dumps({"computed_at": "2026-08-01T00:00:00+00:00", "observed": {"files": 2}}) + new = json.dumps({"computed_at": "2026-08-07T00:00:00+00:00", "observed": {"files": 2}}) + dataset = self._dataset_holding(old) + + assert _write_if_changed(dataset, "stats.json", new) is False + + def test_stats_whose_numbers_moved_are_rewritten(self): + """The churn fix must not suppress a real change.""" + old = json.dumps({"computed_at": "2026-08-01T00:00:00+00:00", "observed": {"files": 0}}) + new = json.dumps({"computed_at": "2026-08-07T00:00:00+00:00", "observed": {"files": 2}}) + dataset = self._dataset_holding(old) + + assert _write_if_changed(dataset, "stats.json", new) is True + + def test_a_non_json_name_is_compared_bytewise(self): + """Only stats.json gets the computed_at exemption.""" + dataset = self._dataset_holding("a") + + assert _write_if_changed(dataset, "README.md", "b") is True + + +class TestBackwardCompatibility: + def test_the_statistics_array_keeps_its_shape(self): + """`_read_stats_json` consumers and the dashboard both read this.""" + stats = _generate_stats_json(_dataset(files=[_file(100)]), hosts_topk=0) + + assert isinstance(stats["statistics"], list) + assert stats["statistics"][0]["kind"] == "language" + assert "deu" in stats["statistics"][0]["values"] + + @pytest.mark.parametrize("key", ["computed_at", "owilix_version", "parquet_files_found", + "declared", "observed", "materialised"]) + def test_new_keys_are_additive(self, key): + assert key in _generate_stats_json(_dataset(files=[_file(1)]), hosts_topk=0) diff --git a/uv.lock b/uv.lock index e11784b..b6b5e9d 100644 --- a/uv.lock +++ b/uv.lock @@ -1102,7 +1102,7 @@ wheels = [ [[package]] name = "owilix" -version = "5.10.0" +version = "5.10.1" source = { editable = "." } dependencies = [ { name = "ciff-toolkit" },