diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..01438c7f56 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,55 @@ +name: Bug report +description: Report incorrect behavior, a crash, or a regression. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Please search existing issues first. Include the smallest reproducible example you can. + + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the actual behavior and the behavior you expected. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Include commands, input files, and the smallest useful example. + render: shell + validations: + required: true + + - type: textarea + id: output + attributes: + label: Error output or graph output + description: Paste the relevant traceback, diagnostic, or incorrect result. + render: text + validations: + required: true + + - type: input + id: version + attributes: + label: Graphify version + placeholder: "0.9.64" + + - type: input + id: environment + attributes: + label: Environment + description: Include operating system, Python version, and installation method. + placeholder: "macOS 15, Python 3.12, uv tool install" + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add related files, screenshots, or links if they help explain the issue. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..ac603f5520 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Security policy + url: https://github.com/Graphify-Labs/graphify/security/policy + about: Please report vulnerabilities privately using the security policy. + - name: 📖 Documentation + url: https://github.com/Graphify-Labs/graphify/blob/v8/README.md + about: Check the README and docs before opening an issue. + - name: 🤝 Contributing guide + url: https://github.com/Graphify-Labs/graphify/blob/v8/README.md#development-setup + about: Read the development and contribution guidelines before opening a pull request. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..668a84a481 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,54 @@ +name: Feature request +description: Suggest an improvement or new capability. +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Please search existing issues first and describe the user problem before proposing an implementation. + + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What are you trying to do that Graphify cannot do today? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the expected behavior, command, API, or workflow. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What other approaches or workarounds did you consider? + + - type: dropdown + id: area + attributes: + label: Area + options: + - Extraction or language support + - Graph building or querying + - CLI or installation + - Skills or assistant integrations + - Documentation + - Performance + - Security + - Developer experience or CI + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional context + description: Add examples, links, or sample input where useful. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..57e09327e6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +## What does this PR do? + + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Tests or CI +- [ ] Refactor +- [ ] Security fix + +## How was this tested? + + + +```text + +``` + +## Graphify-specific checklist + +- [ ] I added or updated tests for behavior changes. +- [ ] I updated documentation or confirmed that no documentation is needed. +- [ ] I updated generated skill artifacts when changing their source fragments. +- [ ] I considered compatibility across supported Python versions. +- [ ] I confirmed that no API keys, generated graph data, or local-only files are included. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f5e373f7..94bcde0c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,71 @@ jobs: uv run --frozen graphify --help uv run --frozen graphify install + omp-bridge: + # tests/omp.test.ts drives the real OMP extension: bun resolves the + # path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent + # devDependency in graphify/omp/ (the only package the host provides; + # graphify/omp/index.ts vendors isReadableUrlPath locally instead of + # importing it from @oh-my-pi/pi-tui, which the host never resolves), and + # the policy assertions run against the real installed Python CLI, so + # this job needs both toolchains. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: uv sync --frozen + + - name: Install OMP extension dependencies + run: bun install --frozen-lockfile + working-directory: graphify/omp + + - name: Run OMP bridge tests + run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts + + - name: Check for upstream symbol moves (advisory, no execution) + # The frozen run above only proves the bridge works against the + # exact pinned version. Upstream OMP has moved exported symbols + # between minor releases before (isReadableUrlPath moved from + # pi-coding-agent to pi-tui) without the frozen lockfile ever seeing + # it; index.ts now vendors that predicate locally instead of + # tracking whichever package currently exports it. This step used + # to install @latest AND execute the whole suite against it -- + # unpinned, mutable third-party code running on every CI invocation. + # It now only downloads the published package (--ignore-scripts: + # no install hooks run) and statically compares index.ts's imported + # path-utils symbols against that file's declared exports as plain + # text -- the downloaded code is never imported or executed, but a + # future symbol move still surfaces here. + # continue-on-error: an unrelated upstream release must not block a + # merge; the frozen run above stays the blocking gate. + continue-on-error: true + run: | + bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest --ignore-scripts + bun -e ' + const fs = require("node:fs"); + const index = fs.readFileSync("graphify/omp/index.ts", "utf8"); + const block = index.match(/import\s*{([^}]+)}\s*from\s*"@oh-my-pi\/pi-coding-agent\/tools\/path-utils"/); + if (!block) throw new Error("no path-utils import block found in index.ts"); + const imported = block[1].split(",").map((s) => s.trim()).filter(Boolean); + const source = fs.readFileSync("graphify/omp/node_modules/@oh-my-pi/pi-coding-agent/src/tools/path-utils.ts", "utf8"); + const exported = new Set([...source.matchAll(/^export\s+(?:async\s+function|function)\s+(\w+)/gm)].map((m) => m[1])); + const missing = imported.filter((name) => !exported.has(name)); + if (missing.length) { + console.error(`@oh-my-pi/pi-coding-agent@latest no longer exports: ${missing.join(", ")}`); + process.exit(1); + } + console.log(`ok: every imported symbol is exported by @latest (${imported.join(", ")})`); + ' + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that diff --git a/.gitignore b/.gitignore index 0a6775b2a8..b5fec288b9 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,9 @@ paper/ # macOS Finder metadata .DS_Store + +# OMP extension test dependency tree (graphify/omp) +graphify/omp/node_modules/ + +# Local symlink for the root-level bun tests (temporary, per AGENTS.md) +/node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..a9fbd9c068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # Changelog -Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +Full release notes with details on each version: [GitHub Releases](https://github.com/Graphify-Labs/graphify/releases) + +## 0.9.65 (2026-09-20) + +- Security: the `svg`/`all` extras now floor Pillow at `>=12.3.0` for CVE-2026-54058 (Pillow was pulled in transitively via matplotlib). Note: Pillow 12.3.0 dropped its glibc<2.27 cp310 Linux wheel, so a very old-glibc Python 3.10 host with the `svg`/`all` extra builds Pillow from sdist (#3698, thanks @viral-antuit). +- Feature: a Go interface's method requirements (`type Foo interface { Bar() }`) now attach to the interface node, so calls resolve to them (#3672, thanks @rajatnagda45). +- Feature: a Swift protocol's method requirements (`protocol P { func f() }`) now attach to the protocol node (#3673, thanks @rajatnagda45). +- Fix: Java enum body members (methods/constructor/fields after the constants) now attach to the enum, not the file, and intra-enum calls resolve (#3674, thanks @rajatnagda45). +- Fix: a Verilog module instantiation now links to the module's local definition instead of a phantom duplicate; a genuinely external module stays a sourceless stub (#3675, thanks @rajatnagda45). +- Fix: JS/TS `let`/`const` bindings are now scoped to their own block rather than the whole function, so a block-local binding no longer suppresses a genuine `indirect_call` edge elsewhere in the function; `var` stays function-scoped (#3688, thanks @ayushcodes10). +- Fix: the incremental rebuild no longer purges AST nodes it just reported as fail-closed "kept" — the eviction pass re-checks the kept set, so a moved-file/symlink layout can't deadlock the shrink guard into refusing every update (#3697, #3695, thanks @hopstreax). +- Fix: `graph.html` no longer crashes vis-network with a stack overflow on large graphs — nodes are seeded on a spiral before physics runs so overlap-avoidance can't blow the layout recursion (#3699, thanks @sanjaiyan-dev). +- Fix: node and edge tooltips now show special characters literally (C++ templates like `vector`, generics, `&`, quotes) instead of raw HTML entities, while the HTML sinks that need escaping keep it (#3686, #3664, thanks @hopstreax). +- Docs: repository links now point at `Graphify-Labs/graphify` instead of the old account (including in generated wiki output), translated READMEs use the current logo, GitHub issue/PR templates were added, and the Enterprise link was corrected (#3692, #3694, #3693, thanks @Abdul535). + +## 0.9.64 (2026-09-18) + +- Feature: Terraform block attributes (`ami`, `instance_type`, `cidr_block`, tags, and the like) are now preserved on the resource/data/module node and are queryable and searchable, with typed values (bool/number/list/map) and nested blocks kept separate from direct attributes. Secret-named attribute values (`password`, `*secret*`, `*token*`, `*_key`, connection strings) are redacted before they reach `graph.json` or the model, so a hardcoded credential in a `.tf` file does not leak (#3644, thanks @hopstreax). +- Feature: JavaScript inside an inline ``` with no body masks to an empty region, + which parses as empty JS and contributes nothing — no special-casing needed. + Returns ``(masked_source, had_script)``; the caller skips the JS pass + entirely when ``had_script`` is False. + """ + def _blank(s: str) -> str: + return re.sub(r"[^\r\n]", " ", s) + + out: list[str] = [] + pos = 0 + had_script = False + for m in _PHP_SCRIPT_RE.finditer(src): + had_script = True + out.append(_blank(src[pos:m.start(1)])) # everything up to and including and any trailing part of the match + pos = m.end() + if not had_script: + return src, False + out.append(_blank(src[pos:])) + return "".join(out), True + + def extract_php(path: Path) -> dict: - """Extract classes, functions, methods, namespace uses, and calls from a .php file.""" - return _extract_generic(path, _PHP_CONFIG) + """Extract classes, functions, methods, namespace uses, and calls from a .php file. + + Also parses any inline ``\n" + ) + r = extract_php(f) + labels = {n["label"] for n in r["nodes"]} + assert "phpSideHelper()" in labels + assert "inlineJsHelper()" in labels + assert "inlineJsCaller()" in labels + assert ("inlineJsCaller", "inlineJsHelper") in _edge_labels(r, "calls") + + +def test_php_inline_script_line_numbers_match_source(tmp_path): + """The masking pass must preserve line numbers so a script-block symbol + points at its real line, not line 1 or an offset guess.""" + f = tmp_path / "page.php" + f.write_text( + "\n" + "\n" + ) + r = extract_php(f) + node = next(n for n in r["nodes"] if n["label"] == "onLineFive()") + assert node["source_location"] == "L5" + + +def test_php_external_script_src_contributes_nothing(tmp_path): + """A \n' + "\n" + ) + r = extract_php(f) + assert "error" not in r + labels = {n["label"] for n in r["nodes"]} + assert "helper()" in labels + assert "realFn()" in labels + + +def test_php_file_without_script_block_is_unaffected(tmp_path): + """A plain .php file with no \n" + ) + r = extract_php(f) + labels = {n["label"] for n in r["nodes"]} + # Exactly one sharedName node survives (the PHP one) -- no duplicate. + assert labels == {"page.php", "sharedName()", "jsCaller()"} + # The JS call to the colliding name must not appear as a calls edge at + # all (misattaching it to the PHP node would be a wrong edge, not a + # missing one). + assert _edge_labels(r, "calls") == set() + # The file's own contains edges for the surviving, non colliding JS node + # must still be present -- the fix for the collision must not also drop + # unrelated JS edges sourced from the shared file node. + assert ("page.php", "jsCaller") in _edge_labels(r, "contains") + + # ── Swift ──────────────────────────────────────────────────────────────────── def test_swift_no_error(): @@ -3496,6 +3600,40 @@ def test_systemverilog_preserves_existing_module_extraction(): assert "instantiates" in _relations(r) +def test_systemverilog_instantiation_resolves_to_local_module(): + """`top` instantiating same-file `leaf` links to leaf's definition, not a + bare-id phantom duplicate. The instantiation target used an unscoped id + (`_make_id(name)`) while the definition used `_make_id(stem, name)`, so + they never matched: the module was split into a real definition node and a + sourced phantom that also blocked the corpus-level rewire (#1402 shape).""" + r = extract_verilog(FIXTURES / "sample.sv") + leaf_nodes = [n for n in r["nodes"] if n["label"] == "leaf"] + assert len(leaf_nodes) == 1, f"leaf split into duplicates: {leaf_nodes}" + leaf_id = leaf_nodes[0]["id"] + # The surviving node is the real, sourced definition. + assert leaf_nodes[0].get("source_file") + inst_targets = {e["target"] for e in r["edges"] if e["relation"] == "instantiates"} + assert leaf_id in inst_targets, ( + f"instantiation did not link to the leaf definition {leaf_id}: {inst_targets}" + ) + + +def test_systemverilog_cross_file_instantiation_is_sourceless_stub(tmp_path): + """A module defined in another file is a SOURCELESS stub so the corpus-level + rewire can collapse it onto the real definition, rather than a sourced node + whose id bakes in this file's path and blocks the rewire.""" + f = tmp_path / "wrapper.sv" + f.write_text("module wrapper;\n external_ip u_ip();\nendmodule\n") + r = extract_verilog(f) + stubs = [n for n in r["nodes"] if n["label"] == "external_ip"] + assert len(stubs) == 1, stubs + assert stubs[0].get("source_file") == "", ( + f"cross-file instantiation target must be sourceless: {stubs[0]}" + ) + inst_targets = {e["target"] for e in r["edges"] if e["relation"] == "instantiates"} + assert stubs[0]["id"] in inst_targets + + def test_systemverilog_missing_file_returns_empty(): r = extract_verilog(Path("nonexistent.sv")) assert r["nodes"] == [] diff --git a/tests/test_multigraph_diagnostics.py b/tests/test_multigraph_diagnostics.py index 8c39b8e23d..0206a0c13b 100644 --- a/tests/test_multigraph_diagnostics.py +++ b/tests/test_multigraph_diagnostics.py @@ -103,6 +103,37 @@ def test_diagnose_extraction_categorizes_same_endpoint_collapse() -> None: assert summary["post_build_edge_count"] == 2 +def test_diagnose_extraction_separates_external_reference_edges() -> None: + extraction = { + "nodes": [ + {"id": "app", "label": "app", "file_type": "code", "source_file": "app.ts"}, + ], + "edges": [ + { + "source": "app", + "target": "ref_react", + "relation": "imports_from", + "confidence": "EXTRACTED", + "source_file": "app.ts", + }, + { + "source": "app", + "target": "missing_internal", + "relation": "references", + "confidence": "INFERRED", + "source_file": "app.ts", + }, + ], + } + + summary = diagnose_extraction(extraction, directed=True) + report = format_diagnostic_report(summary) + + assert summary["external_reference_edges"] == 1 + assert summary["dangling_endpoint_edges"] == 1 + assert "external_reference_edges: 1" in report + + def test_diagnose_extraction_accepts_node_link_links_key() -> None: extraction = _diagnostic_fixture() extraction["links"] = extraction.pop("edges") diff --git a/tests/test_omp_install.py b/tests/test_omp_install.py new file mode 100644 index 0000000000..98a75edede --- /dev/null +++ b/tests/test_omp_install.py @@ -0,0 +1,52 @@ +"""OMP owns registration; Graphify supplies a wheel-contained native package.""" +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +from graphify.install import dispatch_install_cli + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # type: ignore[no-redef] + + +def test_omp_path_is_a_complete_native_package(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "path"]) + assert dispatch_install_cli("omp") + package = Path(capsys.readouterr().out.strip()) + manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) + assert manifest["omp"]["extensions"] + pyproject = tomllib.loads((package.parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + assert manifest["version"] == pyproject["project"]["version"] + for entry in manifest["omp"]["extensions"]: + assert (package / entry).is_file() + assert (package / entry).resolve().is_relative_to(package) + + +def test_omp_install_delegates_to_host_and_propagates_failure(monkeypatch): + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: "/usr/bin/omp") + + def host(argv, *, check): + assert argv[:3] == ["/usr/bin/omp", "plugin", "install"] + assert (Path(argv[3]) / "package.json").is_file() + return SimpleNamespace(returncode=23) + + monkeypatch.setattr("subprocess.run", host) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("omp") + assert error.value.code == 23 + + +def test_omp_install_without_host_does_not_create_configuration(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", "omp", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: None) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("omp") + assert error.value.code == 1 + assert not list(tmp_path.iterdir()) diff --git a/tests/test_phantom_external_import.py b/tests/test_phantom_external_import.py index c30f334ab6..f55145f710 100644 --- a/tests/test_phantom_external_import.py +++ b/tests/test_phantom_external_import.py @@ -48,6 +48,28 @@ def test_scoped_package_import_is_ref_namespaced(): assert tgt.startswith("ref") +# ── #3595: a package subpath import must resolve to the package root, not the ─ +# whole specifier, so it lands on the same node as a bare import of that +# package (and the package.json dependency node itself) instead of dangling. + + +def test_package_subpath_import_resolves_to_the_same_target_as_a_bare_import(): + bare_tgt, _ = _resolve_js_import_target("next", "src/bare.ts") + subpath_tgt, resolved_path = _resolve_js_import_target("next/image", "src/subpath.ts") + assert resolved_path is None + assert subpath_tgt == bare_tgt + + +def test_scoped_package_subpath_import_resolves_to_the_scope_and_package_only(): + bare_tgt, _ = _resolve_js_import_target("@scope/pkg", "src/bare.ts") + subpath_tgt, resolved_path = _resolve_js_import_target("@scope/pkg/deep/sub", "src/subpath.ts") + assert resolved_path is None + assert subpath_tgt == bare_tgt + # Must not fold onto a DIFFERENT package under the same scope. + other_tgt, _ = _resolve_js_import_target("@scope/other", "src/other.ts") + assert subpath_tgt != other_tgt + + # ── end-to-end: the reporter's exact synthetic monorepo ───────────────────── @@ -114,3 +136,54 @@ def test_multiple_tsx_files_do_not_all_alias_onto_one_python_file(tmp_path: Path if d.get("relation") == "imports_from" and ({u, v} & py_ids) ] assert not phantom, f"phantom edges onto colors.py: {phantom}" + + +def test_subpath_import_does_not_dangle_like_a_bare_import(tmp_path: Path): + """The reporter's exact repro for #3595: a package subpath import + ("next/image") produced no edge at all, while a bare import of the same + package ("next") resolved fine, so a file that only ever imports + subpaths came out fully disconnected from the framework it depends on.""" + _write( + tmp_path / "package.json", + '{"name": "mre", "dependencies": {"next": "16.2.7"}}\n', + ) + bare = _write( + tmp_path / "bare.ts", + 'import type { Metadata } from "next";\n' + "export const meta: Metadata = {};\n", + ) + subpath = _write( + tmp_path / "subpath.ts", + 'import Image from "next/image";\n' + "export const i = Image;\n", + ) + + result = extract( + [tmp_path / "package.json", bare, subpath], cache_root=tmp_path / "graphify-out" + ) + G = build_from_json(result, root=str(tmp_path)) + + def _imports_from(nid): + # The built graph may be undirected, which loses (u, v) edge-iteration + # order -- direction is preserved separately as _src/_tgt (build.py). + return { + d.get("_tgt") for u, v, d in G.edges(data=True) + if d.get("relation") == "imports_from" and d.get("_src") == nid + } + + bare_id = next( + n for n, d in G.nodes(data=True) + if str(d.get("source_file", "")).endswith("bare.ts") + ) + subpath_id = next( + n for n, d in G.nodes(data=True) + if str(d.get("source_file", "")).endswith("subpath.ts") + ) + bare_targets = _imports_from(bare_id) + subpath_targets = _imports_from(subpath_id) + assert bare_targets, "the bare import must resolve" + assert subpath_targets, "the subpath import must resolve, not dangle" + assert subpath_targets == bare_targets, ( + "a subpath import of a package must land on the same node as a bare " + "import of that package" + ) diff --git a/tests/test_rust_self_member_calls.py b/tests/test_rust_self_member_calls.py index c4d2d6ddce..d251cae796 100644 --- a/tests/test_rust_self_member_calls.py +++ b/tests/test_rust_self_member_calls.py @@ -16,7 +16,10 @@ from pathlib import Path +import pytest + from graphify.extract import extract +from graphify.extractors.rust import extract_rust def _calls(tmp_path: Path, files: dict[str, str]): @@ -86,6 +89,323 @@ def test_self_call_same_file_control_is_unaffected(tmp_path: Path): assert (caller, callee) in calls +def test_generic_self_call_resolves_across_alpha_renamed_impls(tmp_path: Path): + """Parameter spelling must not split one simple generic impl family.""" + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "read.rs": ( + "use crate::state::Bucket;\n" + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "run.rs": ( + "use crate::state::Bucket;\n" + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "run_bucket_u") + callee = _find(result, ".fetch_value()", "read_bucket_t") + assert (caller, callee) in calls + assert calls[(caller, callee)]["confidence"] == "EXTRACTED" + + +def test_generic_self_call_does_not_cross_unrelated_same_named_families( + tmp_path: Path, +): + """The marker is not proof when two declarations share owner and arity.""" + calls, result = _calls(tmp_path, { + "a/state.rs": "pub struct Bucket { value: T }\n", + "a/read.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "b/state.rs": "pub struct Bucket { value: T }\n", + "b/run.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "b_run_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + +def test_generic_self_call_without_a_declaration_fails_closed(tmp_path: Path): + """Impl blocks alone do not prove that same-named owners are one family.""" + calls, result = _calls(tmp_path, { + "method.rs": ( + "impl Bucket { pub fn fetch_value(&self) {} }\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + +def test_generic_self_call_counts_collapsed_same_file_declarations( + tmp_path: Path, +): + """Nested modules must not hide two unrelated declarations behind one ID.""" + calls, result = _calls(tmp_path, { + "state.rs": ( + "pub mod a { pub struct Bucket(pub T); }\n" + "pub mod b { pub struct Bucket(pub T); }\n" + ), + "a_impl.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "fallback.rs": ( + "pub trait Fallback { fn fetch_value(&self) {} }\n" + ), + "b_impl.rs": ( + "impl Fallback for Bucket {}\n" + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "b_impl_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + +@pytest.mark.parametrize( + ("declaration", "callee_impl", "caller_impl"), + [ + ( + "pub struct Bucket { value: T }\n", + "impl Bucket", + "impl Bucket", + ), + ( + "pub struct Bucket { value: T }\n", + "impl Bucket where T: Clone", + "impl Bucket where U: Clone", + ), + ( + ( + "pub struct Bucket { value: T }\n" + "trait Fetch { fn fetch_value(&self); }\n" + ), + "impl Fetch for Bucket", + "impl Bucket", + ), + ( + "pub struct Bucket<'a, T> { value: &'a T }\n", + "impl<'a, T> Bucket<'a, T>", + "impl<'b, U> Bucket<'b, U>", + ), + ( + "pub struct Bucket { value: [u8; N] }\n", + "impl Bucket", + "impl Bucket", + ), + ( + "pub struct Bucket { value: T }\n", + "impl Bucket>", + "impl Bucket", + ), + ( + "pub struct Bucket { value: T }\n", + "impl Bucket", + "impl Bucket", + ), + ( + "pub struct Pair { left: T, right: U }\n", + "impl Pair", + "impl Pair", + ), + ( + "pub struct Pair { left: T, right: U }\n", + "impl Pair", + "impl Pair", + ), + ], + ids=[ + "bounded", "where", "trait", "lifetime", "const", "nested", + "concrete", "repeated", "permuted", + ], +) +def test_unsupported_generic_impl_shapes_fail_closed( + tmp_path: Path, + declaration: str, + callee_impl: str, + caller_impl: str, +): + """Unsupported type semantics get neither a marker nor a guessed edge.""" + method = tmp_path / "method.rs" + method.write_text( + f"{callee_impl} {{\n pub fn fetch_value(&self) {{}}\n}}\n", + encoding="utf-8", + ) + impl_nodes = [ + node for node in extract_rust(method)["nodes"] + if node.get("label", "").startswith(("Bucket", "Pair")) + ] + assert impl_nodes + assert all("_rust_impl_key" not in node for node in impl_nodes) + + calls, result = _calls(tmp_path / "corpus", { + "state.rs": declaration, + "method.rs": f"{callee_impl} {{\n pub fn fetch_value(&self) {{}}\n}}\n", + "caller.rs": ( + f"{caller_impl} {{\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller") + assert not {target for (source, target) in calls if source == caller} + + +@pytest.mark.parametrize("deferred_first", [False, True]) +def test_mixed_simple_and_bounded_impl_blocks_fail_closed( + tmp_path: Path, + deferred_first: bool, +): + """A shared impl node cannot lend eligibility to a bounded block.""" + simple_target = "impl Bucket { fn marker_a(&self) {} }\n" + bounded_target = ( + "impl Bucket { pub fn fetch_value(&self) {} }\n" + ) + simple_caller = "impl Bucket { fn marker_b(&self) {} }\n" + bounded_caller = ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ) + def order(simple: str, deferred: str) -> str: + return deferred + simple if deferred_first else simple + deferred + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "target.rs": order(simple_target, bounded_target), + "caller.rs": order(simple_caller, bounded_caller), + }) + caller = _find(result, ".run()", "caller_bucket_u") + assert not {target for (source, target) in calls if source == caller} + + target_impl = next( + node for node in extract_rust(tmp_path / "target.rs")["nodes"] + if node.get("label") == "Bucket" + ) + assert "_rust_impl_key" not in target_impl + raw_call = next( + call for call in extract_rust(tmp_path / "caller.rs")["raw_calls"] + if call.get("callee") == "fetch_value" + ) + assert "rust_self_impl_key" not in raw_call + + +def test_mixed_simple_and_trait_impl_target_fails_closed(tmp_path: Path): + """A coalesced trait impl must not expose its methods as inherent ones.""" + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "target.rs": ( + "pub trait Fetch { fn fetch_value(&self); }\n" + "impl Bucket { fn marker(&self) {} }\n" + "impl Fetch for Bucket {\n" + " fn fetch_value(&self) {}\n" + "}\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller_bucket_u") + assert not {target for (source, target) in calls if source == caller} + target_impl = next( + node for node in extract_rust(tmp_path / "target.rs")["nodes"] + if node.get("label") == "Bucket" + ) + assert "_rust_impl_key" not in target_impl + + +def test_scoped_self_call_remains_deferred_across_impl_files(tmp_path: Path): + """`Self::method()` is a scoped-call form, outside this resolver slice.""" + calls, result = _calls(tmp_path, { + "state.rs": "pub struct Bucket { value: T }\n", + "method.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { Self::fetch_value(self); }\n" + "}\n" + ), + }) + caller = _find(result, ".run()", "caller") + assert not {target for (source, target) in calls if source == caller} + + +def test_generic_self_call_invalidates_markerless_ast_cache( + tmp_path: Path, + monkeypatch, +): + """A same-version cache from before the marker contract must be missed.""" + import graphify.cache as cache_mod + from graphify.cache import save_cached + + files = { + "state.rs": "pub struct Bucket { value: T }\n", + "method.rs": ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" + ), + "caller.rs": ( + "impl Bucket {\n" + " pub fn run(&self) { self.fetch_value(); }\n" + "}\n" + ), + } + paths = [] + current_schema = cache_mod._AST_CACHE_SCHEMA + assert current_schema >= 4 + monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema - 1) + monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set()) + for name, body in files.items(): + path = tmp_path / name + path.write_text(body, encoding="utf-8") + paths.append(path) + stale = extract_rust(path) + for node in stale["nodes"]: + node.pop("_rust_impl_key", None) + for raw_call in stale.get("raw_calls", []): + raw_call.pop("rust_self_impl_key", None) + save_cached( + path, + stale, + root=tmp_path, + cache_root=tmp_path, + kind="ast", + ) + + monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema) + monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set()) + result = extract(paths, root=tmp_path, cache_root=tmp_path) + caller = _find(result, ".run()", "caller_bucket_u") + callee = _find(result, ".fetch_value()", "method_bucket_t") + assert any( + edge.get("relation") == "calls" + and edge.get("source") == caller + and edge.get("target") == callee + for edge in result["edges"] + ) + + def test_self_call_to_ambiguous_type_name_yields_no_edge(tmp_path: Path): """Two DIFFERENT structs across the corpus happen to share the bare name `Config`, and both define a same-named method -- the exactly-one-candidate diff --git a/tests/test_swift_protocol_requirements.py b/tests/test_swift_protocol_requirements.py new file mode 100644 index 0000000000..ec41c28523 --- /dev/null +++ b/tests/test_swift_protocol_requirements.py @@ -0,0 +1,100 @@ +"""Regression coverage for method requirements declared in a Swift protocol. + +tree-sitter-swift gives a protocol's body-less method requirement its own node +type, ``protocol_function_declaration``, rather than reusing the +``function_declaration`` used inside a class/struct. The Swift config only +listed ``function_declaration``, so a protocol's method contract was dropped and +the protocol became an empty node -- the API surface every conformer must +implement never entered the graph. +""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from graphify.extract import extract_swift + + +def _labels(result): + return [n["label"] for n in result["nodes"]] + + +class TestSwiftProtocolRequirements(unittest.TestCase): + def _extract(self, src: str) -> dict: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "Proto.swift" + p.write_text(src, encoding="utf-8") + return extract_swift(p) + + def test_protocol_method_requirements_become_methods(self): + r = self._extract( + "protocol Drawable {\n" + " func draw()\n" + " func area() -> Double\n" + "}\n" + ) + proto_nid = next(n["id"] for n in r["nodes"] if n["label"] == "Drawable") + method_targets = { + n["label"] + for e in r["edges"] + if e["relation"] == "method" and e["source"] == proto_nid + for n in r["nodes"] + if n["id"] == e["target"] + } + self.assertEqual(method_targets, {".draw()", ".area()"}) + + def test_protocol_method_return_type_reference_is_captured(self): + # The requirement's body-less signature still carries a return type. + r = self._extract( + "protocol Sized {\n" + " func area() -> Double\n" + "}\n" + ) + area_nid = next(n["id"] for n in r["nodes"] if n["label"] == ".area()") + ref_targets = { + n["label"] + for e in r["edges"] + if e["relation"] == "references" and e["source"] == area_nid + for n in r["nodes"] + if n["id"] == e["target"] + } + self.assertIn("Double", ref_targets) + + def test_protocol_and_conformer_methods_are_distinct_nodes(self): + r = self._extract( + "protocol Drawable {\n" + " func draw()\n" + "}\n\n" + "struct Circle: Drawable {\n" + " func draw() {}\n" + "}\n" + ) + proto_nid = next(n["id"] for n in r["nodes"] if n["label"] == "Drawable") + circle_nid = next(n["id"] for n in r["nodes"] if n["label"] == "Circle") + proto_draw = { + e["target"] for e in r["edges"] + if e["relation"] == "method" and e["source"] == proto_nid + } + circle_draw = { + e["target"] for e in r["edges"] + if e["relation"] == "method" and e["source"] == circle_nid + } + self.assertTrue(proto_draw and circle_draw) + self.assertTrue( + proto_draw.isdisjoint(circle_draw), + "protocol requirement and conformer method collapsed onto one node", + ) + # The conformance heritage edge is untouched. + self.assertTrue( + any( + e["relation"] == "implements" + and e["source"] == circle_nid + and e["target"] == proto_nid + for e in r["edges"] + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terraform.py b/tests/test_terraform.py index 2049078b3f..b01b35cde5 100644 --- a/tests/test_terraform.py +++ b/tests/test_terraform.py @@ -156,3 +156,206 @@ def test_tfvars_key_value_is_safe(tmp_path): r = extract_terraform(_write(tmp_path, "terraform.tfvars", 'region = "us-east-1"\nenv = "prod"\n')) assert r.get("error") is None assert len(r["nodes"]) == 1 # only the file node, no variable nodes + + +def test_terraform_attributes_primitive_literals(tmp_path): + body = """\ +resource "aws_s3_bucket" "example" { + bucket = "my-test-bucket" + force_destroy = true + versioning = false + max_size = 42 + ratio = 3.14 + logging = null +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + node = next(n for n in r["nodes"] if n["label"] == "aws_s3_bucket.example") + attrs = node.get("attributes") + assert attrs is not None + assert attrs["bucket"] == "my-test-bucket" + assert attrs["force_destroy"] is True + assert attrs["versioning"] is False + assert attrs["max_size"] == 42 + assert attrs["ratio"] == 3.14 + assert attrs["logging"] is None + + +def test_terraform_attributes_collections(tmp_path): + body = """\ +resource "aws_security_group" "sg" { + name = "sg" + tags = { Environment = "production", ManagedBy = "terraform" } + ingress_cidrs = ["10.0.0.0/16", "192.168.1.0/24"] +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + node = next(n for n in r["nodes"] if n["label"] == "aws_security_group.sg") + attrs = node.get("attributes") + assert attrs is not None + assert attrs["name"] == "sg" + assert attrs["tags"] == {"Environment": "production", "ManagedBy": "terraform"} + assert attrs["ingress_cidrs"] == ["10.0.0.0/16", "192.168.1.0/24"] + + +def test_terraform_attributes_expressions_and_references(tmp_path): + body = """\ +variable "bucket_name" { default = "my-bucket" } +data "aws_ami" "ubuntu" { most_recent = true } + +resource "aws_s3_bucket" "example" { + bucket = var.bucket_name + ami = data.aws_ami.ubuntu.id +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + bucket_node = next(n for n in r["nodes"] if n["label"] == "aws_s3_bucket.example") + attrs = bucket_node.get("attributes") + assert attrs is not None + assert attrs["bucket"] == "var.bucket_name" + assert attrs["ami"] == "data.aws_ami.ubuntu.id" + + # Verify existing reference edges remain intact + refs = _rel_pairs(r, "references") + assert ("aws_s3_bucket.example", "var.bucket_name") in refs + assert ("aws_s3_bucket.example", "data.aws_ami.ubuntu") in refs + + +def test_terraform_multiple_attributes_and_nested_block_isolation(tmp_path): + body = """\ +resource "aws_instance" "web" { + ami = "ami-12345" + instance_type = "t3.micro" + lifecycle { + prevent_destroy = true + } +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + node = next(n for n in r["nodes"] if n["label"] == "aws_instance.web") + attrs = node.get("attributes") + assert attrs is not None + assert attrs["ami"] == "ami-12345" + assert attrs["instance_type"] == "t3.micro" + # Ownership rule: only direct attributes of the block are captured. + # Nested blocks (e.g. lifecycle) must NOT be flattened into parent attributes. + assert "prevent_destroy" not in attrs + assert "lifecycle" not in attrs + + +def test_terraform_attributes_query_and_search_discovery(tmp_path): + from graphify.serve import _query_graph_text + + body = """\ +resource "aws_s3_bucket" "prod" { + bucket = "company-prod-assets" + force_destroy = true + engine_version = "15.4" +} + +resource "aws_s3_bucket" "backup" { + bucket = "company-backup-assets" + force_destroy = false + engine_version = "14.2" +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + G = build_from_json(r) + + # 1. Attribute name contributes to searchability + res_key = _query_graph_text(G, "force_destroy", depth=1) + assert "aws_s3_bucket.prod" in res_key + assert "aws_s3_bucket.backup" in res_key + + # 2. Attribute literal value contributes to retrieval + res_val = _query_graph_text(G, "company-prod-assets", depth=1) + assert "aws_s3_bucket.prod" in res_val + + # 3. Attribute name contributes to searchability + res_engine = _query_graph_text(G, "engine_version", depth=1) + assert "aws_s3_bucket.prod" in res_engine + + # 4. Verified node retrieval and serialized attribute map content + prod_node = next(n for n in r["nodes"] if n["label"] == "aws_s3_bucket.prod") + assert prod_node["attributes"]["force_destroy"] is True + assert prod_node["attributes"]["engine_version"] == "15.4" + + +def test_terraform_attributes_context_rendering(tmp_path): + from graphify.serve import _subgraph_to_text + + body = """\ +resource "aws_s3_bucket" "example" { + bucket = "var.bucket_name" + force_destroy = true + tags = { + Environment = "production" + } +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + G = build_from_json(r) + node_id = next(n["id"] for n in r["nodes"] if n["label"] == "aws_s3_bucket.example") + + rendered = _subgraph_to_text(G, {node_id}, set(), token_budget=2000) + assert "attrs={" in rendered + assert 'bucket="var.bucket_name"' in rendered + assert "force_destroy=true" in rendered + assert '"Environment": "production"' in rendered + + +def test_terraform_attributes_round_trip_serialization(tmp_path): + from graphify.export import to_json + from graphify.serve import _load_graph + + body = """\ +resource "aws_s3_bucket" "example" { + bucket = "my-bucket" + force_destroy = true +} +""" + r = extract_terraform(_write(tmp_path, "main.tf", body)) + G = build_from_json(r) + out_file = tmp_path / "graph.json" + to_json(G, {}, str(out_file), force=True) + loaded_G = _load_graph(str(out_file)) + node_id = next(n["id"] for n in r["nodes"] if n["label"] == "aws_s3_bucket.example") + assert loaded_G.nodes[node_id].get("attributes") == {"bucket": "my-bucket", "force_destroy": True} + + +def test_terraform_sensitive_attribute_values_are_redacted(tmp_path): + """Attribute values are persisted to graph.json and surfaced to the model, + so a hardcoded credential must not leak: the VALUE of a secret-named key is + redacted while the key stays visible, and non-secret keys are untouched + (#3644 security follow-up). Redaction recurses into map values too.""" + body = """\ +resource "aws_db_instance" "main" { + identifier = "prod-db" + instance_class = "db.t3.medium" + password = "hunter2-super-secret" + db_password = "another-secret" + aws_secret_access_key = "AKIAWHATEVER" + tags = { + Name = "prod" + client_secret = "leaky" + } +} +""" + r = extract_terraform(_write(tmp_path, "db.tf", body)) + node = next(n for n in r["nodes"] if n["label"] == "aws_db_instance.main") + attrs = node["attributes"] + # non-secret keys pass through unchanged + assert attrs["identifier"] == "prod-db" + assert attrs["instance_class"] == "db.t3.medium" + # secret-named keys keep their key but redact the value + assert attrs["password"] == "[redacted]" + assert attrs["db_password"] == "[redacted]" + assert attrs["aws_secret_access_key"] == "[redacted]" + # nested map: the secret is redacted, the ordinary key is not + assert attrs["tags"]["Name"] == "prod" + assert attrs["tags"]["client_secret"] == "[redacted]" + # the literal secret must appear nowhere in the serialized node + import json as _json + assert "hunter2-super-secret" not in _json.dumps(node) + assert "AKIAWHATEVER" not in _json.dumps(node) + assert "leaky" not in _json.dumps(node) diff --git a/tests/test_ts_import_type_arguments.py b/tests/test_ts_import_type_arguments.py index 1bf6db2a8a..d77d87f4d1 100644 --- a/tests/test_ts_import_type_arguments.py +++ b/tests/test_ts_import_type_arguments.py @@ -303,6 +303,7 @@ def test_ts_normalizer_scales_linearly_on_large_files(): flaking the ratio. CPU time counts only work actually done, so it isolates the algorithmic scaling regardless of load. """ + import gc import time def build(n: int) -> bytes: @@ -314,17 +315,30 @@ def build(n: int) -> bytes: def timed(n: int) -> float: source = build(n) - start = time.process_time() - _normalize_ts_import_types(source) - return time.process_time() - start - - timed(200) # warm the grammar/parser import off the measured path - small = min(timed(1000) for _ in range(5)) - large = min(timed(2000) for _ in range(5)) - - # Linear work doubles (~2x). Quadratic work quadruples (~4x). A generous - # 3x ceiling separates the two without being flaky under load. - assert large < small * 3, ( + # A GC pause lands in process_time and, on a small measurement, inflates + # the ratio enough to flake (#3154 CI hit 3.2x on a noisy runner). Disable + # it for the measured section so we time only the normalizer's own work. + gc_was_enabled = gc.isenabled() + gc.disable() + try: + start = time.process_time() + _normalize_ts_import_types(source) + return time.process_time() - start + finally: + if gc_was_enabled: + gc.enable() + + # Measure at larger sizes than the smoke case: at ~1k the per-call fixed + # overhead is a big fraction of the tiny (~0.03s) measurement, so the ratio + # is noisy; at 4k/8k the O(n) work dominates and linear scaling converges + # toward a clean ~2x. min() over repeats takes the least-preempted run. + timed(500) # warm the grammar/parser import off the measured path + small = min(timed(4000) for _ in range(5)) + large = min(timed(8000) for _ in range(5)) + + # Linear work doubles (~2x). Quadratic work quadruples (~4x). A 3.5x ceiling + # separates the two with enough headroom to survive CI scheduling noise. + assert large < small * 3.5, ( f"scaling looks super-linear: {small:.4f}s -> {large:.4f}s " f"({large / small:.1f}x for 2x input)" ) diff --git a/tests/test_watch.py b/tests/test_watch.py index a056538f44..7e1b01b58d 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3514,6 +3514,144 @@ def test_incremental_rebuild_preserves_python_call_to_unchanged_target(tmp_path) assert sorted(_2406_calls(_2406_graph(corpus))) == sorted(full) +# --- Rust generic self calls into unchanged impls --------------------------- + +_RUST_GENERIC_STATE = "pub struct Bucket { value: T }\n" +_RUST_GENERIC_METHOD = ( + "impl Bucket {\n" + " pub fn fetch_value(&self) {}\n" + "}\n" +) +_RUST_GENERIC_CALLER = ( + "impl Bucket {\n" + " pub fn run(&self) {\n%s self.fetch_value();\n" + " }\n" + "}\n" +) + + +def _rust_generic_seed(tmp_path): + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir(parents=True) + (corpus / "state.rs").write_text(_RUST_GENERIC_STATE, encoding="utf-8") + (corpus / "method.rs").write_text(_RUST_GENERIC_METHOD, encoding="utf-8") + (corpus / "caller.rs").write_text( + _RUST_GENERIC_CALLER % "", encoding="utf-8" + ) + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + return corpus + + +def _rust_generic_call(graph): + caller = _2406_nid(graph, ".run()", "caller.rs") + callee = _2406_nid(graph, ".fetch_value()", "method.rs") + return (caller, callee) in _2406_calls(graph) + + +def test_incremental_rust_generic_self_call_uses_unchanged_impl_context(tmp_path): + """A changed generic caller retains its call into an unchanged impl block.""" + from graphify.watch import _rebuild_code + + corpus = _rust_generic_seed(tmp_path) + assert _rust_generic_call(_2406_graph(corpus)) + + caller = corpus / "caller.rs" + caller.write_text( + _RUST_GENERIC_CALLER % " let marker = 1;\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert _rust_generic_call(_2406_graph(corpus)) + + +def test_incremental_rust_generic_self_call_legacy_marker_fails_closed(tmp_path): + """A pre-marker graph does not guess; re-extraction restores the edge.""" + from graphify.watch import _rebuild_code + + corpus = _rust_generic_seed(tmp_path) + graph_path = corpus / "graphify-out" / "graph.json" + legacy = _2406_graph(corpus) + assert any(node.get("_rust_impl_key") for node in legacy["nodes"]) + for node in legacy["nodes"]: + node.pop("_rust_impl_key", None) + graph_path.write_text(json.dumps(legacy), encoding="utf-8") + + caller = corpus / "caller.rs" + caller.write_text( + _RUST_GENERIC_CALLER % " let marker = 1;\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert not _rust_generic_call(_2406_graph(corpus)) + + state = corpus / "state.rs" + method = corpus / "method.rs" + state.write_text(_RUST_GENERIC_STATE + "// refreshed\n", encoding="utf-8") + method.write_text(_RUST_GENERIC_METHOD + "// refreshed\n", encoding="utf-8") + assert _rebuild_code( + corpus, + changed_paths=[state, method, caller], + no_cluster=True, + acquire_lock=False, + ) is True + assert _rust_generic_call(_2406_graph(corpus)) + + +def test_incremental_rust_generic_self_call_keeps_module_ambiguity(tmp_path): + """A collapsed same-file declaration count survives context projection.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "state.rs").write_text( + "pub mod a { pub struct Bucket(pub T); }\n" + "pub mod b { pub struct Bucket(pub T); }\n", + encoding="utf-8", + ) + (corpus / "a_impl.rs").write_text( + "impl Bucket { pub fn fetch_value(&self) {} }\n", + encoding="utf-8", + ) + (corpus / "fallback.rs").write_text( + "pub trait Fallback { fn fetch_value(&self) {} }\n", + encoding="utf-8", + ) + caller = corpus / "b_impl.rs" + caller.write_text( + "impl Fallback for Bucket {}\n" + "impl Bucket { pub fn run(&self) { self.fetch_value(); } }\n", + encoding="utf-8", + ) + + def has_call(): + graph = _2406_graph(corpus) + run_id = _2406_nid(graph, ".run()", "b_impl.rs") + return any( + edge.get("relation") == "calls" and edge.get("source") == run_id + for edge in graph.get("links", graph.get("edges", [])) + ) + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + assert not has_call() + caller.write_text( + "impl Fallback for Bucket {}\n" + "impl Bucket {\n" + " pub fn run(&self) { let marker = 1; self.fetch_value(); }\n" + "}\n", + encoding="utf-8", + ) + assert _rebuild_code( + corpus, changed_paths=[caller], no_cluster=True, acquire_lock=False + ) is True + assert not has_call() + + # --- #3567: inherited Ruby calls into unchanged ancestry -------------------- @@ -4027,11 +4165,13 @@ def _ast_reference(source, target, source_file, **extra): } -def test_markdown_reconcile_links_new_source_to_semantic_target(tmp_path): +@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"]) +def test_markdown_reconcile_links_new_source_to_semantic_target(tmp_path, suffix): """#1915/#1954: a fresh source reaches a semantic-only target.""" + source_file = f"a{suffix}" corpus, graph_path = _markdown_reconcile_fixture( tmp_path, - {"a.md": "[link](b.md)\n", "b.md": "target\n"}, + {source_file: "[link](b.md)\n", "b.md": "target\n"}, [_semantic_doc("b_sem", "b.md")], [], ) @@ -4085,15 +4225,100 @@ def test_markdown_reconcile_preserves_ambiguous_source(tmp_path): assert references[0]["sentinel"] == "keep" -def test_markdown_reconcile_prunes_removed_authored_link(tmp_path): +@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"]) +def test_markdown_reconcile_prunes_removed_authored_link(tmp_path, suffix): """#1915/#1954: removing a Markdown link removes its owned AST edge.""" + source_file = f"a{suffix}" corpus, graph_path = _markdown_reconcile_fixture( tmp_path, - {"a.md": "no link\n", "b.md": "target\n"}, - [_semantic_doc("a_sem", "a.md"), _semantic_doc("b_sem", "b.md")], - [_ast_reference("a_sem", "b_sem", "a.md")], + {source_file: "no link\n", "b.md": "target\n"}, + [_semantic_doc("a_sem", source_file), _semantic_doc("b_sem", "b.md")], + [_ast_reference("a_sem", "b_sem", source_file)], + ) + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + links = json.loads(graph_path.read_text(encoding="utf-8"))["links"] + assert not any(edge.get("relation") == "references" for edge in links) + + +@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"]) +def test_markdown_reconcile_repoints_incremental_link_to_semantic_target( + tmp_path, suffix +): + """A changed Markdown source reuses the target's semantic representative.""" + source_file = f"a{suffix}" + corpus, graph_path = _markdown_reconcile_fixture( + tmp_path, + {source_file: "[link](b.md)\n", "b.md": "target\n"}, + [ + { + "id": "a", + "label": source_file, + "node_kind": "page", + "file_type": "document", + "source_file": source_file, + "source_location": "L1", + "_origin": "ast", + }, + _semantic_doc("b_sem", "b.md"), + ], + [], + ) + + for _ in range(2): + assert _rebuild_code( + corpus, + changed_paths=[corpus / source_file], + no_cluster=True, + acquire_lock=False, + ) is True + links = json.loads(graph_path.read_text(encoding="utf-8"))["links"] + references = [edge for edge in links if edge.get("relation") == "references"] + assert len(references) == 1 + assert {references[0]["source"], references[0]["target"]} == {"a", "b_sem"} + + +@pytest.mark.parametrize("suffix", [".md", ".mdx", ".qmd", ".skill"]) +def test_markdown_reconcile_preserves_links_on_extraction_error( + tmp_path, monkeypatch, suffix +): + """A failed parse cannot claim ownership of persisted authored links.""" + import graphify.extract as extract_module + + source_file = f"a{suffix}" + corpus, graph_path = _markdown_reconcile_fixture( + tmp_path, + {source_file: "[link](b.md)\n", "b.md": "target\n"}, + [_semantic_doc("a_sem", source_file), _semantic_doc("b_sem", "b.md")], + [_ast_reference("a_sem", "b_sem", source_file, sentinel="keep")], + ) + source_path = (corpus / source_file).resolve() + real_extract = extract_module._safe_extract_with_xaml_root + fail_source = True + + def controlled_extract(extractor, path, root): + if fail_source and path.resolve() == source_path: + return {"nodes": [], "edges": [], "error": "simulated read failure"} + return real_extract(extractor, path, root) + + monkeypatch.setattr( + extract_module, "_safe_extract_with_xaml_root", controlled_extract ) + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + links = json.loads(graph_path.read_text(encoding="utf-8"))["links"] + references = [edge for edge in links if edge.get("relation") == "references"] + assert len(references) == 1 + assert references[0]["sentinel"] == "keep" + + fail_source = False + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + links = json.loads(graph_path.read_text(encoding="utf-8"))["links"] + references = [edge for edge in links if edge.get("relation") == "references"] + assert len(references) == 1 + assert {references[0]["source"], references[0]["target"]} == {"a_sem", "b_sem"} + + (corpus / source_file).write_text("no link\n", encoding="utf-8") assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True links = json.loads(graph_path.read_text(encoding="utf-8"))["links"] assert not any(edge.get("relation") == "references" for edge in links) @@ -4489,3 +4714,221 @@ def test_clustered_rebuild_survives_a_permission_error_on_replace(tmp_path, monk graph_path = corpus / "graphify-out" / "graph.json" labels = {n["label"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]} assert "added()" in labels, "the fallback must still land the new content" + + +# ── #3695: fail-closed preserved nodes survive AST ownership & shrink guard ─── + + +def test_full_rebuild_preserves_alive_source_removed_from_detected_corpus( + tmp_path, monkeypatch, capsys +): + """#3695 invariant 1: An alive source removed from the detected corpus survives + a full rebuild under fail-closed preservation.""" + from graphify import detect as detect_mod + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "a.py").write_text("def alpha():\n return 1\n", encoding="utf-8") + (corpus / "b.py").write_text("def beta():\n return 2\n", encoding="utf-8") + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + data = json.loads(graph_path.read_text(encoding="utf-8")) + nodes_before = {n["id"]: n for n in data["nodes"]} + assert any(n.get("source_file") == "b.py" for n in nodes_before.values()) + + # Simulate b.py being dropped from detected corpus while remaining alive on disk + real_detect = detect_mod.detect + + def narrowed(root, **kwargs): + res = real_detect(root, **kwargs) + res["files"]["code"] = [ + f for f in res["files"]["code"] if not str(f).endswith("b.py") + ] + return res + + monkeypatch.setattr(detect_mod, "detect", narrowed) + capsys.readouterr() + + # Explicit full rebuild (changed_paths=None) + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + out = capsys.readouterr().out + assert "fail-closed: kept" in out + + after = json.loads(graph_path.read_text(encoding="utf-8")) + sources = {n.get("source_file") for n in after["nodes"]} + assert "b.py" in sources + labels = {n.get("label") for n in after["nodes"]} + assert "beta()" in labels + + +def test_ast_ownership_does_not_evict_fail_closed_preserved_node( + tmp_path, monkeypatch, capsys +): + """#3695 invariant 1 & 2: AST ownership pass must not evict AST-tier nodes + belonging to a source that fail-closed explicitly preserved, even when full_rebuild + is True and AST ownership filtering evaluates.""" + from graphify import detect as detect_mod + from graphify.watch import _rebuild_code + import graphify.watch as watch_mod + + corpus = tmp_path / "corpus" + corpus.mkdir() + worker = corpus / "worker.py" + worker.write_text( + "def work():\n pass\n\nclass Job:\n pass\n", encoding="utf-8" + ) + (corpus / "main.py").write_text("def start():\n pass\n", encoding="utf-8") + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + data = json.loads(graph_path.read_text(encoding="utf-8")) + worker_ast_ids = { + n["id"] for n in data["nodes"] if n.get("source_file") == "worker.py" + } + assert len(worker_ast_ids) >= 2 + + # Drop worker.py from scan corpus while alive on disk + real_detect = detect_mod.detect + + def narrowed(root, **kwargs): + res = real_detect(root, **kwargs) + res["files"]["code"] = [ + f for f in res["files"]["code"] if not str(f).endswith("worker.py") + ] + return res + + monkeypatch.setattr(detect_mod, "detect", narrowed) + + # When worker.py is evaluated against AST ownership eviction (e.g. from an + # unlinked/replaced file event in deleted_source_identities): + worker_id = Path(os.path.abspath(worker)).as_posix() + real_reconcile = watch_mod._reconcile_existing_graph + + def reconcile_with_eviction(existing_graph, result, **kwargs): + kwargs["deleted_source_identities"] = set(kwargs.get("deleted_source_identities", ())) | {worker_id} + return real_reconcile(existing_graph, result, **kwargs) + + monkeypatch.setattr(watch_mod, "_reconcile_existing_graph", reconcile_with_eviction) + capsys.readouterr() + + # Trigger full rebuild: AST ownership pass runs with full_rebuild=True + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + after = json.loads(graph_path.read_text(encoding="utf-8")) + after_ids = {n["id"] for n in after["nodes"]} + for ast_id in worker_ast_ids: + assert ast_id in after_ids, ( + f"AST node {ast_id} was evicted by AST ownership despite fail-closed" + ) + assert "fail-closed: kept" in capsys.readouterr().out + + +def test_reextracted_source_still_evicts_stale_ast_nodes(tmp_path): + """#3695 invariant 2: Genuine re-extracted sources must retain existing AST + ownership behavior (#1116/#2333), evicting stale AST nodes.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "service.py").write_text( + "def old_handler():\n pass\ndef permanent():\n pass\n", + encoding="utf-8", + ) + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + ids_before = { + n["id"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"] + } + assert "service_old_handler" in ids_before + + # Modify service.py removing old_handler and adding new_handler + (corpus / "service.py").write_text( + "def new_handler():\n pass\ndef permanent():\n pass\n", + encoding="utf-8", + ) + + # Full rebuild: re-extracts service.py + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + ids_after = { + n["id"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"] + } + assert "service_new_handler" in ids_after + assert "service_old_handler" not in ids_after, ( + "stale AST node from re-extracted source must be evicted (#1116/#2333)" + ) + + +def test_shrink_involving_fail_closed_sources_does_not_deadlock_shrink_guard( + tmp_path, monkeypatch, capsys +): + """#3695 invariant 3: When a shrink occurs on a rebuild that also has fail-closed + sources, the shrink guard does not deadlock with 'Refusing to overwrite'.""" + from graphify import detect as detect_mod + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + # a.py has 3 functions, b.py has 1 function + (corpus / "a.py").write_text( + "def f1(): pass\ndef f2(): pass\ndef f3(): pass\n", encoding="utf-8" + ) + (corpus / "b.py").write_text("def helper(): pass\n", encoding="utf-8") + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + initial_count = len(json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]) + + # Now shrink a.py: remove 2 functions (net shrink from initial_count) + (corpus / "a.py").write_text("def f1(): pass\n", encoding="utf-8") + + # And simulate b.py leaving the detected corpus while alive + real_detect = detect_mod.detect + + def narrowed(root, **kwargs): + res = real_detect(root, **kwargs) + res["files"]["code"] = [ + f for f in res["files"]["code"] if not str(f).endswith("b.py") + ] + return res + + monkeypatch.setattr(detect_mod, "detect", narrowed) + capsys.readouterr() + + # Full rebuild: net shrink occurs (a.py lost nodes), and b.py is fail-closed. + # Must NOT deadlock on shrink guard! + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + data = json.loads(graph_path.read_text(encoding="utf-8")) + new_count = len(data["nodes"]) + assert new_count < initial_count + # b.py's helper() node survived + labels = {n.get("label") for n in data["nodes"]} + assert "helper()" in labels + assert "f1()" in labels + assert "f2()" not in labels + +def test_requires_symlinks_rebuild_symlink_worker(requires_symlinks, tmp_path, capsys): + """Exercise true OS symlinks for #3695 on platforms supporting them.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + + fpm_worker = corpus / "plugins" / "fpm-core" / "services" / "hub" / "worker.py" + fpm_worker.parent.mkdir(parents=True) + fpm_worker.write_text( + "def run_worker():\n pass\n\ndef process_task():\n pass\n", + encoding="utf-8", + ) + hub_worker = corpus / "services" / "hub" / "worker.py" + hub_worker.parent.mkdir(parents=True) + hub_worker.symlink_to(fpm_worker) + + app = corpus / "app.py" + app.write_text("def main():\n pass\n", encoding="utf-8") + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + data = json.loads(graph_path.read_text(encoding="utf-8")) + labels = {n.get("label") for n in data["nodes"]} + assert "run_worker()" in labels diff --git a/uv.lock b/uv.lock index 9615327592..fd04b1bea5 100644 --- a/uv.lock +++ b/uv.lock @@ -1096,7 +1096,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.63" +version = "0.9.65" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1150,6 +1150,7 @@ all = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "openai" }, { name = "openpyxl" }, + { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pypdf" }, { name = "python-docx" }, @@ -1240,6 +1241,7 @@ sql = [ svg = [ { name = "matplotlib" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "pillow" }, ] terraform = [ { name = "tree-sitter-hcl" }, @@ -1311,6 +1313,8 @@ requires-dist = [ { name = "openpyxl", marker = "extra == 'all'" }, { name = "openpyxl", marker = "extra == 'google'" }, { name = "openpyxl", marker = "extra == 'office'" }, + { name = "pillow", marker = "extra == 'all'", specifier = ">=12.3.0" }, + { name = "pillow", marker = "extra == 'svg'", specifier = ">=12.3.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'all'" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'" }, { name = "pypdf", marker = "extra == 'all'", specifier = ">=6.16.1" }, @@ -2777,100 +2781,96 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] diff --git a/worked/rsl-siege-manager/README.md b/worked/rsl-siege-manager/README.md index b3d5b243b9..76c26a240a 100644 --- a/worked/rsl-siege-manager/README.md +++ b/worked/rsl-siege-manager/README.md @@ -83,6 +83,6 @@ rsl-siege-manager is structurally interesting for graphify evaluation because: ## Reference -- graphify repo: https://github.com/safishamsi/graphify +- graphify repo: https://github.com/Graphify-Labs/graphify - graphify PyPI: https://pypi.org/project/graphifyy/ - rsl-siege-manager: https://github.com/glitchwerks/rsl-siege-manager