diff --git a/HISTORY.rst b/HISTORY.rst index 2bd20b2..dabc10a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,9 +2,85 @@ History ======= -1.3.0 (2026-10-17) +1.4.0 (2026-10-21) ------------------ +Two guards and a way to read a schema back. Nothing in a dataset changes +for a given seed. + +The guards are both about not destroying something quietly. A dataset +directory could become the union of two runs, and ``schema draft`` could +write over a schema you had edited. Neither announced itself: the first was +reported as "the draft generates badly but validate is happy", which is +exactly what it looks like from outside. + +* Two dependency advisories. ``certifi`` moves to 2026.10.19 for + PYSEC-2026-4320: earlier bundles trust ``emSign ECC Root CA - G3``, and + eight eMudhra intermediates chaining to it have compromised private keys, + so somebody holding one of those keys and sitting on the path can present + a valid-looking certificate for any hostname. Every HTTPS call the package + makes verifies against that bundle, which is the wiki fetcher, the BTS zip + the flights fetcher downloads and the Overpass queries behind the OSM one, + so it is a direct dependency now rather than whatever ``requests`` brings. + + ``pillow`` moves to 13.0.0 in the ``examples`` extra for eight + image-parsing CVEs. Nothing in GraphFaker opens an image: Pillow is there + because matplotlib draws the notebooks' plots, so none of it is reachable + from the package itself. The floor carries a ``python_version >= '3.11'`` + marker, because Pillow 13 needs 3.11 and the package supports 3.10, and + keeping the extra installable there is worth more than forcing the choice. + +* ``graphfaker validate --verbose`` reads a schema back to you. + ``GraphSchema.describe()`` renders the thing somebody holding a few + hundred lines of drafted YAML actually needs, and it is not a + pretty-printer for the file: each attribute carries its sampler and the + sampler's parameters, because a gaussian where the column means money is + the commonest thing wrong with a written schema and a gaussian amount has + no tail; each edge family carries its budget **divided by its source + rows**, because a schema states a share of a total and nothing in the file + says whether that works out to one payment per purchase; and the latent + factor gets a line even when there is none, because a schema without one + is a pile of independent columns. + + That middle number is the one that earns the flag. ``0.80 per Admission`` + means a fifth of admissions land in no ward, and ``0.60 per Patient`` on a + functional relationship means 40% of patients have no primary physician. + Neither is visible in the YAML, and on a drafted customs schema the same + arithmetic was the entire finding. The summary lives on the schema rather + than in the CLI so a notebook can use it, which is where a draft actually + gets picked apart, and it judges nothing: the same contract the hardness + and coverage reports have. + +* ``graphfaker schema draft`` will not write over an existing file. + A drafted schema is meant to be read and edited, and a second draft of + the same sentence is a different schema, so replacing one silently throws + away the editing that is the point of the file. Checked before the model + is asked, so a mistaken rerun costs nothing; ``--force`` overwrites. If a + dataset sits in the directory named after the schema, which is the + pairing people make, it says so rather than doing anything about it. + +* A dataset directory can no longer become the union of two runs. The + write is per table and removed nothing, so generating a second dataset + into the same ``--out`` left both: the manifest said two node types and + the folder held four, and every loader reads the directory rather than + the manifest, so it read one graph made of two. ``GraphRun.write`` + refuses when the directory holds tables the run does not replace, names + them, and writes nothing; ``--force`` on ``generate`` and ``fraud`` + (``force=True`` from Python) replaces the dataset instead. + + It only comes up when the set of tables changes, which means a renamed + or removed node type or a different schema entirely, so regenerating the + same schema is unaffected and needs no flag. Only ``nodes/``, ``edges/`` + and ``truth/`` are considered and only ``.parquet`` in them, so a + directory holding your own files is neither reported on nor touched. + + Reported by a user who drafted twice into the same name and found the + result generated badly while ``validate`` was happy, which is exactly + what this looks like from outside: the schema file was fine and the + directory was not. + +1.3.0 (2026-10-17) +------------------ The schema debt paid: what a domain pack does over time is a declaration the engine runs, rather than three Python files answering the same three questions with different constants. A coverage report that says what a dataset actually diff --git a/README.md b/README.md index 261b86e..f0640bf 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ Both commands write `nodes/`, `edges/`, `truth/`, `schema.yaml` and `manifest.js | `--coverage` | print what the dataset contains: every shape in the pack's catalogue, how many of each, the roles their members filled, where the pattern events sit in the ordinary process, which parts of the population were recruited, and each shape against the decoy that imitates it | | `--json` | when done, print the manifest (and for `fraud`, the hardness, realism and coverage reports) as one JSON document on stdout. Logging goes to stderr, so a pipeline can read stdout | -Three commands look at datasets and schema files without generating anything. `graphfaker inspect ./bank` reads the manifest and the file layout and prints what a directory contains (schema, seed, versions, counts, which truth tables are there, the domain options), with `--json` for scripts. `graphfaker validate my_graph.yaml other.yaml` checks schema files with the same rules `generate --schema` applies and exits non-zero if any fails. `graphfaker evaluate ./bank --accounts flagged.txt --json` scores a detector's output as JSON. +Three commands look at datasets and schema files without generating anything. `graphfaker inspect ./bank` reads the manifest and the file layout and prints what a directory contains (schema, seed, versions, counts, which truth tables are there, the domain options), with `--json` for scripts. `graphfaker validate my_graph.yaml other.yaml` checks schema files with the same rules `generate --schema` applies and exits non-zero if any fails; `--verbose` reads a schema back to you, with each attribute's sampler and each edge family's budget divided by its source rows, which is the number that says whether every purchase has a payment. `graphfaker evaluate ./bank --accounts flagged.txt --json` scores a detector's output as JSON. ### What the options mean diff --git a/docs/reference/api.rst b/docs/reference/api.rst index bd84bc1..0964ad7 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -33,6 +33,9 @@ Engine .. automodule:: graphfaker.engine.seeding :members: Streams +.. automodule:: graphfaker.schema.samplers + :members: describe_sampler, clock_dependent + .. automodule:: graphfaker.schema.process :members: TimingModel, HourProfile, HourWindow, QuarterEndBoost, MonthlySchedule, Lognormal, Mixture, Rounding, ActorMix, ChannelSpec, ProcessSpec diff --git a/docs/semantic-layer.md b/docs/semantic-layer.md index a88e5ca..25950e9 100644 --- a/docs/semantic-layer.md +++ b/docs/semantic-layer.md @@ -64,7 +64,9 @@ drafted 'hospital' with anthropic (claude-opus-5-5) read it before you generate from it: a drafted schema is a first guess, not an answer ``` -The YAML goes to the file (or to stdout with no `--out`, so it can be piped); everything else goes to stderr. Then it is an ordinary schema: +The YAML goes to the file (or to stdout with no `--out`, so it can be piped); everything else goes to stderr. It will not write over a file that already exists: a draft is meant to be edited, and a second draft of the same sentence is a different schema, so replacing one would throw away your changes. Pass `--force` when that is what you want, and it is checked before the model is asked, so a mistaken rerun costs nothing. + +Then it is an ordinary schema: ```sh graphfaker validate hospital.yaml @@ -81,6 +83,32 @@ The draft is validated before you see it. If it does not parse as a `GraphSchema After a second failure it stops, exits non-zero, and prints the last attempt so you can see what it was reaching for. It does not write a file. A schema that does not validate is an error, not a dataset. +## Reading a draft back + +A drafted schema is a few hundred lines of YAML, and `--verbose` reads it back to you: + +```sh +graphfaker validate hospital.yaml --verbose +``` + +``` +hospital.yaml: ok, schema 'hospital', 4 node types, 11 relationship types, 10,340 nodes and 24,000 edges, digest aab96a5d79 + latent department x10: specialty category(8), mean_stay uniform(2, 12), mean_age uniform(8, 72) + + Patient 4,000 name faker(name), age gaussian(@department.mean_age, 16), sex category(2), + blood_type category(8), city faker(city), phone faker(phone_number) + Admission 6,000 patient -> Patient, ward -> Ward, attending -> Doctor, admit_date + faker(date_time_between), total_cost expression, outcome category(4) + + Admission -> Patient 6,000 1.00 per Admission ADMISSION_OF* + Admission -> Ward 4,800 0.80 per Admission ADMITTED_TO* + Patient -> Doctor 2,400 0.60 per Patient TREATED_BY, PRIMARY_PHYSICIAN* + + * functional: a node has at most one of these +``` + +Three things are there on purpose. Each attribute carries its sampler and the sampler's parameters, because a gaussian where the column means money is the commonest thing wrong with a written schema. Each edge family carries its budget **divided by its source rows**, because a schema states a share of a total and nothing in the file says whether that works out to one payment per purchase: `0.80 per Admission` means a fifth of admissions land in no ward, and `0.60 per Patient` on a functional relationship means 40% of patients have no primary physician. And the latent factor gets a line even when there is none, because a schema without one is a pile of independent columns. + ## What to check in a draft The model is good at structure and unreliable about magnitude. In rough order of how often it is worth fixing: diff --git a/graphfaker/__init__.py b/graphfaker/__init__.py index 144cf02..592e59a 100644 --- a/graphfaker/__init__.py +++ b/graphfaker/__init__.py @@ -21,7 +21,7 @@ __author__ = """Dennis Irorere""" __email__ = "denironyx@gmail.com" -__version__ = "1.3.0" +__version__ = "1.4.0" #: public name -> the module it lives in _EXPORTS = { diff --git a/graphfaker/cli.py b/graphfaker/cli.py index 330b2a9..2b56906 100644 --- a/graphfaker/cli.py +++ b/graphfaker/cli.py @@ -278,13 +278,18 @@ def _dump(data: dict) -> None: typer.echo(json.dumps(data, indent=2, default=str)) -def _write_sink(run, out: str, sink: str, blind: bool = False) -> None: +def _write_sink(run, out: str, sink: str, blind: bool = False, force: bool = False) -> None: """Write the run as Parquet, then whatever extra layout ``sink`` asks for. ``blind`` keeps the ground truth out of a database sink (it is still written to ``truth/`` on disk), so the database can be handed to a - detector as an unbiased benchmark.""" - root = run.write(out) + detector as an unbiased benchmark. ``force`` replaces a dataset already + in the directory instead of refusing to half-overwrite it.""" + try: + root = run.write(out, force=force) + except FileExistsError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc logger.info("wrote %s (%s)", root, ", ".join(f"{k}={v}" for k, v in run.manifest.node_counts.items())) if sink == "parquet": return @@ -407,6 +412,7 @@ def generate( blind: bool = typer.Option(False, "--blind", help="Keep the ground truth out of the database sink (it is still written to truth/ on disk)."), quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress logging; warnings and errors only."), coverage: bool = typer.Option(False, "--coverage", help="Print what the dataset contains: shapes, roles, regimes, segments, decoy twins."), + force: bool = typer.Option(False, "--force", help="Replace a dataset already in --out. Without it, a write that would leave an earlier run's tables behind is refused."), as_json: bool = typer.Option(False, "--json", help="Print the manifest as JSON on stdout when done."), ): """Examples: @@ -427,7 +433,7 @@ def generate( loaded = _load_schema(schema) run = _generate(loaded, seed=seed, shard_size=shard_size or DEFAULT_SHARD_SIZE, workers=workers) - _write_sink(run, out, sink, blind) + _write_sink(run, out, sink, blind, force) if as_json: _dump(_with_coverage(run.manifest.model_dump(), run, coverage)) elif coverage: @@ -448,7 +454,7 @@ def generate( except ValidationError as exc: problems = "; ".join(f"--{'.'.join(str(p) for p in e['loc']).replace('_', '-')}: {e['msg']}" for e in exc.errors()) raise typer.BadParameter(f"{problems}. Run `graphfaker domains` to see the options.") from exc - _write_sink(run, out, sink, blind) + _write_sink(run, out, sink, blind, force) if as_json: _dump(_with_coverage(run.manifest.model_dump(), run, coverage, pack.patterns)) elif coverage: @@ -472,11 +478,17 @@ def _with_coverage(payload: dict, run, wanted: bool, catalog=None) -> dict: @app.command(short_help="Check schema YAML files without generating anything.") def validate( files: list[str] = typer.Argument(..., help="Schema YAML files, as written by `graphfaker schema` or by hand."), + verbose: bool = typer.Option( + False, "--verbose", "-v", + help="Read the schema back: node types with their samplers, edge families with the budget per source row, the latent factor.", + ), ): """Parse and validate each file with the same checks `generate --schema` applies, and say what it describes. Exits non-zero if any file fails. graphfaker validate my_graph.yaml other.yaml + + graphfaker validate hospital.yaml --verbose """ failed = False for path in files: @@ -490,6 +502,11 @@ def validate( f"{path}: ok, schema {loaded.name!r}, {len(loaded.nodes)} node types, {len(loaded.relationships())} relationship types, " f"{loaded.total_nodes:,} nodes and {loaded.total_edges:,} edges, digest {loaded.digest()}" ) + if verbose: + # The header line says how big it is; this says what it is, which + # is the question somebody holding a drafted schema has. + for line in loaded.describe()[1:]: + typer.echo(line) for line in _clock_warnings(loaded): typer.echo(line, err=True) if failed: @@ -685,6 +702,7 @@ def schema_command( model: str = typer.Option(None, "--model", help="draft only: the model to ask. Default $GRAPHFAKER_LLM_MODEL, or Anthropic's most capable."), base_url: str = typer.Option(None, "--base-url", help="draft only: an OpenAI-compatible endpoint, for a local model."), name: str = typer.Option(None, "--name", help="draft only: what to call the schema."), + force: bool = typer.Option(False, "--force", help="draft only: overwrite --out if it already exists."), ): """Examples: @@ -697,7 +715,7 @@ def schema_command( from graphfaker.domains import available, get if domain == "draft" and "draft" not in available(): - _draft_schema(list(ctx.args), out, provider, model, base_url, name) + _draft_schema(list(ctx.args), out, provider, model, base_url, name, force) return try: pack = get(domain) @@ -732,7 +750,7 @@ def schema_command( def _draft_schema( words: list[str], out: str | None, provider: str | None, model: str | None, - base_url: str | None, name: str | None, + base_url: str | None, name: str | None, force: bool = False, ) -> None: """``graphfaker schema draft ""``. @@ -749,6 +767,16 @@ def _draft_schema( raise typer.BadParameter( 'describe the graph you want: graphfaker schema draft "a hospital: patients, doctors, wards"' ) + # Checked before the model is asked, so a refusal costs nothing. A + # drafted schema is a file you are meant to read and edit, and a second + # draft of the same sentence is a different schema: overwriting one + # silently would throw away the editing that is the whole point. + if out is not None and Path(out).exists() and not force: + raise typer.BadParameter( + f"{out} already exists. A drafted schema is meant to be edited, and a second draft is a " + "different schema, so this would replace whatever you changed. Write to another file, or " + "pass --force." + ) try: client = get_provider(provider, model, base_url) except ProviderError as exc: @@ -778,9 +806,28 @@ def _draft_schema( else: Path(out).write_text(header + result.yaml, encoding="utf-8") typer.echo(f"wrote {out}", err=True) + for line in _stale_dataset_note(Path(out)): + typer.echo(line, err=True) typer.echo(result.summary(), err=True) +def _stale_dataset_note(schema_path: Path) -> list[str]: + """Point at a dataset generated from the schema this file used to hold. + + ``hospital.yaml`` and ``./hospital`` is the obvious pairing and the one + people make, so a redraft leaves a directory whose data no longer comes + from the file next to it. Nothing is touched; it only says so. + """ + candidate = schema_path.parent / schema_path.stem + manifest = candidate / "manifest.json" + if not manifest.is_file(): + return [] + return [ + f"note: {candidate} holds a dataset generated from the previous draft. " + f"Regenerate it with --force, or write this one somewhere else." + ] + + @app.command(short_help="Generate a fraud / AML transaction graph with labelled typologies.") def fraud( scale: float = typer.Option(0.001, help="1.0 = ~10M accounts / ~90M transactions (gen-fraud-graph convention)."), @@ -796,6 +843,7 @@ def fraud( report: bool = typer.Option(True, help="Print the hardness and realism reports."), blind: bool = typer.Option(False, "--blind", help="Keep the ground truth out of the database sink (it is still written to truth/ on disk)."), quiet: bool = typer.Option(False, "--quiet", "-q", help="No progress logging; warnings and errors only."), + force: bool = typer.Option(False, "--force", help="Replace a dataset already in --out."), as_json: bool = typer.Option(False, "--json", help="Print the manifest and the reports as one JSON document on stdout."), ): """Shortcut for `graphfaker generate fraud ...` that also prints the reports.""" @@ -805,7 +853,7 @@ def fraud( _quiet(quiet) run = get("fraud").run(seed=seed, workers=workers, scale=scale, hardness=hardness, period_days=period_days) - _write_sink(run, out, sink, blind) + _write_sink(run, out, sink, blind, force) if as_json: _dump({ "manifest": run.manifest.model_dump(), diff --git a/graphfaker/engine/run.py b/graphfaker/engine/run.py index 12b83f5..e64cbb8 100644 --- a/graphfaker/engine/run.py +++ b/graphfaker/engine/run.py @@ -77,9 +77,35 @@ class GraphRun: def to_networkx(self) -> nx.DiGraph: return self.tables.to_networkx() - def write(self, directory: str | Path) -> Path: - """``nodes/``, ``edges/``, ``truth/``, ``schema.yaml``, ``manifest.json``.""" + def write(self, directory: str | Path, *, force: bool = False) -> Path: + """``nodes/``, ``edges/``, ``truth/``, ``schema.yaml``, ``manifest.json``. + + Refuses when the directory holds tables this run will not overwrite, + because the write is per table and nothing used to remove what was + already there: generating a second dataset into the same directory + left both, and every loader reads the directory rather than the + manifest, so a dataset silently became the union of two runs. The + manifest said two node types and the folder held four. + + It only comes up when the set of tables changes, which means a + renamed or removed node type or a different schema entirely. + Regenerating the same schema overwrites every file and is unaffected. + ``force`` deletes the leftovers first. + """ root = Path(directory) + leftover = stale_tables(root, self) + if leftover and not force: + names = sorted(str(path.relative_to(root)).replace("\\", "/") for path in leftover) + shown = ", ".join(names[:4]) + (f" and {len(names) - 4} more" if len(names) > 4 else "") + raise FileExistsError( + f"{root} already holds {len(names)} table(s) from an earlier run that this one does " + f"not replace ({shown}). Writing here would leave a dataset that is the union of " + "both, and every loader reads the directory rather than the manifest. Use another " + "directory, or replace the dataset: --force on the command line, force=True here." + ) + for path in leftover: + path.unlink() + self.tables.write_parquet(root) (root / "truth").mkdir(parents=True, exist_ok=True) for name, frame in self.truth.items(): @@ -89,6 +115,27 @@ def write(self, directory: str | Path) -> Path: return root +def stale_tables(directory: str | Path, run: GraphRun) -> list[Path]: + """Parquet files in ``directory`` that writing ``run`` would not replace. + + Only the three directories a run owns are considered, and only + ``.parquet`` in them, so a directory holding somebody else's files is + not something this reports on or removes. + """ + root = Path(directory) + expected = { + "nodes": set(run.tables.nodes), + "edges": set(run.tables.edges), + "truth": set(run.truth), + } + found = [] + for folder, names in expected.items(): + for path in sorted((root / folder).glob("*.parquet")): + if path.stem not in names: + found.append(path) + return found + + @contextlib.contextmanager def node_pool(workers: int) -> Iterator[Executor | None]: """One process pool for a whole run, or ``None`` for in-process.""" diff --git a/graphfaker/schema/graph.py b/graphfaker/schema/graph.py index 7a2eb7d..5cd4bbc 100644 --- a/graphfaker/schema/graph.py +++ b/graphfaker/schema/graph.py @@ -417,6 +417,96 @@ def generation_order(self) -> list[NodeType]: # ------------------------------------------------------------ serialisation + def describe(self, width: int = 100) -> list[str]: + """The schema as lines, for somebody deciding whether to generate it. + + Written against the four things a reader has to check, which is why + it is not a pretty-printer for the YAML: + + * **The ratios.** An edge family's budget divided by its source rows + is what says whether every purchase has a payment. ``0.48 per + Purchase`` is a bug you cannot see in a file that only states a + share of a total. + * **The distributions.** Each attribute carries its sampler and the + sampler's parameters, because the most common thing wrong with a + written schema is a gaussian where the column means money. A + gaussian amount has no tail, and no tail means no outliers. + * **The latent factor**, or a line saying there is none. A schema + without one is a pile of independent columns: attributes that do + not agree with each other and edges that agree with neither. + * **What a relationship is called**, since the queries you write + next have to use those names. + + It says what is there and judges nothing, which is the same contract + the hardness and coverage reports have. + """ + import textwrap + + from graphfaker.schema.samplers import describe_sampler + + counts = {node.name: node.count for node in self.nodes} + lines = [ + f"{self.name}: {self.total_nodes:,} nodes, {self.total_edges:,} edges, " + f"{self.topology.kind} topology, digest {self.digest()}" + ] + + if self.latent: + for factor in self.latent: + groups = f"x{factor.groups}" if factor.groups else "groups chosen from the node count" + params = ", ".join(f"{n} {describe_sampler(s)}" for n, s in factor.params.items()) + lines.append(f" latent {factor.name} {groups}: {params or 'no parameters'}") + else: + lines.append( + " latent none, so nothing makes the attributes and the edges agree with each other" + ) + + lines.append("") + label = max((len(n.name) for n in self.nodes), default=4) + 2 + for node in self.nodes: + attributes = [f"{name} {describe_sampler(s)}" for name, s in node.attributes.items()] + attributes += [f"{name} degree({d.relationship})" for name, d in node.derived.items()] + head = f" {node.name:<{label}}{node.count:>9,} " + body = textwrap.wrap( + ", ".join(attributes) or "no attributes", + width=max(width, len(head) + 20), + initial_indent=head, + subsequent_indent=" " * len(head), + ) + lines.extend(body) + + if self.edges: + lines.append("") + pairs = [f"{e.source} -> {e.target}" for e in self.edges] + pair_width = max(len(p) for p in pairs) + 2 + functional = False + for edge, pair in zip(self.edges, pairs, strict=True): + budget = int(self.total_edges * edge.share) + source_rows = counts.get(edge.source, 0) + # Per source row, because that is the number a reader can + # sanity-check against what the entity means. + ratio = f"{budget / source_rows:>6.2f} per {edge.source}" if source_rows else "no source rows" + names = [] + for rel in edge.relationships: + functional |= rel.functional + names.append(rel.name + ("*" if rel.functional else "")) + closure = ", closes triangles" if edge.closure else "" + lines.append( + f" {pair:<{pair_width}}{budget:>9,} {ratio:<24}{', '.join(names)}{closure}" + ) + if functional: + lines.append("") + lines.append(" * functional: a node has at most one of these") + + if self.targets is not None: + stated = {k: v for k, v in self.targets.model_dump(exclude_none=True).items() if v != {}} + if stated: + lines.append("") + lines.append( + " targets (declared, not enforced): " + + ", ".join(f"{k}={v}" for k, v in stated.items()) + ) + return lines + def to_dict(self) -> dict[str, Any]: return _keep_meaningful_nulls(self, self.model_dump(mode="json", exclude_none=True)) diff --git a/graphfaker/schema/samplers.py b/graphfaker/schema/samplers.py index 89af8ff..23e6a3c 100644 --- a/graphfaker/schema/samplers.py +++ b/graphfaker/schema/samplers.py @@ -211,6 +211,54 @@ class ForeignKeySampler(_Sampler): _RELATIVE = re.compile(r"^(now|today|[+-]\d+[smhdwy])$", re.IGNORECASE) +def describe_sampler(sampler: Any) -> str: + """One short phrase for a sampler, for reading a schema back. + + Short enough that a node's whole attribute list fits on a couple of + lines, because the thing a reader is looking for is a distribution that + is wrong for what the column means: a gaussian amount has no tail, and + a lognormal age is nonsense. The parameters are in the phrase for the + same reason. + """ + kind = getattr(sampler, "kind", None) + if kind == "constant": + return f"constant({_number(sampler.value)})" + if kind == "category": + return f"category({len(sampler.values)})" + if kind == "subcategory": + return f"subcategory(by {sampler.parent})" + if kind == "uniform": + return f"uniform({_number(sampler.low)}, {_number(sampler.high)})" + if kind == "gaussian": + return f"gaussian({_number(sampler.mean)}, {_number(sampler.sd)})" + if kind == "lognormal": + return f"lognormal({_number(sampler.mu)}, {_number(sampler.sigma)})" + if kind == "poisson": + return f"poisson({_number(sampler.lam)})" + if kind == "bernoulli": + return f"bernoulli({_number(sampler.p)})" + if kind == "faker": + return f"faker({sampler.provider})" + if kind == "expression": + return "expression" + if kind == "reference": + return f"@{sampler.ref.lstrip('@')}" + if kind == "mixture": + return "mixture(" + " + ".join(describe_sampler(c.sampler) for c in sampler.components) + ")" + if kind == "foreign_key": + return f"-> {sampler.node_type}" + return str(kind or type(sampler).__name__) + + +def _number(value: Any) -> str: + """A parameter, without the trailing zeros that make a line unreadable.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return str(value) + if isinstance(value, float) and value == int(value): + return str(int(value)) + return f"{value:g}" + + def clock_dependent(sampler: object) -> str | None: """Why this sampler would produce different data tomorrow, or ``None``. diff --git a/pyproject.toml b/pyproject.toml index 1e0480c..38d6545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphfaker" -version = "1.3.0" +version = "1.4.0" description = "Generate graph data that behaves like the real thing: people, the relationships between them and the events over time, with the ground truth included." readme = "README.md" authors = [ @@ -53,6 +53,13 @@ keywords = [ "faker", ] dependencies = [ + # >=2026.10.19 for PYSEC-2026-4320: earlier bundles trust `emSign ECC Root + # CA - G3`, and eight eMudhra intermediates chaining to it have compromised + # private keys. Someone holding one of those keys, on-path, can present a + # valid-looking certificate for any hostname. Every HTTPS call the package + # makes verifies against this bundle, which is the wiki fetcher, the BTS zip + # the flights fetcher downloads and the Overpass queries behind the OSM one. + "certifi>=2026.10.19", "faker>=37.1.0", "networkx>=3.4.2", "numpy>=1.26", @@ -97,6 +104,15 @@ pyg = [ ] examples = [ "matplotlib>=3.8", + # matplotlib brings Pillow, and 12.3.0 has eight image-parsing CVEs + # (CVE-2026-112395/112397/112400/112404/112895/112896/112897/112899: TIFF + # out-of-bounds reads, a PPM width of zero, a GIF decompression bomb, PDF + # append recursion, EPS through Ghostscript). Nothing in GraphFaker opens + # an image, so none of it is reachable from the package itself; this is + # for the notebook environment, where a plot is rendered and saved. The + # marker is because Pillow 13 needs 3.11 and the package supports 3.10, + # which keeps the extra installable there rather than forcing a choice. + "pillow>=13.0.0; python_version >= '3.11'", "ladybug>=0.20", # LadybugDB, the embedded graph database the notebooks query "scikit-learn>=1.4", # the model in docs/notebooks/neo4j_fraud_analysis.ipynb "nbformat", diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..363fdc2 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,27 @@ +"""Helpers shared between test modules.""" + +from __future__ import annotations + +import re + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +_BOX = re.compile(r"[─-╿]") + + +def said(result) -> str: + """What the CLI said, with rich's drawing taken off. + + Typer renders an error through rich, which colours it, wraps it to the + terminal width and puts it in a box. Colour is the part that breaks a + substring match, because an option is styled a dash at a time: + ``--force`` arrives as ``\x1b[1;36m-\x1b[0m\x1b[1;36m-force\x1b[0m``, so + the literal string is not in the output at all. The wrapping breaks the + rest, because a sentence the assertion looks for arrives with a newline + somewhere in the middle of it, and where depends on the terminal width. + + Both are why an assertion on an error message passes locally and fails + in CI, which has happened twice now. Anything asserting on the text of + a ``typer.BadParameter`` or a usage error goes through here. + """ + text = _BOX.sub(" ", _ANSI.sub("", result.output)) + return " ".join(text.split()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7addc62..913c8ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ from typer.testing import CliRunner from graphfaker.cli import app +from tests.helpers import said runner = CliRunner() @@ -250,7 +251,7 @@ def test_inspect_and_json_outputs(tmp_path): assert json.loads(result.output)["truth"] == ["accounts", "patterns", "region", "transactions"] result = runner.invoke(app, ["inspect", str(tmp_path)]) - assert result.exit_code != 0 and "manifest.json" in result.output + assert result.exit_code != 0 and "manifest.json" in said(result) result = runner.invoke(app, ["evaluate", str(out), "--json"]) assert result.exit_code == 0 and json.loads(result.output)["account"]["fn"] > 0 @@ -298,3 +299,113 @@ def test_coverage_is_available_to_every_domain_that_injects_anything(tmp_path): ) assert result.exit_code == 0, result.output assert "no injected patterns" in result.output + + +def test_validate_verbose_reads_the_schema_back(tmp_path): + """What somebody holding a drafted schema needs: the ratios. + + The header line already says how big the thing is. The breakdown says + what it is, and the number that matters most is an edge family's budget + divided by its source rows: a schema states a share of a total and + nothing in the file says whether that works out to one per row. + """ + path = tmp_path / "social.yaml" + result = runner.invoke(app, ["schema", "social", "--total-nodes", "400", "--out", str(path)]) + assert result.exit_code == 0, result.output + + quiet = runner.invoke(app, ["validate", str(path)]) + assert quiet.exit_code == 0, quiet.output + assert "node types" in quiet.output + assert "per Person" not in quiet.output, "the breakdown is opt-in" + + loud = runner.invoke(app, ["validate", str(path), "--verbose"]) + assert loud.exit_code == 0, loud.output + assert "latent community" in loud.output + assert "per Person" in loud.output + assert "faker(name)" in loud.output, "samplers and their parameters, not just attribute names" + assert "* functional" in loud.output + # -v is the same flag. + assert runner.invoke(app, ["validate", str(path), "-v"]).output == loud.output + + +def test_validate_verbose_still_fails_on_a_bad_schema(tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("name: oops\nnodes: []\n", encoding="utf-8") + result = runner.invoke(app, ["validate", str(bad), "--verbose"]) + assert result.exit_code == 1 + + +def test_generate_refuses_to_merge_two_datasets_in_one_directory(tmp_path): + social = tmp_path / "social.yaml" + coordination = tmp_path / "coordination.yaml" + assert runner.invoke(app, ["schema", "social", "--total-nodes", "200", "--out", str(social)]).exit_code == 0 + assert runner.invoke(app, ["schema", "coordination", "--out", str(coordination)]).exit_code == 0 + + out = tmp_path / "out" + base = ["generate", "--seed", "1", "--out", str(out), "--quiet", "--schema"] + assert runner.invoke(app, [*base, str(social)]).exit_code == 0 + first = sorted(p.stem for p in (out / "nodes").glob("*.parquet")) + assert "Person" in first + + # The same schema again is the ordinary loop and is not a conflict. + assert runner.invoke(app, [*base, str(social)]).exit_code == 0 + + refused = runner.invoke(app, [*base, str(coordination)]) + assert refused.exit_code == 1 + assert "union of both" in refused.output and "--force" in refused.output + assert sorted(p.stem for p in (out / "nodes").glob("*.parquet")) == first, "nothing was written" + + forced = runner.invoke(app, [*base, str(coordination), "--force"]) + assert forced.exit_code == 0, forced.output + assert sorted(p.stem for p in (out / "nodes").glob("*.parquet")) == ["Account", "Device", "Topic"] + + +def test_schema_draft_will_not_write_over_an_existing_file(tmp_path, monkeypatch): + """Checked before the model is asked, so a mistaken rerun costs nothing. + + A drafted schema is meant to be read and edited, and a second draft of + the same sentence is a different schema, so replacing one silently + throws away the editing that is the whole point of the file. + """ + from graphfaker.llm import RecordedProvider + + out = tmp_path / "hospital.yaml" + out.write_text("name: edited_by_hand\nnodes: []\n", encoding="utf-8") + # No replies at all: if the model is reached, the provider raises. + monkeypatch.setattr("graphfaker.llm.get_provider", lambda *a, **k: RecordedProvider(replies=[])) + + refused = runner.invoke(app, ["schema", "draft", "a hospital", "--out", str(out)]) + assert refused.exit_code != 0 + # Through said(), because this one is a BadParameter: rich boxes it, + # wraps the sentence and styles --force a dash at a time. + assert "already exists" in said(refused) and "--force" in said(refused) + assert "asking" not in said(refused), "the model was asked before the file was checked" + assert out.read_text(encoding="utf-8").startswith("name: edited_by_hand") + + good = "name: clinic\nnodes:\n- {name: A, count: 2}\ntopology: {kind: uniform}\n" + monkeypatch.setattr("graphfaker.llm.get_provider", lambda *a, **k: RecordedProvider(replies=[good])) + forced = runner.invoke(app, ["schema", "draft", "a hospital", "--out", str(out), "--force"]) + assert forced.exit_code == 0, forced.output + assert "name: clinic" in out.read_text(encoding="utf-8") + + +def test_a_redraft_says_when_a_dataset_came_from_the_previous_one(tmp_path, monkeypatch): + """`hospital.yaml` next to `./hospital` is the pairing people make.""" + from graphfaker.llm import RecordedProvider + + schema = tmp_path / "hospital.yaml" + good = "name: hospital\nnodes:\n- {name: A, count: 2}\ntopology: {kind: uniform}\n" + monkeypatch.setattr("graphfaker.llm.get_provider", lambda *a, **k: RecordedProvider(replies=[good])) + assert runner.invoke(app, ["schema", "draft", "a hospital", "--out", str(schema)]).exit_code == 0 + + data = tmp_path / "hospital" + assert runner.invoke( + app, ["generate", "--schema", str(schema), "--seed", "1", "--out", str(data), "--quiet"] + ).exit_code == 0 + + monkeypatch.setattr("graphfaker.llm.get_provider", lambda *a, **k: RecordedProvider(replies=[good])) + again = runner.invoke(app, ["schema", "draft", "a hospital", "--out", str(schema), "--force"]) + assert again.exit_code == 0, again.output + assert "holds a dataset generated from the previous draft" in again.output + # Said, not done: the dataset is untouched. + assert (data / "manifest.json").is_file() diff --git a/tests/test_engine.py b/tests/test_engine.py index 071d3ad..e24e536 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -302,3 +302,65 @@ def test_a_written_date_reaches_faker_as_a_date(): assert passthrough == {"start_date": "-2y", "end_date": "now"} assert _faker_kwargs(FakerSampler(provider="words", kwargs={"nb": 3})) == {"nb": 3} assert _faker_kwargs(FakerSampler(provider="name")) == {} + + +def test_a_second_dataset_cannot_half_overwrite_the_first(tmp_path): + """The write is per table and used to remove nothing. + + So generating a second dataset into the same directory left both, and + every loader reads the directory rather than the manifest: the manifest + said two node types and the folder held four, which a loader happily + read as one graph. + """ + import pytest + + from graphfaker.backends.tables import GraphTables + from graphfaker.engine.run import generate, stale_tables + from graphfaker.schema import EdgeType, GraphSchema, NodeType, Relationship + from graphfaker.schema.topology import UniformTopology + + def schema(name, source, target, relationship): + return GraphSchema( + name=name, topology=UniformTopology(), total_edges=20, + nodes=[NodeType(name=source, count=10), NodeType(name=target, count=4)], + edges=[EdgeType(source=source, target=target, share=1.0, + relationships=[Relationship(name=relationship)])], + ) + + out = tmp_path / "data" + hospital = generate(schema("hospital", "Patient", "Ward", "ADMITTED_TO"), seed=1) + hospital.write(out) + assert stale_tables(out, hospital) == [] + + # The same tables again is the ordinary loop: every file is replaced. + generate(schema("hospital", "Patient", "Ward", "ADMITTED_TO"), seed=2).write(out) + + library = generate(schema("library", "Member", "Book", "BORROWED"), seed=1) + assert len(stale_tables(out, library)) == 3 + with pytest.raises(FileExistsError, match="union of both"): + library.write(out) + # Nothing was written, so the first dataset is intact. + assert sorted(GraphTables.read_parquet(out).nodes) == ["Patient", "Ward"] + + library.write(out, force=True) + read = GraphTables.read_parquet(out) + assert sorted(read.nodes) == ["Book", "Member"] and sorted(read.edges) == ["BORROWED"] + + +def test_stale_tables_only_looks_at_the_directories_a_run_owns(tmp_path): + """Somebody else's parquet is not this function's business.""" + from graphfaker.engine.run import generate, stale_tables + from graphfaker.schema import GraphSchema, NodeType + from graphfaker.schema.topology import UniformTopology + + run = generate(GraphSchema(name="a", topology=UniformTopology(), + nodes=[NodeType(name="A", count=3)]), seed=1) + out = tmp_path / "data" + run.write(out) + (out / "mine.parquet").write_bytes(b"not ours") + (out / "notes").mkdir() + (out / "notes" / "other.parquet").write_bytes(b"not ours either") + + assert stale_tables(out, run) == [] + run.write(out) # and writing again does not remove them + assert (out / "mine.parquet").is_file() and (out / "notes" / "other.parquet").is_file() diff --git a/tests/test_llm_draft.py b/tests/test_llm_draft.py index 130e339..4c26015 100644 --- a/tests/test_llm_draft.py +++ b/tests/test_llm_draft.py @@ -28,6 +28,7 @@ worked_example, ) from graphfaker.schema.graph import GraphSchema +from tests.helpers import said runner = CliRunner() @@ -242,7 +243,7 @@ def test_the_cli_prints_the_yaml_when_there_is_no_out(tmp_path, monkeypatch): def test_the_cli_needs_a_description(): result = runner.invoke(app, ["schema", "draft"]) assert result.exit_code != 0 - assert "describe the graph" in result.output + assert "describe the graph" in said(result) def test_a_draft_that_will_not_validate_exits_non_zero(tmp_path, monkeypatch): diff --git a/tests/test_schema.py b/tests/test_schema.py index dfb580e..12810d0 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -9,11 +9,14 @@ ConstantSampler, DegreeDerived, EdgeType, + ExpressionSampler, + FakerSampler, ForeignKeySampler, GaussianSampler, GraphSchema, LatentFactor, NodeType, + ReferenceSampler, Relationship, SocialTopology, UniformTopology, @@ -276,3 +279,125 @@ def test_an_expression_that_does_not_parse_is_a_validation_error(): attributes={"n": ConstantSampler(value=1)}, derived={"d": DegreeDerived(relationship="TO", expr="count +")}, )]) + + +# ------------------------------------------------------- reading one back + + +def test_every_sampler_has_a_short_form(): + """Generated from the union, so a new sampler cannot be left out.""" + import typing + + from graphfaker.schema.samplers import Sampler, describe_sampler + + for model in typing.get_args(typing.get_args(Sampler)[0]): + kind = model.model_fields["kind"].default + assert kind in _SAMPLER_FORMS, f"{kind} has no expected short form in this test" + assert describe_sampler(_SAMPLER_FORMS[kind][0]) == _SAMPLER_FORMS[kind][1], kind + + +def _sampler_forms(): + from graphfaker.schema.samplers import ( + BernoulliSampler, + ForeignKeySampler, + LognormalSampler, + MixtureComponent, + MixtureSampler, + PoissonSampler, + SubcategorySampler, + UniformSampler, + ) + + return { + "constant": (ConstantSampler(value="USD"), "constant(USD)"), + "category": (CategorySampler(values=["a", "b", "c"]), "category(3)"), + "subcategory": (SubcategorySampler(parent="specialty", values={"x": ["y"]}), "subcategory(by specialty)"), + "uniform": (UniformSampler(low=1, high=10), "uniform(1, 10)"), + # Trailing zeros taken off, because a column of them is unreadable. + "gaussian": (GaussianSampler(mean=52.0, sd=19.5), "gaussian(52, 19.5)"), + "lognormal": (LognormalSampler(mu=0.0, sigma=1.3), "lognormal(0, 1.3)"), + "poisson": (PoissonSampler(lam=4.2), "poisson(4.2)"), + "bernoulli": (BernoulliSampler(p=0.004), "bernoulli(0.004)"), + "faker": (FakerSampler(provider="name"), "faker(name)"), + "expression": (ExpressionSampler(expr="1 + 1"), "expression"), + "reference": (ReferenceSampler(ref="@community.region"), "@community.region"), + "mixture": ( + MixtureSampler(components=[ + MixtureComponent(weight=0.6, sampler=ReferenceSampler(ref="@community.region")), + MixtureComponent(weight=0.4, sampler=CategorySampler(values=["a", "b"])), + ]), + "mixture(@community.region + category(2))", + ), + "foreign_key": (ForeignKeySampler(node_type="Patient"), "-> Patient"), + } + + +_SAMPLER_FORMS = _sampler_forms() + + +def test_describe_answers_the_questions_a_draft_has(): + """The ratio per source row is the line that earns this feature. + + A schema states a share of a total edge budget, and nothing in the file + says whether that works out to one payment per purchase or half of one. + On a drafted schema that arithmetic was the whole finding. + """ + schema = minimal( + nodes=[ + NodeType(name="Patient", count=4000, attributes={ + "age": GaussianSampler(mean=52, sd=19), + "band": ReferenceSampler(ref="@community.band"), + }), + NodeType(name="Doctor", count=250, attributes={"name": FakerSampler(provider="name")}), + ], + latent=[LatentFactor(name="community", groups=8, params={"band": CategorySampler(values=["a", "b"])})], + edges=[ + EdgeType(source="Patient", target="Doctor", share=0.5, relationships=[ + Relationship(name="TREATED_BY"), + Relationship(name="PRIMARY_PHYSICIAN", functional=True), + ]), + EdgeType(source="Doctor", target="Doctor", share=0.5, closure=True, relationships=[ + Relationship(name="CONSULTS_WITH"), + ]), + ], + total_edges=4000, + ) + text = "\n".join(schema.describe()) + + assert "4,250 nodes, 4,000 edges, social topology" in text + assert "latent community x8: band category(2)" in text + # Samplers and their parameters, because a gaussian where the column + # means money is the commonest thing wrong with a written schema. + assert "age gaussian(52, 19)" in text and "band @community.band" in text + # 2,000 edges over 4,000 patients. + assert "0.50 per Patient" in text + # 2,000 over 250 doctors, on a family that closes triangles. + assert "8.00 per Doctor" in text and "closes triangles" in text + assert "PRIMARY_PHYSICIAN*" in text and "TREATED_BY," in text + assert "* functional" in text + + +def test_describe_says_when_there_is_no_latent_factor(): + """A schema with none is a pile of independent columns, so it is worth + a line rather than an absent section.""" + # Uniform topology, because the default social one reads a latent + # factor and a schema with none has to say which it is. + bare = GraphSchema(name="flat", nodes=[NodeType(name="A", count=5)], topology=UniformTopology()) + assert any("latent none" in line for line in bare.describe()) + # And no edge block at all, which is what the packs look like: their + # edges come from code rather than from the schema. + assert not any("per A" in line for line in bare.describe()) + assert not any("functional" in line for line in bare.describe()) + + +def test_describe_survives_every_shipped_schema(): + from graphfaker.domains import available + + for name, pack in sorted(available().items()): + if pack.schema is None: + continue + schema = pack.schema(**(pack.options().model_dump() if pack.options else {})) + lines = schema.describe() + assert lines and lines[0].startswith(f"{schema.name}:"), name + for node in schema.nodes: + assert any(node.name in line for line in lines), (name, node.name) diff --git a/tests/test_senzing.py b/tests/test_senzing.py index e7b6d6c..ba049ec 100644 --- a/tests/test_senzing.py +++ b/tests/test_senzing.py @@ -9,7 +9,6 @@ import collections import json -import re import zipfile import polars as pl @@ -28,26 +27,10 @@ sources, write_dataset, ) +from tests.helpers import said runner = CliRunner() -_ANSI = re.compile(r"\x1b\[[0-9;]*m") -_BOX = re.compile(r"[\u2500-\u257f]") - - -def said(result) -> str: - """What the CLI said, with rich's drawing taken off. - - Typer renders an error through rich, which colours it, wraps it to the - terminal width and puts it in a box. Colour is the part that breaks a - substring match, because an option is styled a dash at a time: - ``--path`` arrives as ``\x1b[1;36m-\x1b[0m\x1b[1;36m-path\x1b[0m``, so - the literal string is not in the output at all. That is why these - assertions passed locally and failed in CI, where colour is on. - """ - text = _BOX.sub(" ", _ANSI.sub("", result.output)) - return " ".join(text.split()) - RECORDS = [ # Two companies, each an anchor other records point at. {"DATA_SOURCE": "OPENDATA", "RECORD_ID": "c1", "bq_dataset": "COMPANY", "FEATURES": [ diff --git a/uv.lock b/uv.lock index 3c21110..8f57674 100644 --- a/uv.lock +++ b/uv.lock @@ -509,11 +509,11 @@ filecache = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]] @@ -1656,9 +1656,10 @@ wheels = [ [[package]] name = "graphfaker" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ + { name = "certifi" }, { name = "faker" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1714,6 +1715,7 @@ examples = [ { name = "neo4j" }, { name = "notebook" }, { name = "osmnx" }, + { name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-learn", version = "1.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] @@ -1741,6 +1743,7 @@ pyg = [ [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'llm'", specifier = ">=0.49" }, + { name = "certifi", specifier = ">=2026.10.19" }, { name = "coverage", marker = "extra == 'dev'" }, { name = "duckdb", marker = "extra == 'duckdb'", specifier = "==1.5.4" }, { name = "duckdb", marker = "extra == 'examples'", specifier = "==1.5.4" }, @@ -1765,6 +1768,7 @@ requires-dist = [ { name = "osmnx", marker = "extra == 'examples'", specifier = "==2.0.2" }, { name = "osmnx", marker = "extra == 'osm'", specifier = "==2.0.2" }, { name = "pandas", specifier = ">=2.2.2" }, + { name = "pillow", marker = "python_full_version >= '3.11' and extra == 'examples'", specifier = ">=13.0.0" }, { name = "pip-audit", marker = "extra == 'dev'" }, { name = "polars", specifier = ">=1.0" }, { name = "pyarrow", specifier = ">=15.0" }, @@ -2992,7 +2996,7 @@ dependencies = [ { name = "kiwisolver", marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pillow", marker = "python_full_version < '3.11'" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pyparsing", marker = "python_full_version < '3.11'" }, { name = "python-dateutil", marker = "python_full_version < '3.11'" }, ] @@ -3085,7 +3089,7 @@ dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pillow", marker = "python_full_version >= '3.11'" }, + { name = "pillow", version = "13.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyparsing", marker = "python_full_version >= '3.11'" }, { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, ] @@ -4350,6 +4354,10 @@ wheels = [ name = "pillow" version = "12.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform != 'linux'", +] 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" }, @@ -4440,6 +4448,114 @@ wheels = [ { 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]] +name = "pillow" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform == 'linux'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'linux'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" }, + { url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" }, + { url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, + { url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" }, + { url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, +] + [[package]] name = "pip" version = "26.2.1"