From c9774009b4d4b7b93b82bdba9de1b01515a67af7 Mon Sep 17 00:00:00 2001 From: buzzer-re <22428720+buzzer-re@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:51:08 +0900 Subject: [PATCH 1/3] Add APK support with the ASC (droidasc) backend `tocode app.apk` (also .apks/.xapk bundles and base.apk + sibling split_*.apk sets) now exports the DEX side with ASC/androguard into src/raw//.java plus Android metadata (manifest.json, classes.json, functions.json, function-index.json, strings.json, imports.json, exports.json with JNI symbols, reachable.json from manifest components, package-graph.json, sections.json, triage.json), decoded resources, and the raw APK entries. Every shared object found in the set is extracted to lib// and, unless --no-native, decompiled with the regular pipeline (IDA/r2/angr via --backend) into native/// on a background thread. Each library runs in its own process group so an OOM-killed or crashed backend only fails that library (native-libs.json), the thread waits for free memory before each library, and the nested export's log is mirrored live into the main output with a per-library progress bar and a heartbeat. Large documents are streamed row by row and decompile workers are recycled in rounds to keep memory bounded on multi-dex apps. droidasc is pinned as a core dependency. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 18 +- README.md | 19 +- pyproject.toml | 6 + src/tocode/apk.py | 1000 ++++++++++++++++++++++++++++++++++ src/tocode/apk_metadata.py | 1012 ++++++++++++++++++++++++++++++++++ src/tocode/apk_native.py | 589 ++++++++++++++++++++ src/tocode/backends/asc.py | 1012 ++++++++++++++++++++++++++++++++++ src/tocode/cli.py | 71 ++- src/tocode/schema.py | 16 + tests/test_apk_export.py | 936 ++++++++++++++++++++++++++++++++ tests/test_asc_backend.py | 166 ++++++ tests/test_cli.py | 55 ++ uv.lock | 1046 +++++++++++++++++++++++++++++++++--- 13 files changed, 5861 insertions(+), 85 deletions(-) create mode 100644 src/tocode/apk.py create mode 100644 src/tocode/apk_metadata.py create mode 100644 src/tocode/apk_native.py create mode 100644 src/tocode/backends/asc.py create mode 100644 tests/test_apk_export.py create mode 100644 tests/test_asc_backend.py diff --git a/AGENTS.md b/AGENTS.md index ae467f1..e82d0ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS -This repository contains ToCode, a Python-only binary exporter. ToCode takes one binary or IDA database path and writes one source-like project directory for reverse-engineering agents. +This repository contains ToCode, a Python-only binary exporter. ToCode takes one binary, IDA database, or Android APK path and writes one source-like project directory for reverse-engineering agents. ## Scope @@ -15,7 +15,8 @@ This repository contains ToCode, a Python-only binary exporter. ToCode takes one - `src/tocode/cli.py`: command-line entry point for `tocode`. - `src/tocode/__init__.py`: package version and `export_from_binaryview()`, the in-UI Binary Ninja library entry. - `src/tocode/analysis.py`: backend-neutral binary inventory and call graph normalization. -- `src/tocode/backends/`: IDA Domain, radare2, angr, and Binary Ninja (`binja.py`) session adapters. +- `src/tocode/backends/`: IDA Domain, radare2, angr, and Binary Ninja (`binja.py`) session adapters, plus the ASC/droidasc APK backend (`asc.py`: APK set discovery, DEX inventory, worker-side class decompilation). +- `src/tocode/apk.py`: APK export pipeline (extraction, decompile pool, resource decoding, metadata); `apk_metadata.py`: manifest parsing and Android JSON documents; `apk_native.py`: extraction and background native export of every `.so`. - `src/tocode/exporter.py`: project writer, function rendering, worker-session rendering, generated export `AGENTS.md`. - `src/tocode/metadata.py`: JSON metadata and triage documents. - `src/tocode/cluster.py`: call-graph clustering. @@ -59,6 +60,17 @@ When `--tree` is passed, the export also contains: - `src/tree/**/*.c` - `function-index-tree.json` +APK input (`.apk`, `.apks`, `.xapk`) uses the ASC backend and writes instead: + +- `src/raw//**/*.java` (one file per class, folders are Java packages; no clusters, no summaries) +- `AndroidManifest.xml`, `manifest.json`, `classes.json`, `package-graph.json`, `native-libs.json` +- `functions.json`, `function-index.json`, `strings.json`, `imports.json`, `exports.json`, `sections.json`, `reachable.json`, `triage.json`, `project.json`, `export-manifest.json` (same shapes as the native export where applicable) +- `lib//*.so` (every native library, always extracted) and `native///` (a full nested ToCode export per library, all ABIs, unless `--no-native`) +- `data/apk/**` (all other entries verbatim), `data/res/**/*.xml`, `data/resources.json` +- `tocode.log`, generated `AGENTS.md` and `CLAUDE.md` + +`base.apk` merges sibling `split_*.apk` files unless `--no-splits`; bundles are unpacked and merged. `--backend` selects the native backend for the `.so` exports (`binja` is rejected). + ## Development - Prefer `uv` for local commands. @@ -74,6 +86,8 @@ When `--tree` is passed, the export also contains: - IDA Domain is the preferred backend when available. - radare2/r2pipe is a fallback backend. - angr is the optional pure-Python fallback backend (`[angr]` extra). +- ASC (`droidasc`, PyPI) is the APK/DEX backend and a core runtime dependency. It pulls androguard. `backends/asc.py` is the only module that imports it; it pre-imports the modules ASC would otherwise stub in `sys.modules` and silences androguard's loguru logging. Class decompilation is not thread-safe, so it runs in spawned worker processes (recycled in rounds; do not use `max_tasks_per_child`, it deadlocks spawn pools on some CPython builds). The big per-method/per-class/per-string JSON documents are streamed row by row (`apk_metadata.write_json_rows`), never built as one object. +- APK native libraries are exported by `apk_native.py` on a background thread, each `export_binary` in its own spawned process so a backend OOM-kill or crash only fails that library (`native-libs.json` status). The thread waits for `TOCODE_APK_NATIVE_MIN_FREE_MB` (default 1024) of free memory before starting each library so it does not starve the DEX pool. `TOCODE_WORKER_TMP_DIR` also places the unpacked `.apks` bundle. - Binary Ninja is an opt-in backend (`--backend binja`, never auto-selected). The `binaryninja` module is supplied by the Binary Ninja install (in-UI) or the remote VM, so it is not a pip dependency. `rpyc`, the client used for the diff --git a/README.md b/README.md index 71a3052..0c2249b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ToCode -ToCode exports a binary or IDA database into a source-like project tree: raw recovered C, matching assembly, function summaries, section data, optional IDA database, and metadata that coding agents can read directly. +ToCode exports a binary, IDA database, or Android APK into a source-like project tree: raw recovered C (or Java for APKs), matching assembly, function summaries, section data, optional IDA database, and metadata that coding agents can read directly. ## Why @@ -76,6 +76,23 @@ Three backends are supported, selected with `--backend` (default `auto`, which p Other disassemblers may be added in the future. +### Android APKs + +`tocode app.apk` (also `.apks`/`.xapk` bundles) uses [ASC](https://github.com/MG1937/ASC) (`droidasc`, a core dependency) for the DEX side and the regular native backends for every shared object in the package: + +- `src/raw//.java`: one decompiled Java file per class, folders follow Java packages (no clustering, no summaries). +- `AndroidManifest.xml` + `manifest.json`: decoded and parsed manifest (permissions, components with intent filters and exported state, application attributes, split manifests). +- `classes.json`, `functions.json`, `function-index.json`, `strings.json`, `imports.json`, `exports.json` (exported components + JNI methods), `reachable.json` (from manifest components), `package-graph.json`, `sections.json`, `triage.json`. +- `lib//*.so`: every native library extracted; `native///`: a complete nested ToCode export per library (all ABIs), produced on a background thread while the DEX side decompiles. `native-libs.json` records the status of each (each library runs in its own process; one failing or being OOM-killed never fails the APK export, and the native thread waits for `TOCODE_APK_NATIVE_MIN_FREE_MB`, default 1024 MB, of free memory before each library). Pass `--no-native` to skip the native decompilation (libraries are still extracted). `--backend` picks the native backend (`auto`/`ida`/`r2`/`angr`; `binja` is not supported for APKs). +- `data/apk/**`: every other APK entry verbatim; `data/res/**/*.xml` and `data/resources.json`: decoded binary XML and `resources.arsc`. + +`base.apk` automatically merges sibling `split_*.apk` files (config and ABI splits) into the same project; `--no-splits` exports it alone. The default output directory is `_decompiler`. + +```bash +tocode base.apk # DEX + all splits + native libs (IDA/r2/angr) +tocode app.apks --no-native -j 4 # bundle, DEX/Android side only +``` + ### Using diff --git a/pyproject.toml b/pyproject.toml index b8df692..becc2c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Export compiled binaries as source-like projects for reverse engi readme = "README.md" requires-python = ">=3.10" dependencies = [ + "droidasc==0.1.1.post1", "ida-domain==0.5.0", "idapro==0.0.9", "r2pipe==1.9.8", @@ -59,6 +60,11 @@ ignore_missing_imports = true module = ["binaryninja.*"] ignore_missing_imports = true +# droidasc (ASC, the APK backend) and androguard ship no type stubs. +[[tool.mypy.overrides]] +module = ["droidasc.*", "androguard.*"] +ignore_missing_imports = true + [tool.setuptools] package-dir = { "" = "src" } diff --git a/src/tocode/apk.py b/src/tocode/apk.py new file mode 100644 index 0000000..af44568 --- /dev/null +++ b/src/tocode/apk.py @@ -0,0 +1,1000 @@ +"""APK export pipeline (ASC backend). + +Writes one project tree for an APK, split-APK set, or ``.apks``/``.xapk`` +bundle: decompiled Java per class under ``src/raw//``, the decoded +manifest, Android metadata documents, extracted resources, and (unless +disabled) a nested native ToCode export for every shared object found. +""" + +from __future__ import annotations + +from concurrent.futures import Future, ProcessPoolExecutor, as_completed +from dataclasses import dataclass, field +import multiprocessing +import os +from pathlib import Path +import re +import shutil +import tempfile +import threading +import time +from typing import Any, Callable, Iterable +import zipfile + +from . import __version__ +from . import apk_metadata as meta +from .apk_native import ( + DEFAULT_NATIVE_MIN_FREE_MB, + NATIVE_HEARTBEAT_SECONDS, + NativeExporter, + NativeLib, + NativeOptions, + NativeRunner, + classify_native_entry, + default_native_exporter, + extract_native_libs, + native_libs_json, +) +from .backends.asc import ( + ApkSet, + AscSession, + ClassRecord, + DecompiledClass, + MethodRecord, + InlineDecompiler, + class_java_relpath, + decompile_batch_in_worker, + discover_apk_set, + init_decompile_worker, + manifest_xml, + sha256_file, +) +from .errors import ToCodeError +from .metadata import write_json, write_text_atomic +from .naming import clean_path_component +from .parallel import available_memory_mb, choose_jobs +from .progress import Progress +from .schema import ApkExportSummary + + +DECOMPILE_BATCH_SIZE = 24 +# A decompile worker holds the parsed DEX tables plus androguard's per-class +# state; budget this much RAM per worker and recycle workers periodically so +# their resident size stays bounded on long runs. +ASC_WORKER_MEMORY_MB = 768 +ASC_WORKER_BATCHES_PER_CHILD = 48 +DEX_ENTRY_RX = re.compile(r"^classes\d*\.dex$") +# A DAD member declaration at class-body indent: modifiers, return type, name, +# parameter list, optional throws, and a ``;`` only for bodiless methods. +METHOD_DECL_RX = re.compile( + r"^ (?!\{|\}|@)(?P[^;{]*?(?[\w$<>\-]+)\((?P.*)\))" + r"\s*(throws [^;{]*)?(?P;?)\s*$" +) + + +@dataclass(slots=True) +class ApkExportOptions: + out_dir: Path | None = None + jobs: int | None = None + native: bool = True + splits: bool = True + native_options: NativeOptions = field(default_factory=NativeOptions) + decode_resources: bool = True + + +@dataclass +class ApkContext: + input_path: Path + options: ApkExportOptions + progress: Progress + apk_set: ApkSet | None = None + session: AscSession | None = None + root: Path | None = None + raw_dir: Path | None = None + data_dir: Path | None = None + package: str = "" + manifest_infos: list[meta.ManifestInfo] = field(default_factory=list) + manifest_doc: dict[str, Any] = field(default_factory=dict) + manifest_xml_path: Path | None = None + entries: list[meta.ApkEntry] = field(default_factory=list) + libs: list[NativeLib] = field(default_factory=list) + native_runner: NativeRunner | None = None + resource_thread: threading.Thread | None = None + resource_errors: list[str] = field(default_factory=list) + resource_files: list[Path] = field(default_factory=list) + source_files: list[Path] = field(default_factory=list) + failures: list[tuple[str, str]] = field(default_factory=list) + reachable: dict[str, Any] = field(default_factory=dict) + worker_count: int = 1 + render_mode: str = "single" + started: float = 0.0 + bundle_dir: Path | None = None + + +def export_apk( + input_path: Path, + *, + options: ApkExportOptions | None = None, + progress: Progress | None = None, + native_exporter: NativeExporter | None = None, + decompiler_factory: Callable[[AscSession], Any] | None = None, +) -> ApkExportSummary: + options = options or ApkExportOptions() + progress = progress or Progress() + context = ApkContext( + input_path=Path(input_path).resolve(), + options=options, + progress=progress, + started=time.monotonic(), + ) + try: + _discover(context) + _prepare_root(context) + _extract_entries(context) + _start_native(context, native_exporter) + _start_resources(context) + _inventory(context) + _decompile(context, decompiler_factory) + _join_resources(context) + _write_metadata(context) + _join_native(context) + _write_final_documents(context) + except BaseException: + if context.native_runner is not None: + context.native_runner.stop() + raise + finally: + if context.session is not None: + context.session.close() + if context.bundle_dir is not None: + shutil.rmtree(context.bundle_dir, ignore_errors=True) + return _summary(context) + + +# -------------------------------------------------------------------------- +# Steps +# -------------------------------------------------------------------------- + + +def _discover(context: ApkContext) -> None: + path = context.input_path + if not path.is_file(): + raise ToCodeError(f"APK not found: {path}") + workdir = Path(tempfile.mkdtemp(prefix="tocode-apks-", dir=_bundle_tmp_root())) + apk_set = discover_apk_set(path, splits=context.options.splits, workdir=workdir) + if apk_set.workdir is not None: + context.bundle_dir = workdir + else: + shutil.rmtree(workdir, ignore_errors=True) + context.apk_set = apk_set + infos: list[meta.ManifestInfo] = [] + for apk in apk_set.apks: + try: + xml_text = manifest_xml(apk) + except ToCodeError as exc: + if apk == apk_set.primary: + raise + context.progress.log(f"warning: {exc}") + infos.append(meta.parse_manifest("", apk_name=apk.name)) + continue + info = meta.parse_manifest(xml_text, apk_name=apk.name) + infos.append(info) + if apk == apk_set.primary: + context.manifest_doc["_xml"] = xml_text + context.manifest_infos = infos + primary = infos[0] + if primary.parse_error: + raise ToCodeError( + f"cannot parse AndroidManifest.xml of {apk_set.primary.name}: " + f"{primary.parse_error}" + ) + context.package = primary.package or clean_path_component(path.stem) + + +def _bundle_tmp_root() -> str | None: + # Bundles are unpacked here before the package name (and so the export root) + # is known. Honour the same override as the worker database copies so the + # unpacked APKs can be kept off a RAM-backed /tmp. + explicit = os.environ.get("TOCODE_WORKER_TMP_DIR", "").strip() + if not explicit: + return None + candidate = Path(explicit).expanduser() + try: + candidate.mkdir(parents=True, exist_ok=True) + except OSError: + return None + return str(candidate) + + +def _root_dir(context: ApkContext) -> Path: + out_dir = context.options.out_dir + source = context.input_path + if out_dir is not None: + out = Path(out_dir).expanduser() + return out.resolve() if out.is_absolute() else (source.parent / out).resolve() + name = f"{clean_path_component(context.package)}_decompiler" + default_root = os.environ.get("TOCODE_DEFAULT_OUT_ROOT", "").strip() + if default_root: + return (Path(default_root).expanduser().resolve() / name).resolve() + return (source.parent / name).resolve() + + +def _prepare_root(context: ApkContext) -> None: + root = _root_dir(context) + context.root = root + context.raw_dir = root / "src" / "raw" + context.data_dir = root / "data" + if context.progress.log_path is None: + context.progress.set_log_path(root / "tocode.log") + for path in (context.raw_dir, context.data_dir, root / "lib"): + path.mkdir(parents=True, exist_ok=True) + apk_set = _need(context.apk_set) + context.progress.log("Export run started") + context.progress.log(f"Using ASC as backend for {context.package}") + context.progress.log("APK set: " + ", ".join(item.name for item in apk_set.apks)) + xml_text = context.manifest_doc.pop("_xml", "") + manifest_path = root / "AndroidManifest.xml" + write_text_atomic( + manifest_path, xml_text if xml_text.endswith("\n") else xml_text + "\n" + ) + context.manifest_xml_path = manifest_path + context.manifest_doc = meta.manifest_json( + context.manifest_infos[0], context.manifest_infos[1:], xml_path=manifest_path + ) + write_json(root / "manifest.json", context.manifest_doc) + + +def _extract_entries(context: ApkContext) -> None: + root = _need(context.root) + apk_set = _need(context.apk_set) + data_root = _need(context.data_dir) / "apk" + entries: list[meta.ApkEntry] = [] + total = 0 + for apk in apk_set.apks: + with zipfile.ZipFile(apk) as archive: + total += len(archive.infolist()) + with context.progress.bar(total=total, desc="extract", unit="entry") as bar: + for apk in apk_set.apks: + target_root = data_root / clean_path_component(apk.stem) + with zipfile.ZipFile(apk) as archive: + for info in archive.infolist(): + bar.update(1) + if info.is_dir(): + continue + name = info.filename + with archive.open(info) as handle: + head = handle.read(4) + kind = _entry_kind(name, head) + exported: str | None = None + if kind not in {"dex", "native"}: + target = _safe_join(target_root, name) + if target is not None: + target.parent.mkdir(parents=True, exist_ok=True) + with ( + archive.open(info) as source, + target.open("wb") as sink, + ): + shutil.copyfileobj(source, sink, 1 << 20) + exported = target.relative_to(root).as_posix() + entries.append( + meta.ApkEntry( + apk=apk.name, + name=name, + size=info.file_size, + compressed_size=info.compress_size, + kind=kind, + exported_path=exported, + ) + ) + context.entries = entries + context.libs = extract_native_libs(apk_set.apks, root, sha256=sha256_file) + for lib in context.libs: + for entry in entries: + if entry.name == lib.entry and entry.apk == lib.source_apk: + entry.exported_path = lib.path.relative_to(root).as_posix() + context.progress.log( + f"Extracted {len(entries)} entries, {len(context.libs)} native libraries" + ) + _write_native_libs(context) + + +def _entry_kind(name: str, head: bytes) -> str: + leaf = name.rsplit("/", 1)[-1] + if "/" not in name and DEX_ENTRY_RX.match(leaf): + return "dex" + if classify_native_entry(name, head) is not None: + return "native" + if name == "AndroidManifest.xml": + return "manifest" + if name == "resources.arsc": + return "arsc" + if name.startswith("res/"): + return "resource" + if name.startswith("assets/"): + return "asset" + if name.startswith("META-INF/"): + return "meta" + if name.startswith("kotlin/"): + return "kotlin" + return "other" + + +def _safe_join(root: Path, name: str) -> Path | None: + parts = [part for part in name.split("/") if part not in {"", ".", ".."}] + if not parts: + return None + return root.joinpath(*parts) + + +def _start_native(context: ApkContext, exporter: NativeExporter | None) -> None: + root = _need(context.root) + if not context.libs: + return + if not context.options.native: + for lib in context.libs: + lib.status = "skipped: --no-native" + context.progress.log( + f"Native decompilation skipped (--no-native) for {len(context.libs)} libraries" + ) + _write_native_libs(context) + return + runner = NativeRunner( + libs=context.libs, + root=root, + progress=context.progress, + exporter=exporter or default_native_exporter(context.options.native_options), + min_free_mb=_native_min_free_mb(), + available_memory=available_memory_mb, + ) + context.progress.log( + f"Native decompilation started in background for {len(context.libs)} " + f"libraries (backend: {context.options.native_options.backend})" + ) + runner.start() + context.native_runner = runner + + +def _native_min_free_mb() -> int: + raw = os.environ.get("TOCODE_APK_NATIVE_MIN_FREE_MB", "").strip() + if raw: + try: + return max(0, int(raw)) + except ValueError: + pass + return DEFAULT_NATIVE_MIN_FREE_MB + + +def _join_native(context: ApkContext) -> None: + runner = context.native_runner + if runner is None: + return + thread = runner.thread + if thread is not None and thread.is_alive(): + pending = sum(1 for lib in context.libs if lib.status == "pending") + context.progress.log( + f"DEX export done; waiting for {pending} native library export(s) " + "(progress below is mirrored from native///tocode.log)" + ) + # Heartbeat while the native side is in a silent phase (loading or + # auto-analysis produce no log lines for a while). + while thread.is_alive(): + thread.join(timeout=NATIVE_HEARTBEAT_SECONDS) + if thread.is_alive(): + status = runner.describe_current() + if status: + context.progress.log(status) + runner.join() + done = sum(1 for lib in context.libs if lib.status == "done") + context.progress.log( + f"Native decompilation finished: {done}/{len(context.libs)} libraries exported" + ) + _write_native_libs(context) + + +def _write_native_libs(context: ApkContext) -> None: + root = _need(context.root) + write_json(root / "native-libs.json", native_libs_json(context.libs, root)) + + +def _start_resources(context: ApkContext) -> None: + if not context.options.decode_resources: + return + thread = threading.Thread( + target=_decode_resources, args=(context,), name="tocode-resources", daemon=True + ) + thread.start() + context.resource_thread = thread + + +def _join_resources(context: ApkContext) -> None: + thread = context.resource_thread + if thread is None: + return + thread.join() + context.progress.log( + f"Decoded {len(context.resource_files)} resource files" + + ( + f" ({len(context.resource_errors)} failed)" + if context.resource_errors + else "" + ) + ) + for line in context.resource_errors[:20]: + context.progress.file_log(f"resource decode failed: {line}") + + +def _decode_resources(context: ApkContext) -> None: + root = _need(context.root) + data_dir = _need(context.data_dir) + try: + from androguard.core import axml as androguard_axml + except Exception as exc: # pragma: no cover - androguard ships with droidasc + context.resource_errors.append(f"androguard unavailable: {exc}") + return + resources: dict[str, Any] = {"packages": []} + for entry in context.entries: + if entry.exported_path is None: + continue + source = root / entry.exported_path + if entry.kind == "resource" and entry.name.endswith(".xml"): + target = data_dir / "res" / clean_path_component(Path(entry.apk).stem) + target = target.joinpath(*entry.name.split("/")[1:]) + try: + data = source.read_bytes() + if not data.startswith(b"\x03\x00\x08\x00"): + continue # already plain text + printer = androguard_axml.AXMLPrinter(data) + if not printer.is_valid(): + raise ValueError("invalid binary XML") + text = printer.get_xml(pretty=True).decode("utf-8", errors="replace") + write_text_atomic(target, text) + context.resource_files.append(target) + except Exception as exc: + context.resource_errors.append(f"{entry.apk}:{entry.name}: {exc}") + elif entry.kind == "arsc": + try: + resources["packages"].extend( + _decode_arsc(androguard_axml, source.read_bytes(), entry.apk) + ) + except Exception as exc: + context.resource_errors.append(f"{entry.apk}:{entry.name}: {exc}") + write_json(data_dir / "resources.json", resources) + + +def _decode_arsc(axml_module: Any, data: bytes, apk_name: str) -> list[dict[str, Any]]: + parser = axml_module.ARSCParser(data) + packages: list[dict[str, Any]] = [] + resolved = parser.get_resolved_strings() + for package in parser.get_packages_names(): + strings: dict[str, dict[str, str]] = {} + for locale, table in (resolved.get(package) or {}).items(): + strings[str(locale) or "default"] = { + f"0x{res_id:08x}" if isinstance(res_id, int) else str(res_id): value + for res_id, value in table.items() + } + public: list[dict[str, Any]] = [] + try: + public_xml = parser.get_public_resources(package).decode( + "utf-8", errors="replace" + ) + for match in re.finditer( + r' None: + apk_set = _need(context.apk_set) + session = AscSession(apk_set) + with context.progress.bar(total=1, desc="inventory", unit="step") as bar: + session.load() + bar.update(1) + context.session = session + context.progress.log( + f"Inventory: {len(session.images)} dex, {len(session.classes)} classes, " + f"{len(session.methods)} methods, {len(session.strings)} strings" + ) + _assign_java_paths(context) + meta.entry_methods(session, context.manifest_doc) + + +def _assign_java_paths(context: ApkContext) -> None: + raw_dir = _need(context.raw_dir) + session = _need(context.session) + seen: dict[str, int] = {} + for record in session.classes: + rel = class_java_relpath(record.descriptor) + key = rel.as_posix().lower() + count = seen.get(key, 0) + seen[key] = count + 1 + if count: + rel = rel.with_name(f"{rel.stem}~{count}{rel.suffix}") + record.java_path = raw_dir / rel + + +def _select_workers(context: ApkContext) -> int: + session = _need(context.session) + chosen = choose_jobs( + function_count=len(session.classes), + analysis_seconds=0.0, + requested=context.options.jobs, + backend="asc", + ) + memory = available_memory_mb() + if memory is not None and chosen > 1: + ceiling = max(1, memory // ASC_WORKER_MEMORY_MB) + if ceiling < chosen: + context.progress.log( + f"Limiting decompile workers to {ceiling} " + f"({memory} MB available, {ASC_WORKER_MEMORY_MB} MB per worker)" + ) + chosen = ceiling + context.worker_count = max(1, chosen) + context.render_mode = "process" if context.worker_count > 1 else "single" + return context.worker_count + + +def _decompile( + context: ApkContext, decompiler_factory: Callable[[AscSession], Any] | None +) -> None: + session = _need(context.session) + classes = session.classes + if not classes: + context.progress.log("No DEX classes to decompile") + _remove_stale_sources(context, set()) + return + workers = _select_workers(context) + expected = {record.java_path for record in classes if record.java_path} + _remove_stale_sources(context, {path for path in expected if path}) + context.progress.log(f"Decompiling {len(classes)} classes with {workers} worker(s)") + with context.progress.bar( + total=len(classes), desc="decompile", unit="class" + ) as bar: + if decompiler_factory is not None or workers <= 1: + _decompile_serial(context, decompiler_factory, bar) + else: + try: + _decompile_parallel(context, workers, bar) + except _PoolFailure as exc: + context.progress.log( + f"Worker pool failed ({exc}); finishing remaining classes in-process" + ) + context.render_mode = "single-fallback" + _decompile_serial(context, None, bar) + context.progress.log( + f"Decompiled {len(classes) - len(context.failures)} classes, " + f"{len(context.failures)} failed" + ) + + +class _PoolFailure(RuntimeError): + pass + + +def _pending_classes(context: ApkContext) -> list[ClassRecord]: + session = _need(context.session) + return [ + record + for record in session.classes + if record.line_count == 0 and record.error is None + ] + + +def _decompile_serial( + context: ApkContext, factory: Callable[[AscSession], Any] | None, bar: Any +) -> None: + session = _need(context.session) + decompiler = factory(session) if factory is not None else InlineDecompiler(session) + for record in _pending_classes(context): + result = decompiler.decompile(record) + _store_result(context, record, result) + bar.update(1) + + +def _decompile_parallel(context: ApkContext, workers: int, bar: Any) -> None: + pending = _pending_classes(context) + by_descriptor = {record.descriptor: record for record in pending} + # Group by dex so each worker keeps its DexManager hot for long stretches. + ordered = sorted(pending, key=lambda record: (record.dex, record.index)) + batches: list[list[tuple[str, str, int]]] = [] + for start in range(0, len(ordered), DECOMPILE_BATCH_SIZE): + chunk = ordered[start : start + DECOMPILE_BATCH_SIZE] + batches.append([(item.dex, item.descriptor, item.index) for item in chunk]) + context.progress.file_log( + f"decompile: {len(batches)} batches of up to {DECOMPILE_BATCH_SIZE} classes" + ) + # Workers are recycled between rounds to keep their resident size bounded. + # (``max_tasks_per_child`` is not used: it deadlocks spawn pools on some + # CPython builds, e.g. 3.14.4, after the first recycle.) + round_size = workers * ASC_WORKER_BATCHES_PER_CHILD + for start in range(0, len(batches), round_size): + _run_decompile_round( + context, workers, batches[start : start + round_size], by_descriptor, bar + ) + + +def _run_decompile_round( + context: ApkContext, + workers: int, + batches: list[list[tuple[str, str, int]]], + by_descriptor: dict[str, ClassRecord], + bar: Any, +) -> None: + apk_set = _need(context.apk_set) + executor = ProcessPoolExecutor( + max_workers=min(workers, len(batches)), + mp_context=multiprocessing.get_context("spawn"), + initializer=init_decompile_worker, + initargs=(apk_set,), + ) + try: + futures: dict[Future[list[DecompiledClass]], list[tuple[str, str, int]]] = {} + for batch in batches: + futures[executor.submit(decompile_batch_in_worker, batch)] = batch + for future in as_completed(futures): + batch = futures[future] + try: + results = future.result() + except Exception as exc: + raise _PoolFailure(f"{type(exc).__name__}: {exc}") from exc + for result in results: + record = by_descriptor.get(result.descriptor) + if record is not None: + _store_result(context, record, result) + bar.update(len(batch)) + finally: + executor.shutdown(wait=True, cancel_futures=True) + + +def _store_result( + context: ApkContext, record: ClassRecord, result: DecompiledClass +) -> None: + session = _need(context.session) + root = _need(context.root) + session.apply_refs(record, result.refs) + path = _need(record.java_path) + if result.source is None: + record.error = result.error or "unknown decompilation error" + context.failures.append((record.descriptor, record.error)) + text = _failure_stub(record) + context.progress.file_log(f"export {record.descriptor} failed: {record.error}") + else: + text = result.source if result.source.endswith("\n") else result.source + "\n" + text = _annotate_source(record, text) + write_text_atomic(path, text) + record.line_count = text.count("\n") + assign_method_lines(record, text) + context.source_files.append(path) + if record.error is None: + context.progress.file_log( + f"export {record.descriptor} - {record.line_count} lines done " + f"({path.relative_to(root).as_posix()})" + ) + + +def _annotate_source(record: ClassRecord, text: str) -> str: + header = ( + f"// ToCode: {record.descriptor} from {record.dex}" + + (f" (source: {record.source_file})" if record.source_file else "") + + "\n" + ) + return header + text + + +def _failure_stub(record: ClassRecord) -> str: + lines = [ + f"// ToCode: {record.descriptor} from {record.dex}", + f"// decompilation failed: {record.error}", + f"package {record.package};" if record.package else "", + f"{' '.join(record.flag_names)} class {record.simple_name} {{", + ] + for method in record.methods: + lines.append( + f" {' '.join(method.flag_names)} {method.prototype};".replace(" ", " ") + ) + lines.append("}") + return "\n".join(line for line in lines if line is not None) + "\n" + + +def assign_method_lines(record: ClassRecord, text: str) -> None: + """Best-effort mapping of each method to its line range in the Java text. + + DAD prints constructors and the static initializer under the class's + simple name (``Outer$Inner(...)`` / ``static Outer$Inner()``), so those are + keyed by that name and told apart by the ``static`` modifier. + """ + lines = text.splitlines() + simple = record.simple_name + by_name: dict[str, list[MethodRecord]] = {} + for method in record.methods: + method.line_start = None + method.line_end = None + name = simple if method.name in {"", ""} else method.name + by_name.setdefault(name, []).append(method) + starts: list[tuple[int, str, int, bool, bool]] = [] + for number, line in enumerate(lines, start=1): + match = METHOD_DECL_RX.match(line) + if not match: + continue + name = match.group("name") + if name not in by_name: + continue + count = _param_count(match.group("params").strip()) + has_body = match.group("end") != ";" + is_static = " static " in f" {match.group('decl')[: match.start('name') - 4]} " + starts.append((number, name, count, has_body, is_static)) + total = len(lines) + for position, (start, name, count, has_body, is_static) in enumerate(starts): + candidates = [m for m in by_name.get(name, []) if m.line_start is None] + if name == simple: + wanted = "" if is_static else "" + special = [m for m in candidates if m.name == wanted] + candidates = special or [ + m for m in candidates if m.name not in {"", ""} + ] + if not candidates: + continue + chosen = next((m for m in candidates if len(m.params) == count), candidates[0]) + # Annotations directly above the declaration belong to the method. + first = start + while first > 1 and lines[first - 2].startswith(" @"): + first -= 1 + if not has_body: + end = start + else: + closing = _closing_brace(lines, start) + if closing is None: + closing = ( + starts[position + 1][0] - 1 if position + 1 < len(starts) else total + ) + end = closing + chosen.line_start = first + chosen.line_end = end + if record.error is not None: + for method in record.methods: + if method.line_start is None: + method.line_start = 1 + method.line_end = max(total, 1) + + +def _param_count(params: str) -> int: + if not params: + return 0 + depth = 0 + count = 1 + for ch in params: + if ch in "<([": + depth += 1 + elif ch in ">)]": + depth -= 1 + elif ch == "," and depth == 0: + count += 1 + return count + + +def _closing_brace(lines: list[str], start: int) -> int | None: + for number in range(start, len(lines) + 1): + if lines[number - 1] == " }": + return number + return None + + +def _remove_stale_sources(context: ApkContext, expected: set[Path]) -> None: + raw_dir = _need(context.raw_dir) + if not raw_dir.exists(): + return + removed = 0 + for path in raw_dir.rglob("*.java"): + if path not in expected: + path.unlink(missing_ok=True) + removed += 1 + if removed: + context.progress.file_log(f"removed {removed} stale source files") + + +def _write_metadata(context: ApkContext) -> None: + root = _need(context.root) + session = _need(context.session) + with context.progress.bar(total=9, desc="metadata", unit="doc") as bar: + # The class/method/string documents are written row by row: on a large + # APK they reach hundreds of megabytes, which cannot be built as one + # Python structure plus its serialized copy in memory. + meta.write_json_rows( + root / "classes.json", + "classes", + meta.iter_class_rows(session, root), + header={"count": len(session.classes)}, + ) + bar.update(1) + meta.write_json_rows( + root / "functions.json", + "functions", + meta.iter_function_rows(session, root), + ) + bar.update(1) + meta.write_json_rows( + root / "function-index.json", + "functions", + meta.iter_function_index_rows(session, root), + header={"schema_version": 2, "language": "java"}, + ) + bar.update(1) + meta.write_json_rows( + root / "strings.json", "strings", meta.iter_string_rows(session) + ) + bar.update(1) + write_json(root / "imports.json", meta.imports_json(session)) + bar.update(1) + write_json( + root / "exports.json", meta.exports_json(session, context.manifest_doc) + ) + bar.update(1) + write_json(root / "sections.json", meta.sections_json(context.entries)) + bar.update(1) + write_json(root / "package-graph.json", meta.package_graph_json(session)) + bar.update(1) + seeds = meta.entry_methods(session, context.manifest_doc) + context.reachable = meta.reachable_json(session, seeds) + write_json(root / "reachable.json", context.reachable) + bar.update(1) + + +def _write_final_documents(context: ApkContext) -> None: + root = _need(context.root) + session = _need(context.session) + reachable = context.reachable + libs_doc = native_libs_json(context.libs, root) + write_json( + root / "triage.json", + meta.triage_json( + session, context.manifest_doc, reachable, libs_doc["libs"], context.entries + ), + ) + apk_set = _need(context.apk_set) + native_exports = [ + { + "abi": lib.abi, + "name": lib.name, + "export_dir": str(lib.export_dir) if lib.export_dir else None, + "status": lib.status, + "backend": lib.backend, + } + for lib in context.libs + ] + common: dict[str, Any] = { + "binary": str(context.input_path), + "apks": [str(item) for item in apk_set.apks], + "package": context.package, + "backend": "asc", + "decompiler": AscSession.decompiler_label, + "root_dir": str(root), + "src_dir": str(_need(context.raw_dir)), + "raw_src_dir": str(_need(context.raw_dir)), + "tree_src_dir": None, + "include_dir": None, + "data_dir": str(_need(context.data_dir)), + "header": None, + "agents": str(root / "AGENTS.md"), + "claude": str(root / "CLAUDE.md"), + "log": str(root / "tocode.log"), + "manifest_xml": str(context.manifest_xml_path) + if context.manifest_xml_path + else None, + "manifest": str(root / "manifest.json"), + "ida_database": None, + "source_files": [str(item) for item in context.source_files], + "tree_source_files": [], + "summary_files": [], + "asm_files": [], + "function_index": str(root / "function-index.json"), + "tree_function_index": None, + "function_count": len(session.methods), + "class_count": len(session.classes), + "cluster_count": 0, + "failure_count": len(context.failures), + "requested_worker_count": context.options.jobs + if context.options.jobs is not None + else "auto", + "worker_count": context.worker_count, + "parallel_mode": context.render_mode, + "native_enabled": context.options.native, + "native_exports": native_exports, + } + write_json(root / "project.json", common) + manifest: dict[str, Any] = dict(common) + manifest.update( + { + "schema_version": 2, + "tocode_version": __version__, + "format": "apk", + "arch": "dalvik", + "bits": 32, + "entrypoints": context.manifest_doc.get("main_activities", []), + "raw_source_files": [str(item) for item in context.source_files], + "data_variable_count": 0, + "triage": str(root / "triage.json"), + "imports": str(root / "imports.json"), + "exports": str(root / "exports.json"), + "reachable": str(root / "reachable.json"), + "cluster_graph": None, + "package_graph": str(root / "package-graph.json"), + "classes": str(root / "classes.json"), + "strings": str(root / "strings.json"), + "sections": str(root / "sections.json"), + "native_libs": str(root / "native-libs.json"), + "resources": str(_need(context.data_dir) / "resources.json"), + "resource_files": len(context.resource_files), + "types": None, + "type_count": 0, + "variables_interesting": None, + "dex_files": [image.name for image in session.images], + "failures": [ + {"address": None, "name": descriptor, "error": error} + for descriptor, error in context.failures + ], + "native_errors": context.native_runner.errors + if context.native_runner + else [], + } + ) + write_json(root / "export-manifest.json", manifest) + write_text_atomic( + root / "AGENTS.md", + meta.build_apk_agents( + package=context.package, + native=context.options.native and bool(context.libs), + native_count=len(context.libs), + ) + + "\n", + ) + write_text_atomic(root / "CLAUDE.md", "@./AGENTS.md\n") + context.progress.log("Wrote project.json, export-manifest.json, AGENTS.md") + + +def _summary(context: ApkContext) -> ApkExportSummary: + session = context.session + apk_set = _need(context.apk_set) + return ApkExportSummary( + root_dir=_need(context.root), + package=context.package, + apks=list(apk_set.apks), + class_count=len(session.classes) if session else 0, + method_count=len(session.methods) if session else 0, + source_files=list(context.source_files), + failed_classes=list(context.failures), + native_total=len(context.libs), + native_done=sum(1 for lib in context.libs if lib.status == "done"), + native_errors=list(context.native_runner.errors) + if context.native_runner + else [], + manifest_path=context.manifest_xml_path, + seconds=time.monotonic() - context.started, + ) + + +def _need(value: Any) -> Any: + if value is None: + raise RuntimeError("export context not initialized") + return value + + +def iter_source_files(root: Path) -> Iterable[Path]: + yield from sorted((root / "src" / "raw").rglob("*.java")) diff --git a/src/tocode/apk_metadata.py b/src/tocode/apk_metadata.py new file mode 100644 index 0000000..753ca10 --- /dev/null +++ b/src/tocode/apk_metadata.py @@ -0,0 +1,1012 @@ +"""JSON metadata documents for APK exports. + +The document shapes mirror ``metadata.py`` wherever a native concept has a +Dalvik equivalent (functions, strings, imports, exports, sections, reachable, +triage, project, export-manifest) so agents and tooling that already read +ToCode exports keep working. Android-only facts live in ``manifest.json``, +``classes.json``, ``package-graph.json``, and ``native-libs.json``. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +import json +from pathlib import Path +import re +import tempfile +from typing import Any, Iterable, Iterator +import xml.etree.ElementTree as ET + +from .backends.asc import ( + AscSession, + ClassRecord, + MethodRecord, + StringRecord, + java_type, +) +from .metadata import _replace_with_retry + + +ANDROID_NS = "http://schemas.android.com/apk/res/android" +_A = f"{{{ANDROID_NS}}}" + +COMPONENT_TAGS = ("activity", "activity-alias", "service", "receiver", "provider") +DANGEROUS_PERMISSION_HINTS = ( + "READ_SMS", + "SEND_SMS", + "RECEIVE_SMS", + "READ_CONTACTS", + "WRITE_CONTACTS", + "READ_CALL_LOG", + "WRITE_CALL_LOG", + "CALL_PHONE", + "RECORD_AUDIO", + "CAMERA", + "ACCESS_FINE_LOCATION", + "ACCESS_COARSE_LOCATION", + "ACCESS_BACKGROUND_LOCATION", + "READ_EXTERNAL_STORAGE", + "WRITE_EXTERNAL_STORAGE", + "MANAGE_EXTERNAL_STORAGE", + "REQUEST_INSTALL_PACKAGES", + "SYSTEM_ALERT_WINDOW", + "BIND_ACCESSIBILITY_SERVICE", + "BIND_DEVICE_ADMIN", + "READ_PHONE_STATE", + "GET_ACCOUNTS", + "BLUETOOTH_CONNECT", + "QUERY_ALL_PACKAGES", + "RECEIVE_BOOT_COMPLETED", + "PACKAGE_USAGE_STATS", + "BIND_NOTIFICATION_LISTENER_SERVICE", +) +# Strings worth reading first, in priority order (URLs/endpoints and secrets +# before generic crypto and platform hints). Each entry is (tier, pattern). +INTERESTING_STRING_TIERS: tuple[tuple[int, re.Pattern[str]], ...] = ( + ( + 0, + re.compile( + r"(https?://|wss?://|ftp://|content://|\.onion\b|" + r"BEGIN (RSA|EC|OPENSSH|PGP)? ?PRIVATE KEY|" + r"\b(api[_-]?key|secret[_-]?key|client[_-]?secret|access[_-]?token|" + r"bearer|authorization|passw(or)?d)\b)", + re.IGNORECASE, + ), + ), + ( + 1, + re.compile( + r"(/data/(local|data)/|/system/(bin|xbin|app)/|/proc/self|/dev/socket|" + r"/bin/sh\b|\bsu\b|superuser|magisk|\bxposed|\bfrida|substrate|" + r"\b(goldfish|ranchu|genymotion|bluestacks|qemu)\b|" + r"System\.loadLibrary|dlopen|Runtime\.exec|ptrace|" + r"\b\d{1,3}(\.\d{1,3}){3}(:\d{2,5})?\b)", + re.IGNORECASE, + ), + ), + ( + 2, + re.compile( + r"^(javax\.crypto|AES/|RSA/|DES/|DESede/|HmacSHA|PBKDF2|SHA-?256|" + r"SecretKeySpec|network_security_config|DisabledHostnameVerifier)", + ), + ), +) + + +def write_json_rows( + path: Path, + key: str, + rows: Iterable[dict[str, Any]], + *, + header: dict[str, Any] | None = None, +) -> int: + """Write ``{**header, key: [...rows]}`` without holding the array in memory. + + The big APK documents (one row per method, class, or string) reach hundreds + of megabytes; ``json.dumps`` on the whole structure would need several + copies of that at once, so rows are serialized one at a time straight into + the file and the result is renamed into place like ``write_json``. + """ + path.parent.mkdir(parents=True, exist_ok=True) + count = 0 + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + tmp_path = Path(handle.name) + handle.write("{\n") + for name, value in (header or {}).items(): + handle.write(f" {json.dumps(name)}: {json.dumps(value)},\n") + handle.write(f" {json.dumps(key)}: [") + for row in rows: + handle.write("\n " if count == 0 else ",\n ") + handle.write(json.dumps(row)) + count += 1 + handle.write("\n ]\n" if count else "]\n") + handle.write("}\n") + try: + _replace_with_retry(tmp_path, path) + except OSError: + tmp_path.unlink(missing_ok=True) + raise + return count + + +@dataclass(slots=True) +class ManifestComponent: + kind: str + name: str + exported: bool | None + permission: str | None + intent_filters: list[dict[str, list[str]]] + attributes: dict[str, str] + + +@dataclass(slots=True) +class ManifestInfo: + apk: str + package: str + version_code: str | None + version_name: str | None + min_sdk: str | None + target_sdk: str | None + compile_sdk: str | None + split: str | None + permissions: list[str] + defined_permissions: list[str] + features: list[dict[str, Any]] + libraries: list[str] + application: dict[str, Any] + components: list[ManifestComponent] + main_activities: list[str] + parse_error: str | None = None + raw_attributes: dict[str, str] = field(default_factory=dict) + + +def _attr(node: ET.Element, name: str) -> str | None: + value = node.get(f"{_A}{name}") + if value is None: + value = node.get(name) + return value + + +def _bool_attr(node: ET.Element, name: str) -> bool | None: + value = _attr(node, name) + if value is None: + return None + return value.strip().lower() == "true" + + +def _plain_attrs(node: ET.Element) -> dict[str, str]: + result: dict[str, str] = {} + for key, value in node.attrib.items(): + result[key.replace(_A, "android:")] = value + return result + + +def parse_manifest(xml_text: str, *, apk_name: str) -> ManifestInfo: + try: + root = ET.fromstring(xml_text) + except ET.ParseError as exc: + return ManifestInfo( + apk=apk_name, + package="", + version_code=None, + version_name=None, + min_sdk=None, + target_sdk=None, + compile_sdk=None, + split=None, + permissions=[], + defined_permissions=[], + features=[], + libraries=[], + application={}, + components=[], + main_activities=[], + parse_error=str(exc), + ) + uses_sdk = root.find("uses-sdk") + application = root.find("application") + components: list[ManifestComponent] = [] + main_activities: list[str] = [] + app_attrs: dict[str, Any] = {} + if application is not None: + app_attrs = _plain_attrs(application) + for tag in COMPONENT_TAGS: + for node in application.iter(tag): + component = _component(tag, node) + components.append(component) + if tag in {"activity", "activity-alias"} and _is_launcher(component): + main_activities.append(component.name) + return ManifestInfo( + apk=apk_name, + package=root.get("package", "") or "", + version_code=_attr(root, "versionCode"), + version_name=_attr(root, "versionName"), + min_sdk=_attr(uses_sdk, "minSdkVersion") if uses_sdk is not None else None, + target_sdk=_attr(uses_sdk, "targetSdkVersion") + if uses_sdk is not None + else None, + compile_sdk=_attr(root, "compileSdkVersion"), + split=root.get("split"), + permissions=[ + value + for value in (_attr(node, "name") for node in root.iter("uses-permission")) + if value + ] + + [ + value + for value in ( + _attr(node, "name") for node in root.iter("uses-permission-sdk-23") + ) + if value + ], + defined_permissions=[ + value + for value in (_attr(node, "name") for node in root.iter("permission")) + if value + ], + features=[ + { + "name": _attr(node, "name"), + "required": _bool_attr(node, "required"), + "glEsVersion": _attr(node, "glEsVersion"), + } + for node in root.iter("uses-feature") + ], + libraries=[ + value + for value in ( + _attr(node, "name") + for node in list(root.iter("uses-library")) + + list(root.iter("uses-native-library")) + ) + if value + ], + application=app_attrs, + components=components, + main_activities=main_activities, + raw_attributes={ + key.replace(_A, "android:"): value for key, value in root.attrib.items() + }, + ) + + +def _component(tag: str, node: ET.Element) -> ManifestComponent: + filters: list[dict[str, list[str]]] = [] + for item in node.findall("intent-filter"): + filters.append( + { + "actions": [ + value + for value in ( + _attr(child, "name") for child in item.findall("action") + ) + if value + ], + "categories": [ + value + for value in ( + _attr(child, "name") for child in item.findall("category") + ) + if value + ], + "data": [ + " ".join( + f"{key}={value}" + for key, value in sorted(_plain_attrs(child).items()) + ) + for child in item.findall("data") + ], + } + ) + exported = _bool_attr(node, "exported") + if exported is None and filters: + # Pre-API 31 default: components with intent filters are exported. + exported = True + name = _attr(node, "name") or "" + if tag == "activity-alias": + name = _attr(node, "targetActivity") or name + return ManifestComponent( + kind=tag, + name=name, + exported=exported, + permission=_attr(node, "permission"), + intent_filters=filters, + attributes=_plain_attrs(node), + ) + + +def _is_launcher(component: ManifestComponent) -> bool: + for item in component.intent_filters: + if "android.intent.action.MAIN" in item["actions"] and ( + "android.intent.category.LAUNCHER" in item["categories"] + or "android.intent.category.LEANBACK_LAUNCHER" in item["categories"] + ): + return True + return False + + +def resolve_component_class(package: str, name: str) -> str: + if not name: + return name + if name.startswith("."): + return f"{package}{name}" + if "." not in name and package: + return f"{package}.{name}" + return name + + +def class_descriptor(java_name: str) -> str: + return f"L{java_name.replace('.', '/')};" + + +def _component_json(package: str, component: ManifestComponent) -> dict[str, Any]: + resolved = resolve_component_class(package, component.name) + return { + "kind": component.kind, + "name": resolved, + "descriptor": class_descriptor(resolved) if resolved else None, + "exported": component.exported, + "permission": component.permission, + "intent_filters": component.intent_filters, + "attributes": component.attributes, + } + + +def manifest_json( + base: ManifestInfo, splits: list[ManifestInfo], *, xml_path: Path +) -> dict[str, Any]: + package = base.package + return { + "package": package, + "apk": base.apk, + "manifest_xml": str(xml_path), + "version_code": base.version_code, + "version_name": base.version_name, + "min_sdk": base.min_sdk, + "target_sdk": base.target_sdk, + "compile_sdk": base.compile_sdk, + "attributes": base.raw_attributes, + "application": base.application, + "main_activities": [ + resolve_component_class(package, item) for item in base.main_activities + ], + "permissions": base.permissions, + "dangerous_permissions": dangerous_permissions(base.permissions), + "defined_permissions": base.defined_permissions, + "uses_features": base.features, + "uses_libraries": base.libraries, + "components": [_component_json(package, item) for item in base.components], + "exported_components": [ + _component_json(package, item) for item in base.components if item.exported + ], + "parse_error": base.parse_error, + "splits": [ + { + "apk": item.apk, + "split": item.split, + "package": item.package, + "attributes": item.raw_attributes, + "parse_error": item.parse_error, + } + for item in splits + ], + } + + +def dangerous_permissions(permissions: Iterable[str]) -> list[str]: + return [ + item + for item in permissions + if any(item.endswith(hint) for hint in DANGEROUS_PERMISSION_HINTS) + ] + + +# -------------------------------------------------------------------------- +# Classes / functions +# -------------------------------------------------------------------------- + + +def _relpath(path: Path | None, root: Path) -> str | None: + if path is None: + return None + try: + return path.relative_to(root).as_posix() + except ValueError: + return str(path) + + +def _method_json( + method: MethodRecord, session: AscSession, root: Path +) -> dict[str, Any]: + owner = session.class_by_descriptor(method.class_descriptor) + return { + "address": method.address, + "dex": method.dex, + "method_index": method.index, + "name": method.name, + "class": java_type(method.class_descriptor), + "descriptor": method.descriptor, + "prototype": method.prototype, + "access": method.flag_names, + "native": method.is_native, + "abstract": method.is_abstract, + "static": method.is_static, + "code_size": method.code_size, + "registers": method.registers, + "source_file": _relpath(owner.java_path, root) if owner else None, + "source_line_start": method.line_start, + "source_line_end": method.line_end, + } + + +def iter_class_rows(session: AscSession, root: Path) -> Iterator[dict[str, Any]]: + for record in session.classes: + yield { + "descriptor": record.descriptor, + "name": record.java_name, + "package": record.package, + "simple_name": record.simple_name, + "dex": record.dex, + "class_index": record.index, + "access": record.flag_names, + "interface": record.is_interface, + "superclass": java_type(record.superclass) if record.superclass else None, + "interfaces": [java_type(item) for item in record.interfaces], + "source_file": record.source_file, + "java_file": _relpath(record.java_path, root), + "line_count": record.line_count, + "decompile_error": record.error, + "method_count": len(record.methods), + "native_method_count": sum(1 for m in record.methods if m.is_native), + "methods": [ + { + "name": item.name, + "address": item.address, + "prototype": item.prototype, + "access": item.flag_names, + "native": item.is_native, + "code_size": item.code_size, + "line_start": item.line_start, + "line_end": item.line_end, + } + for item in record.methods + ], + "fields": [ + { + "name": item.name, + "type": java_type(item.type), + "descriptor": item.type, + "access": item.flag_names, + "static": item.is_static, + } + for item in record.fields + ], + } + + +def iter_function_rows(session: AscSession, root: Path) -> Iterator[dict[str, Any]]: + by_id = {method.id: method for method in session.methods} + for method in session.methods: + owner = session.class_by_descriptor(method.class_descriptor) + yield { + "address": method.address, + "name": method.descriptor, + "c_name": f"{java_type(method.class_descriptor)}.{method.name}", + "prototype": method.prototype, + "size": method.code_size, + "calltype": "static" if method.is_static else "virtual", + "return_type": java_type(method.return_type), + "params": [ + {"name": f"p{index}", "type": java_type(item)} + for index, item in enumerate(method.params) + ], + "locals": [], + "decl_file": owner.source_file if owner else None, + "decl_dir": None, + "decl_line": None, + "nargs": len(method.params), + "nlocals": max(0, method.registers - len(method.params)), + "stackframe": method.registers, + "callees": [ + by_id[item].address for item in method.callees if item in by_id + ], + "callees_imports": method.external_calls, + "callee_count": len(method.callees) + len(method.external_calls), + "callers": [ + by_id[item].address for item in method.callers if item in by_id + ], + "caller_count": len(method.callers), + "dead_code": not method.callers + and not (owner and _is_entry_class(owner)) + and method.name not in {"", ""}, + "source_file": _relpath(owner.java_path, root) if owner else None, + "source_line_start": method.line_start, + "source_line_end": method.line_end, + "tree_source_file": None, + "tree_source_line_start": None, + "tree_source_line_end": None, + "asm_file": None, + "asm_line_start": None, + "asm_line_end": None, + "dex": method.dex, + "class": java_type(method.class_descriptor), + "access": method.flag_names, + "native": method.is_native, + "string_ref_count": method.string_ref_count, + "field_refs": method.field_refs, + } + + +def _is_entry_class(record: ClassRecord) -> bool: + return record.entry + + +def iter_function_index_rows( + session: AscSession, root: Path +) -> Iterator[dict[str, Any]]: + for method in session.methods: + owner = session.class_by_descriptor(method.class_descriptor) + java_file = _relpath(owner.java_path, root) if owner else None + yield { + "address": method.address, + "name": method.descriptor, + "c": { + "path": java_file, + "line_start": method.line_start, + "line_end": method.line_end, + } + if java_file + else None, + "asm": None, + "origin": {"file": owner.source_file, "line": None} + if owner and owner.source_file + else None, + } + + +# -------------------------------------------------------------------------- +# Strings / imports / exports / sections +# -------------------------------------------------------------------------- + + +def iter_string_rows(session: AscSession) -> Iterator[dict[str, Any]]: + by_id = {method.id: method for method in session.methods} + for item in session.strings: + xrefs = [ + { + "function": by_id[method_id].descriptor, + "address": by_id[method_id].address, + "access": "read", + } + for method_id in session.string_xrefs.get((item.dex, item.index), ()) + if method_id in by_id + ] + yield { + "vaddr": f"{item.dex}:0x{item.offset:x}", + "paddr": f"0x{item.offset:x}", + "size": len(item.value.encode("utf-8", errors="replace")), + "length": len(item.value), + "section": item.dex, + "type": "mutf8", + "value": item.value, + "xrefs": xrefs, + } + + +def imports_json(session: AscSession) -> dict[str, Any]: + groups: dict[str, dict[str, dict[str, Any]]] = {} + for method in session.methods: + for descriptor in method.external_calls: + owner = descriptor.split("->", 1)[0] + package = java_type(owner) + package = package.rsplit(".", 1)[0] if "." in package else package + entry = groups.setdefault(package, {}).setdefault( + descriptor, + { + "name": descriptor, + "address": None, + "delay": False, + "class": java_type(owner), + "callers": [], + }, + ) + if len(entry["callers"]) < 32: + entry["callers"].append(method.address) + imports: list[dict[str, Any]] = [] + for package, members in sorted(groups.items()): + rows: list[dict[str, Any]] = sorted( + members.values(), key=lambda row: str(row["name"]) + ) + imports.append({"dll": package, "functions": rows}) + return {"imports": imports} + + +def exports_json(session: AscSession, manifest: dict[str, Any]) -> dict[str, Any]: + rows: list[dict[str, Any]] = [] + for ordinal, component in enumerate(manifest.get("exported_components", [])): + record = ( + session.class_by_descriptor(component["descriptor"]) + if component.get("descriptor") + else None + ) + rows.append( + { + "ordinal": ordinal, + "name": component["name"], + "address": record.methods[0].address + if record and record.methods + else None, + "is_forwarder": False, + "forwarder_target": None, + "kind": component["kind"], + "descriptor": component["descriptor"], + "defined_in_dex": record is not None, + "intent_filters": component["intent_filters"], + } + ) + for method in session.methods: + if method.is_native: + rows.append( + { + "ordinal": len(rows), + "name": method.descriptor, + "address": method.address, + "is_forwarder": True, + "forwarder_target": jni_symbol(method), + "kind": "jni", + "descriptor": method.descriptor, + "defined_in_dex": True, + "intent_filters": [], + } + ) + return {"exports": rows} + + +def jni_symbol(method: MethodRecord) -> str: + def mangle(text: str) -> str: + out: list[str] = [] + for ch in text: + if ch == "_": + out.append("_1") + elif ch == ";": + out.append("_2") + elif ch == "[": + out.append("_3") + elif ch == "/" or ch == ".": + out.append("_") + elif ch.isascii() and ch.isalnum(): + out.append(ch) + else: + out.append(f"_0{ord(ch):04x}") + return "".join(out) + + owner = method.class_descriptor[1:-1] + return f"Java_{mangle(owner)}_{mangle(method.name)}" + + +@dataclass(slots=True) +class ApkEntry: + apk: str + name: str + size: int + compressed_size: int + kind: str + exported_path: str | None = None + + +def sections_json(entries: list[ApkEntry]) -> dict[str, Any]: + rows = [] + for item in entries: + rows.append( + { + "name": item.name, + "apk": item.apk, + "vaddr": None, + "paddr": None, + "size": item.size, + "vsize": item.compressed_size, + "type": item.kind, + "permissions": "r--", + "rwx": False, + "entropy": None, + "path": item.exported_path, + } + ) + return {"sections": rows} + + +# -------------------------------------------------------------------------- +# Reachability / triage / package graph +# -------------------------------------------------------------------------- + + +def entry_methods(session: AscSession, manifest: dict[str, Any]) -> list[MethodRecord]: + seeds: list[MethodRecord] = [] + seen: set[int] = set() + descriptors = [ + item["descriptor"] + for item in manifest.get("components", []) + if item.get("descriptor") + ] + app_name = manifest.get("application", {}).get("android:name") + if app_name: + descriptors.insert( + 0, class_descriptor(resolve_component_class(manifest["package"], app_name)) + ) + for descriptor in descriptors: + record = session.class_by_descriptor(descriptor) + if record is None: + continue + record.entry = True + for method in record.methods: + if method.id not in seen: + seen.add(method.id) + seeds.append(method) + return seeds + + +def reachable_json(session: AscSession, seeds: list[MethodRecord]) -> dict[str, Any]: + by_id = {method.id: method for method in session.methods} + depths: dict[int, int] = {} + queue: deque[int] = deque() + for seed in seeds: + if seed.id not in depths: + depths[seed.id] = 0 + queue.append(seed.id) + while queue: + current = queue.popleft() + depth = depths[current] + for callee in by_id[current].callees: + if callee not in depths: + depths[callee] = depth + 1 + queue.append(callee) + rows = [ + { + "address": by_id[item].address, + "name": by_id[item].descriptor, + "depth": depth, + } + for item, depth in sorted(depths.items(), key=lambda pair: (pair[1], pair[0])) + ] + return { + "reachable": rows, + "unreachable_count": len(session.methods) - len(depths), + "seed_count": len(seeds), + } + + +def interesting_strings( + strings: Iterable[StringRecord], *, limit: int = 100 +) -> list[dict[str, Any]]: + buckets: dict[int, list[dict[str, Any]]] = { + tier: [] for tier, _ in INTERESTING_STRING_TIERS + } + seen: set[str] = set() + for item in strings: + value = item.value + if len(value) < 6 or len(value) > 512 or value in seen: + continue + if value.startswith(("L", "[")) and value.endswith(";"): + continue # type descriptors + if value.startswith("(") and ")" in value: + continue # method prototypes + if value.startswith("SMAP\n") or value.endswith((".kt", ".java")): + continue # Kotlin source maps and source file names + for tier, pattern in INTERESTING_STRING_TIERS: + if pattern.search(value): + seen.add(value) + buckets[tier].append( + { + "value": value, + "address": f"{item.dex}:0x{item.offset:x}", + "tier": tier, + } + ) + break + rows: list[dict[str, Any]] = [] + for tier in sorted(buckets): + rows.extend(buckets[tier]) + return rows[:limit] + + +def triage_json( + session: AscSession, + manifest: dict[str, Any], + reachable: dict[str, Any], + native_libs: list[dict[str, Any]], + entries: list[ApkEntry], +) -> dict[str, Any]: + packages: dict[str, int] = {} + for record in session.classes: + packages[record.package] = packages.get(record.package, 0) + 1 + top_packages = sorted(packages.items(), key=lambda pair: (-pair[1], pair[0]))[:25] + native_methods = [ + { + "name": method.descriptor, + "address": method.address, + "jni_symbol": jni_symbol(method), + } + for method in session.methods + if method.is_native + ] + return { + "binary_type": "apk", + "arch": "dalvik", + "bits": 32, + "package": manifest.get("package"), + "version_name": manifest.get("version_name"), + "version_code": manifest.get("version_code"), + "min_sdk": manifest.get("min_sdk"), + "target_sdk": manifest.get("target_sdk"), + "main_activities": manifest.get("main_activities", []), + "application_class": manifest.get("application", {}).get("android:name"), + "debuggable": manifest.get("application", {}).get("android:debuggable"), + "allow_backup": manifest.get("application", {}).get("android:allowBackup"), + "uses_cleartext_traffic": manifest.get("application", {}).get( + "android:usesCleartextTraffic" + ), + "network_security_config": manifest.get("application", {}).get( + "android:networkSecurityConfig" + ), + "dangerous_permissions": manifest.get("dangerous_permissions", []), + "permission_count": len(manifest.get("permissions", [])), + "component_counts": _component_counts(manifest.get("components", [])), + "exported_components": [ + {"kind": item["kind"], "name": item["name"]} + for item in manifest.get("exported_components", []) + ], + "dex_files": [ + {"name": image.name, "size": image.size, "apk": image.source_apk.name} + for image in session.images + ], + "class_count": len(session.classes), + "method_count": len(session.methods), + "string_count": len(session.strings), + "top_packages": [ + {"package": name or "", "classes": count} + for name, count in top_packages + ], + "native_libs": [ + { + "abi": item["abi"], + "name": item["name"], + "status": item["status"], + "export_dir": item.get("export_dir"), + } + for item in native_libs + ], + "native_methods": native_methods, + "assets": [item.name for item in entries if item.kind == "asset"][:100], + "strings_of_interest": interesting_strings(session.strings), + "reachable_count": len(reachable.get("reachable", [])), + "unreachable_count": reachable.get("unreachable_count", 0), + } + + +def _component_counts(components: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for item in components: + counts[item["kind"]] = counts.get(item["kind"], 0) + 1 + return counts + + +def package_graph_json(session: AscSession) -> dict[str, Any]: + by_id = {method.id: method for method in session.methods} + calls: dict[str, dict[str, int]] = {} + external: dict[str, dict[str, int]] = {} + class_counts: dict[str, int] = {} + for record in session.classes: + class_counts[record.package] = class_counts.get(record.package, 0) + 1 + for method in session.methods: + source = _package_of(method.class_descriptor) + for callee in method.callees: + target = _package_of(by_id[callee].class_descriptor) + if target != source: + bucket = calls.setdefault(source, {}) + bucket[target] = bucket.get(target, 0) + 1 + for descriptor in method.external_calls: + target = _package_of(descriptor.split("->", 1)[0]) + bucket = external.setdefault(source, {}) + bucket[target] = bucket.get(target, 0) + 1 + packages = [] + for name in sorted(class_counts): + packages.append( + { + "package": name or "", + "folder": f"src/raw/{name.replace('.', '/')}" if name else "src/raw", + "classes": class_counts[name], + "calls_packages": [ + {"package": target or "", "calls": count} + for target, count in sorted( + calls.get(name, {}).items(), key=lambda pair: -pair[1] + ) + ], + "calls_external": [ + {"package": target, "calls": count} + for target, count in sorted( + external.get(name, {}).items(), key=lambda pair: -pair[1] + )[:50] + ], + } + ) + return {"packages": packages} + + +def _package_of(descriptor: str) -> str: + name = java_type(descriptor) + return name.rsplit(".", 1)[0] if "." in name else "" + + +# -------------------------------------------------------------------------- +# Generated AGENTS.md +# -------------------------------------------------------------------------- + + +def build_apk_agents(*, package: str, native: bool, native_count: int) -> str: + lines = [ + "# AGENTS", + "", + f"You are working inside a ToCode APK export of `{package}`.", + "Treat the recovered Java, the manifest, the metadata, and the extracted resources as evidence for reverse engineering.", + "", + "## Mission", + "", + "Reverse the app, answer user questions, or serve as an oracle/helper to the user regarding this recovered source code.", + "Write a report only when the user asks for one; otherwise keep findings focused on the question or task at hand.", + "Do not refactor or modify the generated export unless the user explicitly asks for edits.", + "Use subagents only for narrow evidence-gathering tasks such as strings, component entry paths, or one package family.", + "", + "## Files", + "", + "- `src/raw//.java`: decompiled Java (ASC + androguard DAD), one file per class, folders follow Java packages. Inner classes are `Outer$Inner.java` next to their outer class.", + "- `AndroidManifest.xml`: decoded manifest; `manifest.json`: parsed package, versions, SDKs, permissions, components with intent filters and exported state, application attributes, split manifests.", + "- `classes.json`: every class with superclass, interfaces, access flags, fields, methods (with `address`), source file, and Java file/line ranges.", + "- `functions.json`: per-method callers/callees, external (framework/library) calls, string/field/type references, prototypes, and source line ranges. `address` is `:`.", + "- `function-index.json`: exact Java file and line range for each method.", + "- `strings.json`: DEX string pool with the methods that load each string.", + "- `imports.json`: methods called but not defined in any DEX (Android framework, JDK, missing libraries), grouped by package.", + "- `exports.json`: exported manifest components and JNI `native` methods with their expected `Java_...` symbol.", + "- `reachable.json`: methods reachable from manifest components (Application, activities, services, receivers, providers) with call depth.", + "- `package-graph.json`: inter-package call graph.", + "- `sections.json`: every entry of the APK set (dex, native libs, resources, assets) with sizes.", + "- `triage.json`: first-read summary: package, entry points, dangerous permissions, exported components, native libs and methods, strings of interest, reachability counts.", + "- `native-libs.json`: every shared object found in the APK set with ABI, hash, and the status of its native decompilation.", + "- `lib//*.so`: extracted native libraries.", + ] + if native: + lines.append( + f"- `native///`: a complete nested ToCode export for each of the {native_count} native libraries (own `AGENTS.md`, `src/raw/*.c`, `functions.json`, `exports.json`, ...). Pair `Java___` exports there with the `native` methods listed in `exports.json` here.", + ) + else: + lines.append( + "- `native/`: not generated (`--no-native`); native libraries are only extracted under `lib/`.", + ) + lines.extend( + [ + "- `data/apk/**`: every non-code APK entry verbatim (assets, res, META-INF, resources.arsc).", + "- `data/res/**/*.xml`: binary XML resources decoded to text; `data/resources.json`: decoded `resources.arsc` string/resource tables.", + "- `project.json` and `export-manifest.json`: top-level export paths and artifact inventory.", + "- `tocode.log`: export history including native library decompilation status.", + "- `CLAUDE.md`: Claude entrypoint that references `AGENTS.md`.", + "", + "## Working Style", + "", + "- Start with `triage.json`, `manifest.json`, `strings.json`, and `reachable.json`.", + "- Use `functions.json` and `function-index.json` to open only the method line ranges you need.", + "- Follow `native` methods into `native///` exports when the logic lives in JNI code.", + "- Treat obfuscated names (`a.b.c`) as opaque; rely on string, field, and framework references to recover intent.", + "- Cite file paths, class and method names, and line numbers for every major claim.", + "- Be explicit about uncertainty, decompilation failures (`classes.json` `decompile_error`), and unresolved references.", + "", + ] + ) + return "\n".join(lines) diff --git a/src/tocode/apk_native.py b/src/tocode/apk_native.py new file mode 100644 index 0000000..872f1c0 --- /dev/null +++ b/src/tocode/apk_native.py @@ -0,0 +1,589 @@ +"""Native shared-object handling for APK exports. + +Every ``.so`` found in the APK set is extracted to ``lib//`` and, unless +``--no-native`` is given, decompiled with the regular ToCode pipeline +(``create_analyzer`` + ``export_binary``) into ``native///``. The +native work runs on a background thread so it overlaps with the DEX export; +one library failing never fails the APK export. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import multiprocessing +import os +from pathlib import Path +import queue as queue_mod +import re +import signal +import threading +import time +from typing import Any, Callable +import zipfile + +from .backends.base import BackendRequest +from .errors import ToCodeError +from .naming import clean_path_component +from .progress import Progress + + +ELF_MAGIC = b"\x7fELF" +# A decompiler backend needs room to analyze a library (r2's ``aaa`` on a few MB +# of ARM code can reach a gigabyte). The native thread runs next to the DEX +# decompile pool, so it waits for this much free memory before starting the next +# library instead of competing for the last megabytes and getting the whole run +# OOM-killed. The DEX side finishing is what usually releases it. +DEFAULT_NATIVE_MIN_FREE_MB = 1024 +NATIVE_MEMORY_POLL_SECONDS = 5.0 +NATIVE_MEMORY_MAX_WAIT_SECONDS = 1800.0 +# The nested export writes its own tocode.log; its lines are mirrored to the main +# output at this cadence so the terminal shows what the native side is doing. +NATIVE_LOG_TAIL_SECONDS = 0.5 +NATIVE_HEARTBEAT_SECONDS = 20.0 +_LOG_TIMESTAMP_RX = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\s+") +_RENDER_TOTAL_RX = re.compile(r"Rendering and writing (\d+) functions") +_FUNCTION_LINE_RX = re.compile(r"^export \S+ 0x[0-9a-f]+ - ") +KNOWN_ABIS = ("arm64-v8a", "armeabi-v7a", "armeabi", "x86_64", "x86", "mips", "mips64") + + +@dataclass(slots=True) +class NativeLib: + abi: str + name: str + entry: str + source_apk: str + size: int + sha256: str + path: Path + export_dir: Path | None = None + backend: str | None = None + decompiler: str | None = None + function_count: int | None = None + failure_count: int | None = None + status: str = "pending" + seconds: float | None = None + + @property + def stem(self) -> str: + return clean_path_component(Path(self.name).stem) + + def to_json(self, root: Path) -> dict[str, Any]: + return { + "abi": self.abi, + "name": self.name, + "entry": self.entry, + "source_apk": self.source_apk, + "size": self.size, + "sha256": self.sha256, + "path": _rel(self.path, root), + "export_dir": _rel(self.export_dir, root), + "backend": self.backend, + "decompiler": self.decompiler, + "function_count": self.function_count, + "failure_count": self.failure_count, + "status": self.status, + "seconds": round(self.seconds, 1) if self.seconds is not None else None, + } + + +@dataclass(slots=True) +class NativeOptions: + backend: BackendRequest = "auto" + analysis_command: str = "aaa" + idadir: Path | None = None + ida_domain_path: Path | None = None + purge_cache: bool = False + jobs: int | None = None + tree: bool = False + entropy: bool = False + + +def _rel(path: Path | None, root: Path) -> str | None: + if path is None: + return None + try: + return path.relative_to(root).as_posix() + except ValueError: + return str(path) + + +def classify_native_entry(name: str, head: bytes) -> tuple[str, str] | None: + """Return ``(abi, file name)`` when a zip entry is a shared object.""" + parts = name.split("/") + leaf = parts[-1] + if not leaf: + return None + is_so = leaf.endswith(".so") or ".so." in leaf + if len(parts) >= 3 and parts[0] == "lib": + if is_so or head.startswith(ELF_MAGIC): + return parts[1], leaf + if head.startswith(ELF_MAGIC) and (is_so or parts[0] in {"assets", "res"}): + abi = next((part for part in parts if part in KNOWN_ABIS), "unknown") + return abi, leaf + return None + + +def native_lib_path(root: Path, abi: str, name: str) -> Path: + return root / "lib" / clean_path_component(abi) / name + + +def native_export_dir(root: Path, lib: NativeLib) -> Path: + return root / "native" / clean_path_component(lib.abi) / lib.stem + + +NativeExporter = Callable[[NativeLib, Path, Progress], tuple[str, str, int, int]] + + +def run_native_export( + lib: NativeLib, out_dir: Path, options: NativeOptions, progress: Progress +) -> tuple[str, str, int, int]: + """Export one shared object with the regular binary pipeline.""" + from .analysis import create_analyzer + from .exporter import export_binary + + with create_analyzer( + lib.path, + backend=options.backend, + analysis_command=options.analysis_command, + progress=progress, + idadir=options.idadir, + ida_domain_path=options.ida_domain_path, + purge_cache=options.purge_cache, + ) as analyzer: + summary = export_binary( + analyzer, + out_dir=out_dir, + progress=progress, + jobs=options.jobs, + tree=options.tree, + entropy=options.entropy, + ) + return ( + analyzer.backend_name, + analyzer.decompiler_label, + summary.function_count, + len(summary.failed_functions), + ) + + +def _native_export_child( + lib: NativeLib, + out_dir: Path, + options: NativeOptions, + log_path: Path, + result_queue: Any, +) -> None: + """Entry point of the isolated per-library export process.""" + # Lead a process group of our own: the backend's worker pool (grandchildren + # of the APK export) can then be killed as one unit if this process dies. + if hasattr(os, "setsid"): + try: + os.setsid() + except OSError: # pragma: no cover - already a session leader + pass + progress = Progress(enabled=False) + progress.set_log_path(log_path) + _redirect_output(log_path) + try: + result = run_native_export(lib, out_dir, options, progress) + except ToCodeError as exc: + result_queue.put(("error", str(exc))) + except BaseException as exc: # pragma: no cover - backend crash isolation + result_queue.put(("error", f"{type(exc).__name__}: {exc}")) + else: + result_queue.put(("ok", result)) + + +_active_children: set[int] = set() +_active_lock = threading.Lock() + + +def kill_process_group(pid: int | None) -> None: + """Kill the process group led by ``pid`` (no-op where unsupported). + + An OOM-killed or interrupted native child leaves its decompiler workers + behind; they hold the multiprocessing resource tracker open and the APK + export would then hang at exit waiting for it. + """ + if pid is None or not hasattr(os, "killpg"): + return + try: + os.killpg(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + pass + + +def terminate_active_native_children() -> None: + with _active_lock: + pids = list(_active_children) + for pid in pids: + kill_process_group(pid) + + +def collect_child_result( + process: Any, result_queue: Any, *, poll_seconds: float = 1.0 +) -> tuple[str, str, int, int]: + """Wait for an isolated export process and turn its outcome into a result. + + A missing outcome means the child died without reporting: a negative exit + code is a signal (the OOM killer, typically), which becomes a per-library + ``ToCodeError`` instead of taking the APK export down. Whatever the child + left running is killed with its process group. + """ + outcome: tuple[str, Any] | None = None + try: + while True: + try: + outcome = result_queue.get(timeout=poll_seconds) + break + except queue_mod.Empty: + if not process.is_alive(): + break + finally: + process.join() + if process.pid is not None: + with _active_lock: + _active_children.discard(process.pid) + if outcome is None: + kill_process_group(process.pid) + code = process.exitcode + if code is not None and code < 0: + raise ToCodeError( + f"export process was killed by signal {-code} " + "(out of memory?); try -j 1 or --no-native" + ) + raise ToCodeError(f"export process exited with code {code}") + kind, payload = outcome + if kind == "error": + raise ToCodeError(str(payload)) + backend, decompiler, functions, failures = payload + return (str(backend), str(decompiler), int(functions), int(failures)) + + +def _redirect_output(log_path: Path) -> None: + """Send the backend's own stdout/stderr chatter into the nested log.""" + try: + handle = open(log_path, "ab", buffering=0) + os.dup2(handle.fileno(), 1) + os.dup2(handle.fileno(), 2) + except OSError: # pragma: no cover - keep the terminal if the log is unusable + return + + +def default_native_exporter(options: NativeOptions) -> NativeExporter: + """Run each library's export in its own process. + + A decompiler backend can use more memory than the host has (r2's ``aaa`` on + a large library), and it may be OOM-killed or crash. In a child process that + only fails that library; in-process it would take the whole APK export -- and + the finished DEX work -- down with it. + """ + + def run( + lib: NativeLib, out_dir: Path, progress: Progress + ) -> tuple[str, str, int, int]: + context = multiprocessing.get_context("spawn") + result_queue: Any = context.Queue() + process = context.Process( + target=_native_export_child, + args=( + lib, + out_dir, + options, + _need_log_path(progress, out_dir), + result_queue, + ), + name=f"tocode-native-{lib.stem}", + ) + process.start() + if process.pid is not None: + with _active_lock: + _active_children.add(process.pid) + return collect_child_result(process, result_queue) + + return run + + +def _need_log_path(progress: Progress, out_dir: Path) -> Path: + return ( + progress.log_path if progress.log_path is not None else out_dir / "tocode.log" + ) + + +class NestedLogTail: + """Mirror a nested export's ``tocode.log`` into the main progress. + + Per-function render lines drive a progress bar instead of being echoed + (there are thousands); every other line is shown as ``native[lib]: ...``. + """ + + def __init__( + self, + log_path: Path, + lib: NativeLib, + progress: Progress, + *, + poll_seconds: float = NATIVE_LOG_TAIL_SECONDS, + ) -> None: + self.log_path = log_path + self.lib = lib + self.progress = progress + self.poll_seconds = poll_seconds + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._offset = 0 + self._bar_cm: Any = None + self._bar: Any = None + self.rendered = 0 + self.total: int | None = None + self.last_line: str = "starting" + + def start(self) -> None: + self._thread = threading.Thread( + target=self._run, name=f"tocode-native-tail-{self.lib.stem}", daemon=True + ) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join() + self._drain() + self._close_bar() + + def _run(self) -> None: + while not self._stop.is_set(): + self._drain() + self._stop.wait(self.poll_seconds) + + def _drain(self) -> None: + try: + with self.log_path.open("rb") as handle: + handle.seek(self._offset) + chunk = handle.read() + except OSError: + return + if not chunk: + return + # Only consume complete lines; keep a partial tail for the next poll. + end = chunk.rfind(b"\n") + if end < 0: + return + self._offset += end + 1 + for raw in chunk[: end + 1].decode("utf-8", errors="replace").splitlines(): + self._handle_line(_LOG_TIMESTAMP_RX.sub("", raw).rstrip()) + + def _handle_line(self, line: str) -> None: + if not line: + return + if _FUNCTION_LINE_RX.match(line): + self.rendered += 1 + if self._bar is not None: + self._bar.update(1) + return + self.last_line = line + total = _RENDER_TOTAL_RX.search(line) + if total is not None: + self.total = int(total.group(1)) + self._open_bar() + self.progress.log(f"native[{self.lib.name}]: {line}") + + def _open_bar(self) -> None: + if self._bar is not None or self.total is None: + return + self._bar_cm = self.progress.bar( + total=self.total, desc=f"native {self.lib.name}", unit="fn" + ) + self._bar = self._bar_cm.__enter__() + if self.rendered: + self._bar.update(self.rendered) + + def _close_bar(self) -> None: + if self._bar_cm is not None: + self._bar_cm.__exit__(None, None, None) + self._bar_cm = None + self._bar = None + + def status(self) -> str: + if self.total: + return f"{self.rendered}/{self.total} functions" + return self.last_line + + +@dataclass +class NativeRunner: + """Runs the native exports on a background thread.""" + + libs: list[NativeLib] + root: Path + progress: Progress + exporter: NativeExporter + stop_event: threading.Event = field(default_factory=threading.Event) + thread: threading.Thread | None = None + errors: list[str] = field(default_factory=list) + min_free_mb: int = 0 + available_memory: Callable[[], int | None] = lambda: None + poll_seconds: float = NATIVE_MEMORY_POLL_SECONDS + max_wait_seconds: float = NATIVE_MEMORY_MAX_WAIT_SECONDS + mirror_logs: bool = True + current: NativeLib | None = None + current_started: float = 0.0 + current_tail: NestedLogTail | None = None + + def describe_current(self) -> str | None: + """One line about what the native thread is doing right now.""" + lib = self.current + if lib is None: + return None + elapsed = time.monotonic() - self.current_started + detail = self.current_tail.status() if self.current_tail else "running" + done = sum(1 for item in self.libs if item.status == "done") + finished = sum(1 for item in self.libs if item.status not in {"pending"}) + return ( + f"native: {lib.name} ({lib.abi}) {detail}, {elapsed:.0f}s elapsed; " + f"{finished}/{len(self.libs)} libraries finished, {done} exported" + ) + + def start(self) -> None: + self.thread = threading.Thread( + target=self._run, name="tocode-native", daemon=True + ) + self.thread.start() + + def join(self) -> None: + if self.thread is not None: + self.thread.join() + + def stop(self) -> None: + self.stop_event.set() + terminate_active_native_children() + + def _run(self) -> None: + for lib in self.libs: + if self.stop_event.is_set(): + lib.status = "skipped: interrupted" + continue + self._wait_for_memory(lib) + if self.stop_event.is_set(): + lib.status = "skipped: interrupted" + continue + self._export_one(lib) + + def _wait_for_memory(self, lib: NativeLib) -> None: + if self.min_free_mb <= 0: + return + waited = 0.0 + logged = False + while not self.stop_event.is_set() and waited < self.max_wait_seconds: + available = self.available_memory() + if available is None or available >= self.min_free_mb: + break + if not logged: + self.progress.log( + f"native: waiting for memory before {lib.name} ({lib.abi}): " + f"{available} MB free, need {self.min_free_mb} MB" + ) + logged = True + self.stop_event.wait(self.poll_seconds) + waited += self.poll_seconds + if logged and not self.stop_event.is_set(): + self.progress.file_log( + f"native: waited {waited:.0f}s for memory before {lib.name}" + ) + + def _export_one(self, lib: NativeLib) -> None: + out_dir = native_export_dir(self.root, lib) + lib.export_dir = out_dir + log_path = out_dir / "tocode.log" + child = Progress(enabled=False) + child.set_log_path(log_path) + self.progress.log( + f"native: {lib.name} ({lib.abi}) export started -> " + f"{_rel(out_dir, self.root)}" + ) + started = time.monotonic() + tail = NestedLogTail(log_path, lib, self.progress) if self.mirror_logs else None + self.current = lib + self.current_started = started + self.current_tail = tail + if tail is not None: + tail.start() + try: + backend, decompiler, functions, failures = self.exporter( + lib, out_dir, child + ) + except ToCodeError as exc: + lib.status = f"failed: {exc}" + except Exception as exc: # pragma: no cover - backend crash isolation + lib.status = f"failed: {type(exc).__name__}: {exc}" + else: + lib.backend = backend + lib.decompiler = decompiler + lib.function_count = functions + lib.failure_count = failures + lib.status = "done" + finally: + if tail is not None: + tail.stop() + self.current = None + self.current_tail = None + lib.seconds = time.monotonic() - started + if lib.status == "done": + self.progress.log( + f"native: {lib.name} ({lib.abi}) exported with {lib.backend} " + f"in {lib.seconds:.1f}s: functions={lib.function_count} " + f"failures={lib.failure_count}" + ) + else: + self.errors.append(f"{lib.abi}/{lib.name}: {lib.status}") + self.progress.log(f"native: {lib.name} ({lib.abi}) {lib.status}") + + +def extract_native_libs( + apks: list[Path], root: Path, *, sha256: Callable[[Path], str] +) -> list[NativeLib]: + """Extract every shared object of the APK set to ``lib//``.""" + libs: list[NativeLib] = [] + seen: set[tuple[str, str]] = set() + for apk in apks: + with zipfile.ZipFile(apk) as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + with archive.open(info) as handle: + head = handle.read(4) + classified = classify_native_entry(info.filename, head) + if classified is None: + continue + abi, name = classified + if (abi, name) in seen: + continue + seen.add((abi, name)) + target = native_lib_path(root, abi, name) + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info) as source, target.open("wb") as sink: + while True: + chunk = source.read(1 << 20) + if not chunk: + break + sink.write(chunk) + libs.append( + NativeLib( + abi=abi, + name=name, + entry=info.filename, + source_apk=apk.name, + size=info.file_size, + sha256=sha256(target), + path=target, + ) + ) + return libs + + +def native_libs_json(libs: list[NativeLib], root: Path) -> dict[str, Any]: + return { + "count": len(libs), + "done": sum(1 for item in libs if item.status == "done"), + "libs": [item.to_json(root) for item in libs], + } diff --git a/src/tocode/backends/asc.py b/src/tocode/backends/asc.py new file mode 100644 index 0000000..327b7e7 --- /dev/null +++ b/src/tocode/backends/asc.py @@ -0,0 +1,1012 @@ +"""ASC (droidasc) backend: APK/DEX inventory and per-class Java decompilation. + +ASC treats the APK as a read-only database, so this module keeps the same +shape: the parent process inflates every DEX once, walks the tinydex tables for +the inventory, and worker processes rebuild their own view of one DEX to +decompile classes. Nothing outside this module imports ``droidasc`` directly. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +import importlib +import importlib.util +import mmap +from pathlib import Path +import struct +import sys +from typing import Any +import zipfile + +from ..errors import ToCodeError + + +APK_SUFFIXES = frozenset({".apk"}) +# Per-method/per-string reference caps. Both tables are unbounded in principle +# (a large APK has millions of edges), and the tail is not useful for triage. +MAX_STRING_XREFS = 64 +MAX_FIELD_REFS = 32 +APK_BUNDLE_SUFFIXES = frozenset({".apks", ".xapk"}) +NO_INDEX = 0xFFFFFFFF + +# Dalvik access flags (dex format spec). +ACC_PUBLIC = 0x1 +ACC_PRIVATE = 0x2 +ACC_PROTECTED = 0x4 +ACC_STATIC = 0x8 +ACC_FINAL = 0x10 +ACC_SYNCHRONIZED = 0x20 +ACC_VOLATILE = 0x40 +ACC_BRIDGE = 0x40 +ACC_TRANSIENT = 0x80 +ACC_VARARGS = 0x80 +ACC_NATIVE = 0x100 +ACC_INTERFACE = 0x200 +ACC_ABSTRACT = 0x400 +ACC_STRICT = 0x800 +ACC_SYNTHETIC = 0x1000 +ACC_ANNOTATION = 0x2000 +ACC_ENUM = 0x4000 +ACC_CONSTRUCTOR = 0x10000 +ACC_DECLARED_SYNCHRONIZED = 0x20000 + +_CLASS_FLAG_NAMES = ( + (ACC_PUBLIC, "public"), + (ACC_PRIVATE, "private"), + (ACC_PROTECTED, "protected"), + (ACC_STATIC, "static"), + (ACC_FINAL, "final"), + (ACC_INTERFACE, "interface"), + (ACC_ABSTRACT, "abstract"), + (ACC_SYNTHETIC, "synthetic"), + (ACC_ANNOTATION, "annotation"), + (ACC_ENUM, "enum"), +) +_METHOD_FLAG_NAMES = ( + (ACC_PUBLIC, "public"), + (ACC_PRIVATE, "private"), + (ACC_PROTECTED, "protected"), + (ACC_STATIC, "static"), + (ACC_FINAL, "final"), + (ACC_SYNCHRONIZED, "synchronized"), + (ACC_BRIDGE, "bridge"), + (ACC_VARARGS, "varargs"), + (ACC_NATIVE, "native"), + (ACC_ABSTRACT, "abstract"), + (ACC_STRICT, "strictfp"), + (ACC_SYNTHETIC, "synthetic"), + (ACC_CONSTRUCTOR, "constructor"), + (ACC_DECLARED_SYNCHRONIZED, "declared-synchronized"), +) +_FIELD_FLAG_NAMES = ( + (ACC_PUBLIC, "public"), + (ACC_PRIVATE, "private"), + (ACC_PROTECTED, "protected"), + (ACC_STATIC, "static"), + (ACC_FINAL, "final"), + (ACC_VOLATILE, "volatile"), + (ACC_TRANSIENT, "transient"), + (ACC_SYNTHETIC, "synthetic"), + (ACC_ENUM, "enum"), +) + +# ``droidasc.asc_core.utils.decompiler`` replaces modules that are not yet +# imported with dummies (to make androguard import faster). Anything ToCode or +# androguard needs afterwards must therefore be loaded for real first. +_PRELOAD_MODULES = ( + "json", + "math", + "random", + "tempfile", + "shutil", + "bisect", + "weakref", + "bz2", + "lzma", + "multiprocessing", + "multiprocessing.connection", + "xml.sax.saxutils", + "xml.dom.minidom", + "email", + "email.parser", + "urllib", + "urllib.request", + "http.client", + "lxml", + "lxml.etree", + "asn1crypto", + "asn1crypto.x509", + "cryptography", + "loguru", + "click", + "colorama", + "dateutil", + "urllib3", + "requests", + "idna", + "chardet", + "certifi", + "androguard.core.axml", +) + +_asc_cache: dict[str, Any] | None = None + + +def is_apk_bundle(path: Path) -> bool: + return path.suffix.lower() in APK_BUNDLE_SUFFIXES + + +def is_apk_input(path: Path) -> bool: + suffix = path.suffix.lower() + return suffix in APK_SUFFIXES or suffix in APK_BUNDLE_SUFFIXES + + +def probe_asc() -> bool: + return importlib.util.find_spec("droidasc") is not None + + +def preload_real_modules() -> None: + for name in _PRELOAD_MODULES: + if name in sys.modules: + continue + try: + importlib.import_module(name) + except Exception: # pragma: no cover - optional third-party modules + continue + + +def quiet_android_loggers() -> None: + """androguard logs every instruction at DEBUG through loguru; silence it.""" + try: + from loguru import logger + + logger.remove() + except Exception: # pragma: no cover - loguru always ships with androguard + pass + import logging + + for name in ("androguard", "asc", "droidasc"): + logging.getLogger(name).disabled = True + logging.getLogger(name).setLevel(logging.CRITICAL) + + +def asc_modules() -> dict[str, Any]: + """Import droidasc lazily (it is slow and patches androguard at import).""" + global _asc_cache + if _asc_cache is not None: + return _asc_cache + if not probe_asc(): + raise ToCodeError( + "APK input requires the ASC backend: install droidasc " + "(`pip install droidasc`)" + ) + preload_real_modules() + quiet_android_loggers() + apk_handler = importlib.import_module("droidasc.asc_client.apk_handler") + dex_container = importlib.import_module("droidasc.asc_client.dex_container") + manifest_handler = importlib.import_module("droidasc.asc_client.manifest_handler") + tinydex = importlib.import_module("droidasc.asc_core.utils.tinydex") + dvm_opcode = importlib.import_module("droidasc.asc_core.models.dvm_opcode") + _asc_cache = { + "apk_handler": apk_handler, + "dex_container": dex_container, + "manifest_handler": manifest_handler, + "tinydex": tinydex, + "dvm_opcode": dvm_opcode, + } + return _asc_cache + + +def _decompiler_modules() -> tuple[Any, Any]: + mods = asc_modules() + if "dex_manager" not in mods: + preload_real_modules() + quiet_android_loggers() + mods["dex_manager"] = importlib.import_module( + "droidasc.asc_core.core.dex.dex_manager" + ) + mods["decompiler"] = importlib.import_module( + "droidasc.asc_core.utils.decompiler" + ) + return mods["dex_manager"], mods["decompiler"] + + +# -------------------------------------------------------------------------- +# APK set discovery (bundles and split APKs) +# -------------------------------------------------------------------------- + + +@dataclass(slots=True) +class ApkSet: + primary: Path + apks: list[Path] + workdir: Path | None = None + + @property + def is_split(self) -> bool: + return len(self.apks) > 1 + + +def discover_apk_set(path: Path, *, splits: bool = True, workdir: Path) -> ApkSet: + """Resolve the APK(s) that make up one app. + + ``.apks``/``.xapk`` bundles are unpacked into ``workdir`` and every inner + ``*.apk`` joins the set. A ``base.apk`` picks up sibling ``split_*.apk`` + files unless ``splits`` is off. Any other APK is exported on its own. + """ + path = Path(path).resolve() + if is_apk_bundle(path): + return _unpack_bundle(path, workdir) + if not zipfile.is_zipfile(path): + raise ToCodeError(f"not a valid APK (zip) file: {path}") + apks = [path] + if splits and path.name.lower() == "base.apk": + siblings = sorted( + item + for item in path.parent.glob("split_*.apk") + if item.is_file() and zipfile.is_zipfile(item) + ) + apks.extend(siblings) + return ApkSet(primary=path, apks=apks) + + +def _unpack_bundle(path: Path, workdir: Path) -> ApkSet: + try: + archive = zipfile.ZipFile(path) + except zipfile.BadZipFile as exc: + raise ToCodeError(f"not a valid APK bundle (zip) file: {path}") from exc + extracted: list[Path] = [] + with archive: + for info in archive.infolist(): + name = Path(info.filename).name + if "/" in info.filename.strip("/") or not name.lower().endswith(".apk"): + continue + target = workdir / name + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info) as source, target.open("wb") as sink: + while True: + chunk = source.read(1 << 20) + if not chunk: + break + sink.write(chunk) + if zipfile.is_zipfile(target): + extracted.append(target) + if not extracted: + raise ToCodeError(f"APK bundle contains no .apk entries: {path}") + extracted.sort(key=_split_order) + return ApkSet(primary=extracted[0], apks=extracted, workdir=workdir) + + +def _split_order(path: Path) -> tuple[int, str]: + name = path.name.lower() + if name == "base.apk": + return (0, name) + if name.startswith("split_config."): + return (2, name) + return (1, name) + + +# -------------------------------------------------------------------------- +# DEX images and inventory records +# -------------------------------------------------------------------------- + + +@dataclass(slots=True) +class DexImage: + name: str + source_apk: Path + entry: str + data: bytes + dex: Any + + @property + def size(self) -> int: + return len(self.data) + + +@dataclass(slots=True) +class FieldRecord: + dex: str + index: int + class_descriptor: str + name: str + type: str + access_flags: int + + @property + def is_static(self) -> bool: + return bool(self.access_flags & ACC_STATIC) + + @property + def flag_names(self) -> list[str]: + return flag_names(self.access_flags, _FIELD_FLAG_NAMES) + + +@dataclass(slots=True) +class MethodRecord: + id: int + dex: str + index: int + class_descriptor: str + name: str + return_type: str + params: list[str] + access_flags: int + is_direct: bool + code_offset: int + code_size: int + registers: int = 0 + # Filled by the reference scan (worker side), resolved by the session. + callees: list[int] = field(default_factory=list) + external_calls: list[str] = field(default_factory=list) + # String references are kept once per string in ``AscSession.string_xrefs`` + # (a method-id list per string) rather than as a resolved list per method: + # a large APK has millions of such edges and the per-method copy dominated + # the exporter's memory. + string_ref_count: int = 0 + field_refs: list[str] = field(default_factory=list) + callers: list[int] = field(default_factory=list) + line_start: int | None = None + line_end: int | None = None + + @property + def descriptor(self) -> str: + return method_descriptor( + self.class_descriptor, self.name, self.params, self.return_type + ) + + @property + def is_native(self) -> bool: + return bool(self.access_flags & ACC_NATIVE) + + @property + def is_abstract(self) -> bool: + return bool(self.access_flags & ACC_ABSTRACT) + + @property + def is_static(self) -> bool: + return bool(self.access_flags & ACC_STATIC) + + @property + def flag_names(self) -> list[str]: + return flag_names(self.access_flags, _METHOD_FLAG_NAMES) + + @property + def address(self) -> str: + return ( + f"{self.dex}:0x{self.code_offset:x}" + if self.code_offset + else (f"{self.dex}:method@{self.index}") + ) + + @property + def prototype(self) -> str: + params = ", ".join(java_type(item) for item in self.params) + return f"{java_type(self.return_type)} {self.name}({params})" + + +@dataclass(slots=True) +class ClassRecord: + descriptor: str + dex: str + index: int + access_flags: int + superclass: str | None + interfaces: list[str] + source_file: str | None + methods: list[MethodRecord] + fields: list[FieldRecord] + java_path: Path | None = None + line_count: int = 0 + error: str | None = None + entry: bool = False + + @property + def java_name(self) -> str: + return java_type(self.descriptor) + + @property + def package(self) -> str: + name = self.java_name + return name.rsplit(".", 1)[0] if "." in name else "" + + @property + def simple_name(self) -> str: + return self.java_name.rsplit(".", 1)[-1] + + @property + def flag_names(self) -> list[str]: + return flag_names(self.access_flags, _CLASS_FLAG_NAMES) + + @property + def is_interface(self) -> bool: + return bool(self.access_flags & ACC_INTERFACE) + + @property + def has_native_methods(self) -> bool: + return any(item.is_native for item in self.methods) + + +@dataclass(slots=True) +class StringRecord: + dex: str + index: int + value: str + offset: int + + +@dataclass(slots=True) +class DecompiledClass: + descriptor: str + source: str | None + error: str | None + # method index -> list of (ref kind, index) pairs from the bytecode scan. + refs: dict[int, list[tuple[str, int]]] + + +def flag_names(flags: int, table: tuple[tuple[int, str], ...]) -> list[str]: + return [name for bit, name in table if flags & bit] + + +def java_type(descriptor: str) -> str: + """``Lcom/foo/Bar;`` -> ``com.foo.Bar``; ``[I`` -> ``int[]``.""" + dims = 0 + while descriptor.startswith("["): + dims += 1 + descriptor = descriptor[1:] + primitives = { + "V": "void", + "Z": "boolean", + "B": "byte", + "S": "short", + "C": "char", + "I": "int", + "J": "long", + "F": "float", + "D": "double", + } + if descriptor in primitives: + base = primitives[descriptor] + elif descriptor.startswith("L") and descriptor.endswith(";"): + base = descriptor[1:-1].replace("/", ".") + else: + base = descriptor + return base + "[]" * dims + + +def method_descriptor( + class_descriptor: str, name: str, params: list[str], return_type: str +) -> str: + return f"{class_descriptor}->{name}({''.join(params)}){return_type}" + + +def class_java_relpath(descriptor: str) -> Path: + """Package folders plus ``.java`` for a Dalvik class descriptor.""" + from ..naming import clean_path_component + + name = descriptor[1:-1] if descriptor.startswith("L") else descriptor + parts = name.split("/") + folders = [clean_path_component(item) for item in parts[:-1]] + leaf = ( + "".join( + ch if (ch.isascii() and (ch.isalnum() or ch in "_$")) else "_" + for ch in parts[-1] + ).strip("_") + or "unnamed" + ) + return Path(*folders, f"{leaf}.java") if folders else Path(f"{leaf}.java") + + +# -------------------------------------------------------------------------- +# DEX loading +# -------------------------------------------------------------------------- + + +def load_dex_images(apk: Path, *, prefix: str = "") -> list[DexImage]: + """Inflate every ``classes*.dex`` (and v41 container members) of one APK.""" + mods = asc_modules() + apk_handler = mods["apk_handler"] + dex_container = mods["dex_container"] + tinydex = mods["tinydex"] + images: list[DexImage] = [] + with open(apk, "rb") as handle: + try: + view = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) + except ValueError: + return images # empty file + try: + entries = apk_handler._parse_cd_dex_entries(view) + for entry in entries: + try: + data = bytes(apk_handler._inflate_dex(view, entry)) + except Exception as exc: + raise ToCodeError(f"cannot inflate {entry[0]} in {apk}: {exc}") + for name, buf in dex_container.iter_logical_dex_buffers(entry[0], data): + payload = bytes(buf) + images.append( + DexImage( + name=f"{prefix}{name}", + source_apk=apk, + entry=entry[0], + data=payload, + dex=tinydex.DEX(payload, name), + ) + ) + finally: + view.close() + return images + + +def load_apk_set_images(apk_set: ApkSet) -> list[DexImage]: + """DEX images across the whole set; names stay unique across APKs.""" + images: list[DexImage] = [] + seen: set[str] = set() + for apk in apk_set.apks: + prefix = "" if apk == apk_set.primary else f"{apk.name}!" + for image in load_dex_images(apk, prefix=prefix): + if image.name in seen: + image.name = f"{apk.name}!{image.name}" + seen.add(image.name) + images.append(image) + return images + + +def manifest_xml(apk: Path) -> str: + mods = asc_modules() + try: + return str(mods["manifest_handler"].get_manifest_xml(str(apk), pretty=True)) + except KeyError as exc: + raise ToCodeError(f"{apk.name} has no AndroidManifest.xml") from exc + except Exception as exc: + raise ToCodeError(f"cannot decode AndroidManifest.xml in {apk.name}: {exc}") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +# -------------------------------------------------------------------------- +# Bytecode reference scan +# -------------------------------------------------------------------------- + +REF_STRING = "string" +REF_TYPE = "type" +REF_FIELD = "field" +REF_METHOD = "method" + +_U16 = struct.Struct(" list[tuple[str, int]]: + """Return every (kind, index) the instruction stream references. + + Mirrors ASC's ``IndexHandler``: walks the opcode table, stops at the first + switch/array-data payload, and reads the constant-pool index of the + string/type/field/method instruction formats. + """ + if opcodes is None: + opcodes = asc_modules()["dvm_opcode"] + table = opcodes.opcodes + index_flag = opcodes.IndexFlag + verify_flag = opcodes.VerifyFlag + fmt_k31c = opcodes.Format.k31c + payload_mask = verify_flag.kVerifySwitchTargets | verify_flag.kVerifyArrayData + kinds = { + index_flag.kIndexStringRef: REF_STRING, + index_flag.kIndexTypeRef: REF_TYPE, + index_flag.kIndexFieldRef: REF_FIELD, + index_flag.kIndexMethodRef: REF_METHOD, + index_flag.kIndexMethodAndProtoRef: REF_METHOD, + } + data = bytes(bytecode) + refs: list[tuple[str, int]] = [] + length = len(data) + bound = length + pc = 0 + while pc + 1 < length and pc < bound: + op = table.get(data[pc]) + if op is None: + break + kind = kinds.get(op.idx) + if kind is not None and pc + 4 <= length: + if op.fmt == fmt_k31c: + if pc + 6 <= length: + refs.append((kind, _U32.unpack_from(data, pc + 2)[0])) + else: + refs.append((kind, _U16.unpack_from(data, pc + 2)[0])) + elif op.vflag & payload_mask and pc + 6 <= length: + target = pc + _I32.unpack_from(data, pc + 2)[0] * 2 + if 0 < target < bound: + bound = target + pc += op.oplen * 2 + return refs + + +# -------------------------------------------------------------------------- +# Session: inventory over loaded DEX images +# -------------------------------------------------------------------------- + + +class AscSession: + backend_name = "asc" + backend_label = "ASC" + decompiler_label = "ASC (droidasc) + androguard DAD" + + def __init__(self, apk_set: ApkSet) -> None: + self.apk_set = apk_set + self.images: list[DexImage] = [] + self.classes: list[ClassRecord] = [] + self.methods: list[MethodRecord] = [] + self.strings: list[StringRecord] = [] + self._by_descriptor: dict[str, ClassRecord] = {} + self._method_by_descriptor: dict[str, MethodRecord] = {} + self._images_by_name: dict[str, DexImage] = {} + self._pool_cache: dict[tuple[str, str, int], str] = {} + # (dex name, string index) -> ids of the methods that load it (capped). + self.string_xrefs: dict[tuple[str, int], list[int]] = {} + + # -- loading ----------------------------------------------------------- + + def load(self) -> None: + self.images = load_apk_set_images(self.apk_set) + self._images_by_name = {image.name: image for image in self.images} + self.classes = [] + self.methods = [] + self.strings = [] + next_id = 0 + for image in self.images: + dex = image.dex + for class_index in range(len(dex.classes)): + cls = dex.classes[class_index] + record, next_id = self._class_record(image, cls, next_id) + self.classes.append(record) + self._by_descriptor[record.descriptor] = record + for method in record.methods: + self.methods.append(method) + self._method_by_descriptor[method.descriptor] = method + self.strings.extend(self._string_records(image)) + + def _class_record( + self, image: DexImage, cls: Any, next_id: int + ) -> tuple[ClassRecord, int]: + dex = image.dex + ( + _class_idx, + access_flags, + superclass_idx, + interfaces_off, + source_file_idx, + _annotations_off, + _class_data_off, + _static_values_off, + ) = struct.unpack_from("<8I", dex.buf, cls._class_def_off) + superclass = ( + self._type_name(image, superclass_idx) + if superclass_idx != NO_INDEX + else None + ) + interfaces: list[str] = [] + if interfaces_off: + count = _U32.unpack_from(dex.buf, interfaces_off)[0] + for i in range(count): + type_idx = _U16.unpack_from(dex.buf, interfaces_off + 4 + i * 2)[0] + interfaces.append(self._type_name(image, type_idx)) + source_file = ( + self._string_value(image, source_file_idx) + if source_file_idx != NO_INDEX + else None + ) + descriptor = cls.fullname + methods: list[MethodRecord] = [] + for method in cls.methods: + proto = method.prototype + code_size = 0 + registers = 0 + code_off = method.code_offset + if code_off: + registers, _ins, _outs, _tries, _dbg, insns = struct.unpack_from( + " list[StringRecord]: + dex = image.dex + count = len(dex.strings) + base = dex.header.strings[0] + records: list[StringRecord] = [] + for index in range(count): + offset = _U32.unpack_from(dex.buf, base + index * 4)[0] + try: + value = dex.strings[index] + except Exception: + continue + records.append(StringRecord(image.name, index, str(value), offset)) + return records + + # -- lookups ----------------------------------------------------------- + + def image(self, name: str) -> DexImage: + return self._images_by_name[name] + + def class_by_descriptor(self, descriptor: str) -> ClassRecord | None: + return self._by_descriptor.get(descriptor) + + def method_by_descriptor(self, descriptor: str) -> MethodRecord | None: + return self._method_by_descriptor.get(descriptor) + + def _type_name(self, image: DexImage, type_idx: int) -> str: + key = (image.name, REF_TYPE, type_idx) + cached = self._pool_cache.get(key) + if cached is None: + try: + cached = str(image.dex.types[type_idx].descriptor) + except Exception: + cached = f"" + self._pool_cache[key] = cached + return cached + + def _string_value(self, image: DexImage, string_idx: int) -> str: + key = (image.name, REF_STRING, string_idx) + cached = self._pool_cache.get(key) + if cached is None: + try: + cached = str(image.dex.strings[string_idx]) + except Exception: + cached = f"" + self._pool_cache[key] = cached + return cached + + def _method_name(self, image: DexImage, method_idx: int) -> str: + key = (image.name, REF_METHOD, method_idx) + cached = self._pool_cache.get(key) + if cached is None: + try: + method = image.dex.methods[method_idx] + proto = method.prototype + cached = method_descriptor( + method.cls.fullname, + method.name, + [item.descriptor for item in proto.parameters_type], + self._type_name(image, proto.return_type_idx), + ) + except Exception: + cached = f"" + self._pool_cache[key] = cached + return cached + + def _field_name(self, image: DexImage, field_idx: int) -> str: + key = (image.name, REF_FIELD, field_idx) + cached = self._pool_cache.get(key) + if cached is None: + try: + item = image.dex.fields[field_idx] + cached = f"{item.cls.fullname}->{item.name}:{item.type}" + except Exception: + cached = f"" + self._pool_cache[key] = cached + return cached + + # -- reference resolution --------------------------------------------- + + def apply_refs( + self, record: ClassRecord, refs: dict[int, list[tuple[str, int]]] + ) -> None: + """Resolve a worker's raw (kind, index) pairs into method records.""" + image = self._images_by_name.get(record.dex) + if image is None: + return + by_index = {method.index: method for method in record.methods} + for method_index, pairs in refs.items(): + method = by_index.get(method_index) + if method is None: + continue + callees: list[int] = [] + external: list[str] = [] + fields: list[str] = [] + string_count = 0 + seen: set[tuple[str, int]] = set() + for kind, index in pairs: + if (kind, index) in seen: + continue + seen.add((kind, index)) + if kind == REF_METHOD: + descriptor = self._method_name(image, index) + target = self._method_by_descriptor.get(descriptor) + if target is None: + external.append(descriptor) + else: + callees.append(target.id) + target.callers.append(method.id) + elif kind == REF_STRING: + string_count += 1 + bucket = self.string_xrefs.setdefault((record.dex, index), []) + if len(bucket) < MAX_STRING_XREFS: + bucket.append(method.id) + elif kind == REF_FIELD and len(fields) < MAX_FIELD_REFS: + fields.append(self._field_name(image, index)) + method.callees = callees + method.external_calls = external + method.string_ref_count = string_count + method.field_refs = fields + + def scan_refs_inline(self, record: ClassRecord) -> dict[int, list[tuple[str, int]]]: + """Reference scan in the parent (used when no worker pool runs).""" + image = self._images_by_name[record.dex] + cls = image.dex.classes[record.index] + result: dict[int, list[tuple[str, int]]] = {} + opcodes = asc_modules()["dvm_opcode"] + for method in cls.methods: + bytecode = method.bytecode + if bytecode: + result[method.index] = scan_bytecode_refs(bytecode, opcodes=opcodes) + return result + + def close(self) -> None: + self.images = [] + self._images_by_name = {} + self._pool_cache = {} + self.string_xrefs = {} + + +# -------------------------------------------------------------------------- +# Worker-side decompilation (spawned processes) +# -------------------------------------------------------------------------- + +_worker_state: dict[str, Any] = {} + + +def init_decompile_worker(apk_set: ApkSet) -> None: + """Load the DEX images of the set in this worker; managers are built lazily.""" + images = load_apk_set_images(apk_set) + dex_manager_mod, decompiler_mod = _decompiler_modules() + quiet_android_loggers() + _worker_state["images"] = {image.name: image for image in images} + _worker_state["managers"] = {} + _worker_state["manager_factory"] = dex_manager_mod.DexManager + _worker_state["decompile"] = decompiler_mod.decompile_dex_bytes + _worker_state["opcodes"] = asc_modules()["dvm_opcode"] + + +def _worker_manager(dex_name: str) -> tuple[DexImage, Any]: + image = _worker_state["images"][dex_name] + managers = _worker_state["managers"] + manager = managers.get(dex_name) + if manager is None: + manager = _worker_state["manager_factory"](memoryview(image.data)) + managers[dex_name] = manager + return image, manager + + +def decompile_batch_in_worker( + items: list[tuple[str, str, int]], +) -> list[DecompiledClass]: + """Decompile ``(dex name, descriptor, class index)`` items in this worker.""" + results: list[DecompiledClass] = [] + for dex_name, descriptor, class_index in items: + try: + image, manager = _worker_manager(dex_name) + except Exception as exc: + results.append( + DecompiledClass(descriptor, None, f"dex unavailable: {exc}", {}) + ) + continue + results.append( + decompile_class( + image, + descriptor, + class_index, + manager=manager, + decompile=_worker_state["decompile"], + opcodes=_worker_state["opcodes"], + ) + ) + return results + + +def decompile_class( + image: DexImage, + descriptor: str, + class_index: int, + *, + manager: Any, + decompile: Any, + opcodes: Any, +) -> DecompiledClass: + refs: dict[int, list[tuple[str, int]]] = {} + try: + cls = image.dex.classes[class_index] + for method in cls.methods: + bytecode = method.bytecode + if bytecode: + refs[method.index] = scan_bytecode_refs(bytecode, opcodes=opcodes) + except Exception as exc: # pragma: no cover - defensive, tinydex is stable + return DecompiledClass(descriptor, None, f"reference scan failed: {exc}", {}) + try: + rebuilt = manager.extract_and_rebuild(descriptor) + source = decompile(bytes(rebuilt), descriptor) + except Exception as exc: + return DecompiledClass(descriptor, None, f"{type(exc).__name__}: {exc}", refs) + if not isinstance(source, str) or source.startswith("Error:"): + return DecompiledClass(descriptor, None, str(source).strip() or "empty", refs) + return DecompiledClass(descriptor, source, None, refs) + + +class InlineDecompiler: + """In-process decompiler used for the serial path (and by tests).""" + + def __init__(self, session: AscSession) -> None: + self.session = session + self._managers: dict[str, Any] = {} + self._factory: Any = None + self._decompile: Any = None + self._opcodes: Any = None + + def _ensure(self) -> None: + if self._factory is None: + dex_manager_mod, decompiler_mod = _decompiler_modules() + self._factory = dex_manager_mod.DexManager + self._decompile = decompiler_mod.decompile_dex_bytes + self._opcodes = asc_modules()["dvm_opcode"] + + def decompile(self, record: ClassRecord) -> DecompiledClass: + self._ensure() + image = self.session.image(record.dex) + manager = self._managers.get(record.dex) + if manager is None: + manager = self._factory(memoryview(image.data)) + self._managers[record.dex] = manager + return decompile_class( + image, + record.descriptor, + record.index, + manager=manager, + decompile=self._decompile, + opcodes=self._opcodes, + ) diff --git a/src/tocode/cli.py b/src/tocode/cli.py index 7387856..8f8b546 100644 --- a/src/tocode/cli.py +++ b/src/tocode/cli.py @@ -7,6 +7,9 @@ from pathlib import Path from .analysis import create_analyzer +from .apk import ApkExportOptions, export_apk +from .apk_native import NativeOptions +from .backends.asc import is_apk_input from .errors import ToCodeError from .exporter import export_binary from .naming import default_output_name @@ -68,7 +71,17 @@ def build_parser() -> argparse.ArgumentParser: "--backend", choices=("auto", "ida", "r2", "angr", "binja"), default=default_backend, - help="Decompiler backend: auto prefers IDA, then r2, then angr as a last-resort fallback; binja drives Binary Ninja and is opt-in (default: TOCODE_BACKEND or auto).", + help="Decompiler backend: auto prefers IDA, then r2, then angr as a last-resort fallback; binja drives Binary Ninja and is opt-in (default: TOCODE_BACKEND or auto). For APK input this selects the backend for the native .so libraries (binja is not supported there).", + ) + parser.add_argument( + "--no-native", + action="store_true", + help="APK input: only export the DEX/Android side; native .so libraries are extracted but not decompiled.", + ) + parser.add_argument( + "--no-splits", + action="store_true", + help="APK input: export base.apk alone instead of merging sibling split_*.apk files.", ) parser.add_argument( "--binja-host", @@ -169,6 +182,11 @@ def main(argv: list[str] | None = None) -> int: if args.binja_view is not None and args.all_views: parser.error("--binja-view and --all-views cannot be combined") if args.backend == "binja": + if args.binary is not None and is_apk_input(args.binary): + parser.error( + "--backend binja is not supported for APK input; native libraries " + "use ida, r2, or angr" + ) return _run_binja(args, progress, parser, argv) if args.binary is None: @@ -179,6 +197,8 @@ def main(argv: list[str] | None = None) -> int: args.out_dir = ( args.out_dir.expanduser().resolve() if args.out_dir is not None else None ) + if is_apk_input(binary): + return _run_apk(binary, args=args, progress=progress, parser=parser) log_root = ( args.out_dir if args.out_dir is not None @@ -208,6 +228,55 @@ def main(argv: list[str] | None = None) -> int: return 0 +def _run_apk( + binary: Path, + *, + args: argparse.Namespace, + progress: Progress, + parser: argparse.ArgumentParser, +) -> int: + if not binary.is_file(): + parser.error(f"input must be a regular file: {binary}") + options = ApkExportOptions( + out_dir=args.out_dir, + jobs=args.jobs, + native=not args.no_native, + splits=not args.no_splits, + native_options=NativeOptions( + backend=args.backend, + analysis_command=args.analysis, + idadir=args.idadir, + ida_domain_path=args.ida_domain_path, + purge_cache=args.purge_cache, + jobs=args.jobs, + tree=args.tree, + entropy=args.entropy, + ), + ) + started = time.monotonic() + try: + summary = export_apk(binary, options=options, progress=progress) + except KeyboardInterrupt: + progress.log("tocode: interrupted") + print("tocode: interrupted", file=sys.stderr) + return 130 + except ToCodeError as exc: + progress.log(f"tocode: {exc}") + print(f"tocode: {exc}", file=sys.stderr) + return 1 + if not args.quiet: + print(f"Project: {summary.root_dir}") + print( + f"Summary: package={summary.package} classes={summary.class_count} " + f"methods={summary.method_count} failures={len(summary.failed_classes)} " + f"natives={summary.native_done}/{summary.native_total}" + ) + for line in summary.native_errors: + print(f"native: {line}", file=sys.stderr) + print(f"Exported in {_format_duration(time.monotonic() - started)}") + return 0 + + def _format_duration(seconds: float) -> str: if seconds >= 60: minutes, secs = divmod(int(round(seconds)), 60) diff --git a/src/tocode/schema.py b/src/tocode/schema.py index ddf1751..62a8c73 100644 --- a/src/tocode/schema.py +++ b/src/tocode/schema.py @@ -263,3 +263,19 @@ class ExportSummary: tree_function_index_path: Path | None = None manifest_path: Path | None = None data_dir: Path | None = None + + +@dataclass(slots=True) +class ApkExportSummary: + root_dir: Path + package: str + apks: list[Path] + class_count: int + method_count: int + source_files: list[Path] + failed_classes: list[tuple[str, str]] + native_total: int + native_done: int + native_errors: list[str] = field(default_factory=list) + manifest_path: Path | None = None + seconds: float = 0.0 diff --git a/tests/test_apk_export.py b/tests/test_apk_export.py new file mode 100644 index 0000000..6df2d5c --- /dev/null +++ b/tests/test_apk_export.py @@ -0,0 +1,936 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import threading +import time +from typing import Any +import zipfile + +import pytest + +from tocode import apk as apk_module +from tocode import apk_metadata as meta +from tocode.apk import ApkExportOptions, assign_method_lines, export_apk +from tocode.apk_native import ( + NativeLib, + NativeRunner, + classify_native_entry, + extract_native_libs, +) +from tocode.backends.asc import ( + AscSession, + ClassRecord, + DecompiledClass, + DexImage, + FieldRecord, + MethodRecord, + StringRecord, +) +from tocode.errors import ToCodeError +from tocode.progress import Progress + + +SAMPLE_MANIFEST = """ + + + + + + + + + + + + + + + + + + + + + + +""" + +SAMPLE_JAVA = """package com.example.app; +public class MainActivity extends android.app.Activity { + private int counter; + + public MainActivity() + { + return; + } + + @Override + protected void onCreate(android.os.Bundle p1) + { + this.counter = 1; + return; + } + + private native int nativeAdd(int p1, int p2); + + public int add(int p1) + { + return p1; + } + + public int add(int p1, int p2) + { + return (p1 + p2); + } +} +""" + + +def _method( + id_: int, + cls: str, + name: str, + params: list[str], + ret: str, + flags: int = 0x1, + code: int = 0, +) -> MethodRecord: + return MethodRecord( + id=id_, + dex="classes.dex", + index=id_, + class_descriptor=cls, + name=name, + return_type=ret, + params=params, + access_flags=flags, + is_direct=False, + code_offset=code, + code_size=8 if code else 0, + registers=len(params) + 2, + ) + + +def _main_activity_record() -> ClassRecord: + cls = "Lcom/example/app/MainActivity;" + return ClassRecord( + descriptor=cls, + dex="classes.dex", + index=0, + access_flags=0x1, + superclass="Landroid/app/Activity;", + interfaces=[], + source_file="MainActivity.java", + methods=[ + _method(0, cls, "", [], "V", 0x10001, code=0x100), + _method(1, cls, "onCreate", ["Landroid/os/Bundle;"], "V", 0x4, code=0x120), + _method(2, cls, "nativeAdd", ["I", "I"], "I", 0x102), + _method(3, cls, "add", ["I"], "I", 0x1, code=0x140), + _method(4, cls, "add", ["I", "I"], "I", 0x1, code=0x160), + ], + fields=[FieldRecord("classes.dex", 0, cls, "counter", "I", 0x2)], + ) + + +def _helper_record() -> ClassRecord: + cls = "Lcom/example/util/Helper;" + return ClassRecord( + descriptor=cls, + dex="classes.dex", + index=1, + access_flags=0x11, + superclass="Ljava/lang/Object;", + interfaces=["Ljava/lang/Runnable;"], + source_file=None, + methods=[_method(5, cls, "run", [], "V", 0x1, code=0x200)], + fields=[], + ) + + +class FakeSession(AscSession): + """Inventory built by hand: no droidasc, no real DEX.""" + + def load(self) -> None: + self.images = [ + DexImage("classes.dex", self.apk_set.primary, "classes.dex", b"dex", None) + ] + self._images_by_name = {} + self.classes = [_main_activity_record(), _helper_record()] + self.methods = [m for record in self.classes for m in record.methods] + self._by_descriptor = {record.descriptor: record for record in self.classes} + self._method_by_descriptor = {m.descriptor: m for m in self.methods} + self.strings = [ + StringRecord("classes.dex", 0, "https://api.example.com/v1", 0x10), + StringRecord("classes.dex", 1, "hello", 0x30), + ] + # onCreate -> add(int); add(int) -> Helper.run; run -> external + self.methods[1].callees = [3] + self.methods[3].callers = [1] + self.methods[3].callees = [5] + self.methods[5].callers = [3] + self.methods[5].external_calls = ["Ljava/lang/Thread;->start()V"] + self.methods[1].string_ref_count = 1 + self.string_xrefs = {("classes.dex", 0): [1]} + + +class FakeDecompiler: + def __init__(self, session: AscSession) -> None: + self.session = session + + def decompile(self, record: ClassRecord) -> DecompiledClass: + if record.descriptor.endswith("Helper;"): + return DecompiledClass(record.descriptor, None, "boom", {}) + return DecompiledClass(record.descriptor, SAMPLE_JAVA, None, {}) + + +def _build_apk(path: Path, *, libs: bool = True) -> Path: + entries: dict[str, bytes] = { + "AndroidManifest.xml": b"\x03\x00\x08\x00binary", + "classes.dex": b"dex\n035\x00" + b"\x00" * 64, + "assets/config.json": b"{}", + "res/values/strings.xml": b"", + "resources.arsc": b"garbage", + "META-INF/MANIFEST.MF": b"Manifest-Version: 1.0", + } + if libs: + entries["lib/arm64-v8a/libfoo.so"] = b"\x7fELF" + b"\x00" * 32 + entries["lib/armeabi-v7a/libfoo.so"] = b"\x7fELF" + b"\x01" * 32 + entries["assets/hidden.so"] = b"\x7fELF" + b"\x02" * 32 + entries["assets/plain.so"] = b"not an elf" + with zipfile.ZipFile(path, "w") as archive: + for name, data in entries.items(): + archive.writestr(name, data) + return path + + +@pytest.fixture +def fake_backend(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(apk_module, "AscSession", FakeSession) + monkeypatch.setattr(apk_module, "manifest_xml", lambda apk: SAMPLE_MANIFEST) + + +def _fake_native_exporter(fail_name: str | None = None) -> Any: + calls: list[tuple[str, str, Path]] = [] + + def run( + lib: NativeLib, out_dir: Path, progress: Progress + ) -> tuple[str, str, int, int]: + calls.append((lib.abi, lib.name, out_dir)) + if fail_name and lib.name == fail_name and lib.abi == "armeabi-v7a": + raise ToCodeError("no decompiler backend available") + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "functions.json").write_text("{}", encoding="utf-8") + progress.log("fake native export") + return ("r2", "radare2 pdc", 12, 1) + + run.calls = calls # type: ignore[attr-defined] + return run + + +# -------------------------------------------------------------------------- +# manifest +# -------------------------------------------------------------------------- + + +def test_parse_manifest_extracts_package_components_and_permissions() -> None: + info = meta.parse_manifest(SAMPLE_MANIFEST, apk_name="base.apk") + + assert info.package == "com.example.app" + assert info.version_code == "48" and info.version_name == "4.5" + assert info.min_sdk == "24" and info.target_sdk == "34" + assert info.permissions == [ + "android.permission.INTERNET", + "android.permission.READ_SMS", + ] + assert info.main_activities == [".MainActivity"] + kinds = [(item.kind, item.name, item.exported) for item in info.components] + assert ".MainActivity" in [k[1] for k in kinds] + exported = {item.name: item.exported for item in info.components} + assert exported[".MainActivity"] is True # intent filter => exported + assert exported["com.example.app.Hidden"] is False + assert exported[".Svc"] is True + assert exported[".Prov"] is None + assert info.application["android:debuggable"] == "true" + + +def test_manifest_json_resolves_relative_component_names() -> None: + info = meta.parse_manifest(SAMPLE_MANIFEST, apk_name="base.apk") + doc = meta.manifest_json(info, [], xml_path=Path("AndroidManifest.xml")) + + assert doc["main_activities"] == ["com.example.app.MainActivity"] + names = {item["name"]: item for item in doc["components"]} + assert names["com.example.app.Boot"]["descriptor"] == "Lcom/example/app/Boot;" + assert names["com.example.app.Svc"]["permission"] == "com.example.P" + assert [item["name"] for item in doc["exported_components"]] == [ + "com.example.app.MainActivity", + "com.example.app.Svc", + "com.example.app.Boot", + ] + assert doc["dangerous_permissions"] == ["android.permission.READ_SMS"] + + +def test_parse_manifest_reports_parse_errors() -> None: + info = meta.parse_manifest(" None: + method = _method(0, "Lcom/ex_ample/Main$Inner;", "do_it", ["I"], "V", 0x100) + assert meta.jni_symbol(method) == "Java_com_ex_1ample_Main_00024Inner_do_1it" + + +# -------------------------------------------------------------------------- +# method line mapping +# -------------------------------------------------------------------------- + + +def test_assign_method_lines_maps_constructors_overloads_and_natives() -> None: + record = _main_activity_record() + assign_method_lines(record, SAMPLE_JAVA) + lines = { + m.name + str(len(m.params)): (m.line_start, m.line_end) for m in record.methods + } + + assert lines["0"] == (5, 8) + assert lines["onCreate1"] == (10, 15) # includes the @Override line + assert lines["nativeAdd2"] == (17, 17) + assert lines["add1"] == (19, 22) + assert lines["add2"] == (24, 27) + + +# -------------------------------------------------------------------------- +# native libs +# -------------------------------------------------------------------------- + + +def test_classify_native_entry_detects_abis_and_elf_magic() -> None: + assert classify_native_entry("lib/arm64-v8a/libx.so", b"\x7fELF") == ( + "arm64-v8a", + "libx.so", + ) + assert classify_native_entry("lib/x86/libx.so", b"") == ("x86", "libx.so") + assert classify_native_entry("assets/arm64-v8a/libhidden.so", b"\x7fELF") == ( + "arm64-v8a", + "libhidden.so", + ) + assert classify_native_entry("assets/hidden.so", b"\x7fELF") == ( + "unknown", + "hidden.so", + ) + assert classify_native_entry("assets/plain.so", b"not") is None + assert classify_native_entry("res/raw/data.bin", b"\x7fELF") == ( + "unknown", + "data.bin", + ) + assert classify_native_entry("assets/config.json", b"{}") is None + + +def test_extract_native_libs_writes_every_abi_once(tmp_path: Path) -> None: + apk = _build_apk(tmp_path / "base.apk") + dup = _build_apk(tmp_path / "split_config.arm64_v8a.apk") + root = tmp_path / "out" + + libs = extract_native_libs([apk, dup], root, sha256=lambda path: "h") + + assert [(lib.abi, lib.name) for lib in libs] == [ + ("arm64-v8a", "libfoo.so"), + ("armeabi-v7a", "libfoo.so"), + ("unknown", "hidden.so"), + ] + assert ( + (root / "lib" / "arm64_v8a" / "libfoo.so").read_bytes().startswith(b"\x7fELF") + ) + assert all(lib.source_apk == "base.apk" for lib in libs) + + +def test_native_runner_isolates_failures(tmp_path: Path) -> None: + libs = [ + NativeLib( + "arm64-v8a", + "libfoo.so", + "lib/arm64-v8a/libfoo.so", + "base.apk", + 1, + "h", + tmp_path / "a.so", + ), + NativeLib( + "armeabi-v7a", + "libfoo.so", + "lib/armeabi-v7a/libfoo.so", + "base.apk", + 1, + "h", + tmp_path / "b.so", + ), + ] + runner = NativeRunner( + libs=libs, + root=tmp_path, + progress=Progress(enabled=False), + exporter=_fake_native_exporter(fail_name="libfoo.so"), + ) + runner.start() + runner.join() + + assert libs[0].status == "done" + assert libs[0].backend == "r2" and libs[0].function_count == 12 + assert libs[0].export_dir == tmp_path / "native" / "arm64_v8a" / "libfoo" + assert libs[1].status == "failed: no decompiler backend available" + assert runner.errors == [ + "armeabi-v7a/libfoo.so: failed: no decompiler backend available" + ] + + +# -------------------------------------------------------------------------- +# end-to-end export with fakes +# -------------------------------------------------------------------------- + + +def test_export_apk_writes_full_tree(tmp_path: Path, fake_backend: None) -> None: + apk = _build_apk(tmp_path / "base.apk") + exporter = _fake_native_exporter(fail_name="libfoo.so") + + summary = export_apk( + apk, + options=ApkExportOptions(out_dir=tmp_path / "export", jobs=1), + progress=Progress(enabled=False), + native_exporter=exporter, + decompiler_factory=FakeDecompiler, + ) + + root = summary.root_dir + assert root == tmp_path / "export" + assert summary.package == "com.example.app" + assert summary.class_count == 2 and summary.method_count == 6 + assert summary.failed_classes == [("Lcom/example/util/Helper;", "boom")] + assert summary.native_total == 3 and summary.native_done == 2 + + for name in ( + "AndroidManifest.xml", + "manifest.json", + "classes.json", + "functions.json", + "function-index.json", + "strings.json", + "imports.json", + "exports.json", + "sections.json", + "reachable.json", + "triage.json", + "package-graph.json", + "native-libs.json", + "project.json", + "export-manifest.json", + "AGENTS.md", + "CLAUDE.md", + "tocode.log", + "data/resources.json", + ): + assert (root / name).is_file(), name + + java = root / "src" / "raw" / "com" / "example" / "app" / "MainActivity.java" + assert java.is_file() + text = java.read_text(encoding="utf-8") + assert text.startswith("// ToCode: Lcom/example/app/MainActivity; from classes.dex") + assert "protected void onCreate" in text + stub = root / "src" / "raw" / "com" / "example" / "util" / "Helper.java" + assert "decompilation failed: boom" in stub.read_text(encoding="utf-8") + assert "public void run();" in stub.read_text(encoding="utf-8") + + # extracted entries and native libs + assert ( + root / "data" / "apk" / "base" / "assets" / "config.json" + ).read_text() == "{}" + assert not (root / "data" / "apk" / "base" / "classes.dex").exists() + assert (root / "lib" / "arm64_v8a" / "libfoo.so").is_file() + assert (root / "lib" / "armeabi_v7a" / "libfoo.so").is_file() + assert (root / "lib" / "unknown" / "hidden.so").is_file() + assert (root / "native" / "arm64_v8a" / "libfoo" / "functions.json").is_file() + assert (root / "native" / "arm64_v8a" / "libfoo" / "tocode.log").is_file() + assert {(abi, name) for abi, name, _ in exporter.calls} == { + ("arm64-v8a", "libfoo.so"), + ("armeabi-v7a", "libfoo.so"), + ("unknown", "hidden.so"), + } + + libs = json.loads((root / "native-libs.json").read_text(encoding="utf-8")) + assert libs["count"] == 3 and libs["done"] == 2 + statuses = {(item["abi"], item["name"]): item["status"] for item in libs["libs"]} + assert statuses[("arm64-v8a", "libfoo.so")] == "done" + assert statuses[("armeabi-v7a", "libfoo.so")].startswith("failed:") + done = next(item for item in libs["libs"] if item["status"] == "done") + assert done["export_dir"].startswith("native/") + assert done["backend"] == "r2" and done["function_count"] == 12 + + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + assert manifest["package"] == "com.example.app" + assert manifest["main_activities"] == ["com.example.app.MainActivity"] + + functions = json.loads((root / "functions.json").read_text(encoding="utf-8")) + rows = {row["name"]: row for row in functions["functions"]} + on_create = rows["Lcom/example/app/MainActivity;->onCreate(Landroid/os/Bundle;)V"] + assert on_create["address"] == "classes.dex:0x120" + assert on_create["callees"] == ["classes.dex:0x140"] + assert on_create["string_ref_count"] == 1 + assert on_create["source_file"] == "src/raw/com/example/app/MainActivity.java" + assert on_create["source_line_start"] == 11 and on_create["source_line_end"] == 16 + assert on_create["dead_code"] is False + run = rows["Lcom/example/util/Helper;->run()V"] + assert run["callees_imports"] == ["Ljava/lang/Thread;->start()V"] + assert run["callers"] == ["classes.dex:0x140"] + assert run["source_line_start"] == 1 # failure stub covers the whole file + assert rows["Lcom/example/app/MainActivity;->nativeAdd(II)I"]["native"] is True + + index = json.loads((root / "function-index.json").read_text(encoding="utf-8")) + assert index["schema_version"] == 2 and index["language"] == "java" + entry = next( + item for item in index["functions"] if item["name"].endswith("add(II)I") + ) + assert entry["c"] == { + "path": "src/raw/com/example/app/MainActivity.java", + "line_start": 25, + "line_end": 28, + } + assert entry["asm"] is None + + imports = json.loads((root / "imports.json").read_text(encoding="utf-8")) + assert imports["imports"][0]["dll"] == "java.lang" + assert ( + imports["imports"][0]["functions"][0]["name"] == "Ljava/lang/Thread;->start()V" + ) + + exports = json.loads((root / "exports.json").read_text(encoding="utf-8")) + kinds = [(item["kind"], item["name"]) for item in exports["exports"]] + assert ("activity", "com.example.app.MainActivity") in kinds + jni = next(item for item in exports["exports"] if item["kind"] == "jni") + assert jni["forwarder_target"] == "Java_com_example_app_MainActivity_nativeAdd" + + strings = json.loads((root / "strings.json").read_text(encoding="utf-8")) + url = next(item for item in strings["strings"] if item["value"].startswith("https")) + assert url["xrefs"][0]["address"] == "classes.dex:0x120" + + reachable = json.loads((root / "reachable.json").read_text(encoding="utf-8")) + depths = {item["name"]: item["depth"] for item in reachable["reachable"]} + assert depths["Lcom/example/util/Helper;->run()V"] == 1 + assert reachable["unreachable_count"] == 0 + + triage = json.loads((root / "triage.json").read_text(encoding="utf-8")) + assert triage["binary_type"] == "apk" + assert triage["dangerous_permissions"] == ["android.permission.READ_SMS"] + assert ( + triage["native_methods"][0]["jni_symbol"] + == "Java_com_example_app_MainActivity_nativeAdd" + ) + assert triage["strings_of_interest"][0]["value"] == "https://api.example.com/v1" + assert len(triage["native_libs"]) == 3 + + sections = json.loads((root / "sections.json").read_text(encoding="utf-8")) + kinds_by_name = {item["name"]: item["type"] for item in sections["sections"]} + assert kinds_by_name["classes.dex"] == "dex" + assert kinds_by_name["lib/arm64-v8a/libfoo.so"] == "native" + assert kinds_by_name["assets/config.json"] == "asset" + assert kinds_by_name["resources.arsc"] == "arsc" + + graph = json.loads((root / "package-graph.json").read_text(encoding="utf-8")) + app = next( + item for item in graph["packages"] if item["package"] == "com.example.app" + ) + assert app["calls_packages"] == [{"package": "com.example.util", "calls": 1}] + + project = json.loads((root / "project.json").read_text(encoding="utf-8")) + assert project["backend"] == "asc" and project["package"] == "com.example.app" + assert project["native_enabled"] is True + manifest_doc = json.loads( + (root / "export-manifest.json").read_text(encoding="utf-8") + ) + assert manifest_doc["format"] == "apk" + assert manifest_doc["failures"] == [ + {"address": None, "name": "Lcom/example/util/Helper;", "error": "boom"} + ] + assert manifest_doc["native_errors"] == [ + "armeabi-v7a/libfoo.so: failed: no decompiler backend available" + ] + agents = (root / "AGENTS.md").read_text(encoding="utf-8") + assert "com.example.app" in agents and "native///" in agents + assert (root / "CLAUDE.md").read_text(encoding="utf-8") == "@./AGENTS.md\n" + log = (root / "tocode.log").read_text(encoding="utf-8") + assert "Native decompilation finished: 2/3" in log + assert "export Lcom/example/util/Helper; failed: boom" in log + + +def test_export_apk_no_native_only_extracts_libs( + tmp_path: Path, fake_backend: None +) -> None: + apk = _build_apk(tmp_path / "base.apk") + exporter = _fake_native_exporter() + + summary = export_apk( + apk, + options=ApkExportOptions(out_dir=tmp_path / "export", jobs=1, native=False), + progress=Progress(enabled=False), + native_exporter=exporter, + decompiler_factory=FakeDecompiler, + ) + + assert exporter.calls == [] + assert summary.native_done == 0 and summary.native_total == 3 + root = summary.root_dir + assert (root / "lib" / "arm64_v8a" / "libfoo.so").is_file() + assert not (root / "native").exists() + libs = json.loads((root / "native-libs.json").read_text(encoding="utf-8")) + assert {item["status"] for item in libs["libs"]} == {"skipped: --no-native"} + assert "--no-native" in (root / "AGENTS.md").read_text(encoding="utf-8") + project = json.loads((root / "project.json").read_text(encoding="utf-8")) + assert project["native_enabled"] is False + + +def test_export_apk_default_out_dir_uses_package_name( + tmp_path: Path, fake_backend: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("TOCODE_DEFAULT_OUT_ROOT", raising=False) + apk = _build_apk(tmp_path / "base.apk", libs=False) + + summary = export_apk( + apk, + options=ApkExportOptions(jobs=1), + progress=Progress(enabled=False), + decompiler_factory=FakeDecompiler, + ) + + assert summary.root_dir == tmp_path / "com_example_app_decompiler" + assert summary.native_total == 0 + + +def test_export_apk_merges_sibling_splits_and_bundles( + tmp_path: Path, fake_backend: None +) -> None: + _build_apk(tmp_path / "base.apk", libs=False) + with zipfile.ZipFile(tmp_path / "split_config.arm64_v8a.apk", "w") as archive: + archive.writestr("AndroidManifest.xml", b"\x03\x00\x08\x00") + archive.writestr("lib/arm64-v8a/libsplit.so", b"\x7fELF" + b"\x00" * 8) + with zipfile.ZipFile(tmp_path / "app.apks", "w") as bundle: + bundle.write(tmp_path / "base.apk", "base.apk") + bundle.write( + tmp_path / "split_config.arm64_v8a.apk", "split_config.arm64_v8a.apk" + ) + + exporter = _fake_native_exporter() + merged = export_apk( + tmp_path / "base.apk", + options=ApkExportOptions(out_dir=tmp_path / "merged", jobs=1), + progress=Progress(enabled=False), + native_exporter=exporter, + decompiler_factory=FakeDecompiler, + ) + assert [item.name for item in merged.apks] == [ + "base.apk", + "split_config.arm64_v8a.apk", + ] + assert merged.native_total == 1 and merged.native_done == 1 + manifest = json.loads( + (merged.root_dir / "manifest.json").read_text(encoding="utf-8") + ) + assert [item["apk"] for item in manifest["splits"]] == [ + "split_config.arm64_v8a.apk" + ] + assert ( + merged.root_dir + / "data" + / "apk" + / "split_config_arm64_v8a" + / "AndroidManifest.xml" + ).is_file() + + alone = export_apk( + tmp_path / "base.apk", + options=ApkExportOptions(out_dir=tmp_path / "alone", jobs=1, splits=False), + progress=Progress(enabled=False), + native_exporter=exporter, + decompiler_factory=FakeDecompiler, + ) + assert [item.name for item in alone.apks] == ["base.apk"] + assert alone.native_total == 0 + + bundled = export_apk( + tmp_path / "app.apks", + options=ApkExportOptions(out_dir=tmp_path / "bundled", jobs=1), + progress=Progress(enabled=False), + native_exporter=exporter, + decompiler_factory=FakeDecompiler, + ) + assert [item.name for item in bundled.apks] == [ + "base.apk", + "split_config.arm64_v8a.apk", + ] + assert bundled.native_total == 1 + assert not any(item.exists() for item in bundled.apks) # bundle workdir cleaned + + +def test_export_apk_rejects_missing_or_invalid_input(tmp_path: Path) -> None: + with pytest.raises(ToCodeError): + export_apk(tmp_path / "missing.apk", progress=Progress(enabled=False)) + bad = tmp_path / "bad.apk" + bad.write_bytes(b"nope") + with pytest.raises(ToCodeError): + export_apk(bad, progress=Progress(enabled=False)) + + +def test_export_apk_removes_stale_java_files( + tmp_path: Path, fake_backend: None +) -> None: + apk = _build_apk(tmp_path / "base.apk", libs=False) + stale = tmp_path / "export" / "src" / "raw" / "old" / "Gone.java" + stale.parent.mkdir(parents=True) + stale.write_text("stale", encoding="utf-8") + + export_apk( + apk, + options=ApkExportOptions(out_dir=tmp_path / "export", jobs=1), + progress=Progress(enabled=False), + decompiler_factory=FakeDecompiler, + ) + + assert not stale.exists() + + +@pytest.mark.skipif( + not os.environ.get("TOCODE_TEST_APK"), reason="set TOCODE_TEST_APK to a real APK" +) +def test_export_real_apk_with_asc(tmp_path: Path) -> None: + pytest.importorskip("droidasc") + apk = Path(os.environ["TOCODE_TEST_APK"]) + + summary = export_apk( + apk, + options=ApkExportOptions( + out_dir=tmp_path / "export", native=False, splits=False + ), + progress=Progress(enabled=False), + ) + + assert summary.class_count > 0 + classes = json.loads( + (summary.root_dir / "classes.json").read_text(encoding="utf-8") + ) + assert classes["count"] == summary.class_count + assert ( + len(list((summary.root_dir / "src" / "raw").rglob("*.java"))) + == summary.class_count + ) + + +class _FakeProcess: + def __init__(self, exitcode: int) -> None: + self.exitcode = exitcode + self.joined = False + self.pid: int | None = None + + def is_alive(self) -> bool: + return False + + def join(self) -> None: + self.joined = True + + +class _FakeQueue: + def __init__(self, item: Any = None) -> None: + self.item = item + + def get(self, timeout: float | None = None) -> Any: + import queue + + if self.item is None: + raise queue.Empty + return self.item + + +def test_collect_child_result_reports_a_killed_export( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tocode import apk_native + from tocode.apk_native import collect_child_result + + killed: list[int | None] = [] + monkeypatch.setattr(apk_native, "kill_process_group", killed.append) + process = _FakeProcess(-9) + process.pid = 4242 + with pytest.raises(ToCodeError) as info: + collect_child_result(process, _FakeQueue(), poll_seconds=0.01) + + assert "killed by signal 9" in str(info.value) + assert "--no-native" in str(info.value) + assert process.joined + assert killed == [4242] # the child's leftover worker pool is reaped too + + +def test_collect_child_result_passes_through_errors_and_results() -> None: + from tocode.apk_native import collect_child_result + + with pytest.raises(ToCodeError, match="no decompiler backend"): + collect_child_result( + _FakeProcess(1), _FakeQueue(("error", "no decompiler backend available")) + ) + with pytest.raises(ToCodeError, match="exited with code 3"): + collect_child_result(_FakeProcess(3), _FakeQueue(), poll_seconds=0.01) + + assert collect_child_result( + _FakeProcess(0), _FakeQueue(("ok", ("r2", "radare2 pdc", 12, 1))) + ) == ("r2", "radare2 pdc", 12, 1) + + +def test_native_runner_waits_for_free_memory(tmp_path: Path) -> None: + libs = [ + NativeLib( + "arm64-v8a", + "libfoo.so", + "lib/arm64-v8a/libfoo.so", + "base.apk", + 1, + "h", + tmp_path / "a.so", + ) + ] + readings = iter([100, 200, 4096]) + seen: list[int | None] = [] + + def available() -> int | None: + value = next(readings, 4096) + seen.append(value) + return value + + runner = NativeRunner( + libs=libs, + root=tmp_path, + progress=Progress(enabled=False), + exporter=_fake_native_exporter(), + min_free_mb=1024, + available_memory=available, + poll_seconds=0.01, + ) + runner.start() + runner.join() + + assert seen == [100, 200, 4096] # waited until there was headroom + assert libs[0].status == "done" + + +def test_native_runner_memory_gate_is_off_without_a_floor(tmp_path: Path) -> None: + libs = [ + NativeLib( + "x86", + "libfoo.so", + "lib/x86/libfoo.so", + "base.apk", + 1, + "h", + tmp_path / "a.so", + ) + ] + calls = 0 + + def available() -> int | None: + nonlocal calls + calls += 1 + return 0 + + runner = NativeRunner( + libs=libs, + root=tmp_path, + progress=Progress(enabled=False), + exporter=_fake_native_exporter(), + available_memory=available, + ) + runner.start() + runner.join() + + assert calls == 0 and libs[0].status == "done" + + +def test_nested_log_tail_mirrors_lines_and_counts_functions(tmp_path: Path) -> None: + from tocode.apk_native import NestedLogTail + + main = Progress(enabled=False) + main.set_log_path(tmp_path / "main.log") + nested = tmp_path / "nested.log" + nested.write_text( + "2026-09-19T20:41:14 Analyzing with IDA Domain auto-analysis\n", + encoding="utf-8", + ) + lib = NativeLib( + "arm64-v8a", + "libfoo.so", + "lib/arm64-v8a/libfoo.so", + "base.apk", + 1, + "h", + tmp_path / "libfoo.so", + ) + tail = NestedLogTail(nested, lib, main, poll_seconds=0.01) + tail.start() + with nested.open("a", encoding="utf-8") as handle: + handle.write( + "2026-09-19T20:41:15 Rendering and writing 3 functions in 1 clusters\n" + ) + handle.write("2026-09-19T20:41:15 export sub_1000 0x1000 - 10 bytes done\n") + handle.write("2026-09-19T20:41:15 export sub_2000 0x2000 - 10 bytes done\n") + handle.write("plain backend stderr line\n") + handle.write("2026-09-19T20:41:16 partial line without newline") + tail.stop() + + assert tail.total == 3 and tail.rendered == 2 + assert tail.status() == "2/3 functions" + mirrored = (tmp_path / "main.log").read_text(encoding="utf-8") + assert "native[libfoo.so]: Analyzing with IDA Domain auto-analysis" in mirrored + assert "native[libfoo.so]: Rendering and writing 3 functions" in mirrored + assert "native[libfoo.so]: plain backend stderr line" in mirrored + assert "export sub_1000" not in mirrored # per-function lines feed the bar only + assert "partial line" not in mirrored + + +def test_native_runner_describe_current_reports_progress(tmp_path: Path) -> None: + from tocode import apk_native + + lib = NativeLib( + "arm64-v8a", + "libfoo.so", + "lib/arm64-v8a/libfoo.so", + "base.apk", + 1, + "h", + tmp_path / "a.so", + ) + started = threading.Event() + release = threading.Event() + + def slow( + lib_: NativeLib, out_dir: Path, progress: Progress + ) -> tuple[str, str, int, int]: + progress.log("Rendering and writing 5 functions in 1 clusters") + started.set() + release.wait(5) + return ("ida", "Hex-Rays", 5, 0) + + runner = apk_native.NativeRunner( + libs=[lib], root=tmp_path, progress=Progress(enabled=False), exporter=slow + ) + assert runner.describe_current() is None + runner.start() + assert started.wait(5) + deadline = time.monotonic() + 5 + while runner.current_tail is not None and runner.current_tail.total is None: + if time.monotonic() > deadline: + break + time.sleep(0.01) + status = runner.describe_current() + release.set() + runner.join() + + assert status is not None + assert status.startswith("native: libfoo.so (arm64-v8a) 0/5 functions") + assert "0/1 libraries finished" in status + assert runner.describe_current() is None diff --git a/tests/test_asc_backend.py b/tests/test_asc_backend.py new file mode 100644 index 0000000..fcbce7e --- /dev/null +++ b/tests/test_asc_backend.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from pathlib import Path +import zipfile + +import pytest + +from tocode.backends.asc import ( + ApkSet, + class_java_relpath, + discover_apk_set, + is_apk_bundle, + is_apk_input, + java_type, + method_descriptor, + scan_bytecode_refs, +) +from tocode.errors import ToCodeError + + +def _write_zip(path: Path, entries: dict[str, bytes]) -> Path: + with zipfile.ZipFile(path, "w") as archive: + for name, data in entries.items(): + archive.writestr(name, data) + return path + + +def test_java_type_converts_descriptors() -> None: + assert java_type("Lcom/foo/Bar$Baz;") == "com.foo.Bar$Baz" + assert java_type("[I") == "int[]" + assert java_type("[[Ljava/lang/String;") == "java.lang.String[][]" + assert java_type("V") == "void" + + +def test_method_descriptor_round_trips_signature() -> None: + assert ( + method_descriptor("Lcom/foo/Bar;", "run", ["I", "Ljava/lang/String;"], "V") + == "Lcom/foo/Bar;->run(ILjava/lang/String;)V" + ) + + +def test_class_java_relpath_uses_package_folders() -> None: + assert class_java_relpath("Lcom/foo/Bar;") == Path("com/foo/Bar.java") + assert class_java_relpath("Lcom/foo/Bar$Inner;") == Path("com/foo/Bar$Inner.java") + assert class_java_relpath("LNoPackage;") == Path("NoPackage.java") + assert class_java_relpath("La/b/c/ünïcode;") == Path("a/b/c/n_code.java") + + +def test_apk_input_detection() -> None: + assert is_apk_input(Path("app.APK")) + assert is_apk_input(Path("bundle.apks")) + assert is_apk_input(Path("bundle.xapk")) + assert is_apk_bundle(Path("bundle.apks")) + assert not is_apk_bundle(Path("app.apk")) + assert not is_apk_input(Path("libfoo.so")) + + +def test_discover_apk_set_picks_up_sibling_splits(tmp_path: Path) -> None: + base = _write_zip(tmp_path / "base.apk", {"AndroidManifest.xml": b"x"}) + _write_zip(tmp_path / "split_config.en.apk", {"AndroidManifest.xml": b"x"}) + _write_zip(tmp_path / "split_config.arm64_v8a.apk", {"AndroidManifest.xml": b"x"}) + (tmp_path / "split_broken.apk").write_bytes(b"not a zip") + _write_zip(tmp_path / "other.apk", {"AndroidManifest.xml": b"x"}) + + apk_set = discover_apk_set(base, workdir=tmp_path / "work") + + assert apk_set.primary == base.resolve() + assert [item.name for item in apk_set.apks] == [ + "base.apk", + "split_config.arm64_v8a.apk", + "split_config.en.apk", + ] + assert apk_set.workdir is None + assert apk_set.is_split + + +def test_discover_apk_set_can_ignore_splits(tmp_path: Path) -> None: + base = _write_zip(tmp_path / "base.apk", {"AndroidManifest.xml": b"x"}) + _write_zip(tmp_path / "split_config.en.apk", {"AndroidManifest.xml": b"x"}) + + apk_set = discover_apk_set(base, splits=False, workdir=tmp_path / "work") + + assert apk_set.apks == [base.resolve()] + assert not apk_set.is_split + + +def test_discover_apk_set_exports_standalone_split_alone(tmp_path: Path) -> None: + _write_zip(tmp_path / "base.apk", {"AndroidManifest.xml": b"x"}) + split = _write_zip(tmp_path / "split_config.en.apk", {"AndroidManifest.xml": b"x"}) + + apk_set = discover_apk_set(split, workdir=tmp_path / "work") + + assert apk_set.apks == [split.resolve()] + + +def test_discover_apk_set_unpacks_bundles(tmp_path: Path) -> None: + inner_base = _write_zip(tmp_path / "inner_base.apk", {"AndroidManifest.xml": b"b"}) + inner_split = _write_zip( + tmp_path / "inner_split.apk", {"AndroidManifest.xml": b"s"} + ) + bundle = _write_zip( + tmp_path / "app.apks", + { + "split_config.en.apk": inner_split.read_bytes(), + "base.apk": inner_base.read_bytes(), + "nested/ignored.apk": inner_split.read_bytes(), + "toc.pb": b"meta", + }, + ) + workdir = tmp_path / "work" + + apk_set = discover_apk_set(bundle, workdir=workdir) + + assert apk_set.workdir == workdir + assert [item.name for item in apk_set.apks] == ["base.apk", "split_config.en.apk"] + assert apk_set.primary == workdir / "base.apk" + assert all(item.parent == workdir for item in apk_set.apks) + + +def test_discover_apk_set_rejects_non_zip_and_empty_bundle(tmp_path: Path) -> None: + bad = tmp_path / "bad.apk" + bad.write_bytes(b"nope") + with pytest.raises(ToCodeError): + discover_apk_set(bad, workdir=tmp_path / "work") + empty = _write_zip(tmp_path / "empty.apks", {"toc.pb": b""}) + with pytest.raises(ToCodeError): + discover_apk_set(empty, workdir=tmp_path / "work2") + + +def test_apk_set_is_picklable_for_spawned_workers(tmp_path: Path) -> None: + import pickle + + apk_set = ApkSet(primary=tmp_path / "base.apk", apks=[tmp_path / "base.apk"]) + assert pickle.loads(pickle.dumps(apk_set)) == apk_set + + +def test_scan_bytecode_refs_reads_constant_pool_indices() -> None: + pytest.importorskip("droidasc") + bytecode = bytes.fromhex( + "1a000500" # const-string v0, string@5 + "6e10020100 00" # invoke-virtual {v0}, method@0x102 + "62000300" # sget-object v0, field@3 + "1c000700" # const-class v0, type@7 + "1b0078563412" # const-string/jumbo v0, string@0x12345678 + "0e00" # return-void + ) + + refs = scan_bytecode_refs(bytecode) + + assert refs == [ + ("string", 5), + ("method", 0x102), + ("field", 3), + ("type", 7), + ("string", 0x12345678), + ] + + +def test_scan_bytecode_refs_stops_at_switch_payload() -> None: + pytest.importorskip("droidasc") + bytecode = bytes.fromhex( + "2b0003000000" # packed-switch v0, +3 (payload starts at byte 6) + "00011a000900" # payload bytes: must not be decoded as const-string + ) + + assert scan_bytecode_refs(bytecode) == [] diff --git a/tests/test_cli.py b/tests/test_cli.py index 8cd3d41..76e5d48 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,3 +40,58 @@ def test_parser_uses_entropy_as_opt_in_flag() -> None: assert parser.parse_args(["sample.bin"]).entropy is False assert parser.parse_args(["--entropy", "sample.bin"]).entropy is True + + +def test_parser_has_apk_flags() -> None: + parser = build_parser() + + default_args = parser.parse_args(["app.apk"]) + apk_args = parser.parse_args(["--no-native", "--no-splits", "app.apk"]) + + assert default_args.no_native is False and default_args.no_splits is False + assert apk_args.no_native is True and apk_args.no_splits is True + + +def test_main_rejects_binja_backend_for_apk_input(tmp_path) -> None: + from tocode.cli import main + + apk = tmp_path / "app.apk" + apk.write_bytes(b"PK\x05\x06" + b"\x00" * 18) + + with pytest.raises(SystemExit) as info: + main(["--backend", "binja", str(apk)]) + + assert info.value.code == 2 + + +def test_main_routes_apk_input_to_apk_export(tmp_path, monkeypatch) -> None: + from types import SimpleNamespace + + from tocode import cli + + apk = tmp_path / "app.apk" + apk.write_bytes(b"PK\x05\x06" + b"\x00" * 18) + seen = {} + + def fake_export(binary, *, options, progress): + seen["binary"] = binary + seen["options"] = options + return SimpleNamespace( + root_dir=tmp_path / "out", + package="com.example", + class_count=1, + method_count=2, + failed_classes=[], + native_total=1, + native_done=1, + native_errors=[], + ) + + monkeypatch.setattr(cli, "export_apk", fake_export) + + assert cli.main(["--no-native", "-j", "3", "--backend", "r2", "-q", str(apk)]) == 0 + assert seen["binary"] == apk.resolve() + assert seen["options"].native is False + assert seen["options"].jobs == 3 + assert seen["options"].native_options.backend == "r2" + assert seen["options"].native_options.jobs == 3 diff --git a/uv.lock b/uv.lock index 9683cc5..c9e8fcb 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,48 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "androguard" +version = "4.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apkinspector" }, + { name = "asn1crypto" }, + { name = "click" }, + { name = "colorama" }, + { name = "cryptography" }, + { name = "dataset" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "loguru" }, + { name = "lxml" }, + { name = "mutf8" }, + { 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'" }, + { name = "pydot" }, + { name = "pygments" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/a4/c6a1bcc4f4b40098259202f7155214f2ec315eb3ac5923f093646cf352c6/androguard-4.1.4.tar.gz", hash = "sha256:1e117ee4574366a2d7376b8c858433ad724b0a29e4036d9f1a9fda4372180267", size = 956315, upload-time = "2026-06-01T08:58:44.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/fc/dc0df02bcfb4c5182e2c15a60b2cd823f11470ddce8abcf3f99a43394434/androguard-4.1.4-py3-none-any.whl", hash = "sha256:6265a6d4007401cf5a62a98fa99a1c2994644d4311da031a5398efe3bcaa63b0", size = 1026935, upload-time = "2026-06-01T08:58:42.638Z" }, +] + [[package]] name = "angr" version = "9.2.213" @@ -16,30 +58,30 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "archinfo", version = "9.2.213", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "cachetools", marker = "python_full_version < '3.12'" }, - { name = "capstone", marker = "python_full_version < '3.12'" }, - { name = "cffi", marker = "python_full_version < '3.12'" }, - { name = "claripy", version = "9.2.213", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "cle", version = "9.2.213", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, - { name = "cxxheaderparser", marker = "python_full_version < '3.12'" }, - { name = "gitpython", marker = "python_full_version < '3.12'" }, - { name = "lmdb", marker = "python_full_version < '3.12'" }, - { name = "msgspec", marker = "python_full_version < '3.12' and implementation_name == 'cpython'" }, - { name = "mulpyplexer", marker = "python_full_version < '3.12'" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "archinfo", version = "9.2.213", source = { registry = "https://pypi.org/simple" } }, + { name = "cachetools" }, + { name = "capstone" }, + { name = "cffi" }, + { name = "claripy", version = "9.2.213", source = { registry = "https://pypi.org/simple" } }, + { name = "cle", version = "9.2.213", source = { registry = "https://pypi.org/simple" } }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "cxxheaderparser" }, + { name = "gitpython" }, + { name = "lmdb" }, + { name = "msgspec", marker = "implementation_name == 'cpython'" }, + { name = "mulpyplexer" }, + { 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.*'" }, - { name = "protobuf", marker = "python_full_version < '3.12'" }, - { name = "psutil", marker = "python_full_version < '3.12'" }, - { name = "pycparser", marker = "python_full_version < '3.12'" }, - { name = "pydemumble", marker = "python_full_version < '3.12'" }, - { name = "pypcode", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pyvex", version = "9.2.213", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "rich", marker = "python_full_version < '3.12'" }, - { name = "sortedcontainers", marker = "python_full_version < '3.12'" }, - { name = "sympy", marker = "python_full_version < '3.12'" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "protobuf" }, + { name = "psutil" }, + { name = "pycparser" }, + { name = "pydemumble" }, + { name = "pypcode", version = "3.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "pyvex", version = "9.2.213", source = { registry = "https://pypi.org/simple" } }, + { name = "rich" }, + { name = "sortedcontainers" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/cc/fc2c05157b0d7d65b919f33ceba47a3e324a4bc965bf9fea0283c4b2de93/angr-9.2.213.tar.gz", hash = "sha256:773ca0912107f64332ae0d98676300c03d3df79b7d507bbb223c65d076505c93", size = 4163352, upload-time = "2026-04-28T18:04:51.768Z" } wheels = [ @@ -73,31 +115,31 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "archinfo", version = "9.2.221", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cachetools", marker = "python_full_version >= '3.12'" }, - { name = "capstone", marker = "python_full_version >= '3.12'" }, - { name = "cffi", marker = "python_full_version >= '3.12'" }, - { name = "claripy", version = "9.2.221", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "cle", version = "9.2.221", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "cxxheaderparser", marker = "python_full_version >= '3.12'" }, - { name = "gitpython", marker = "python_full_version >= '3.12'" }, - { name = "lmdb", marker = "python_full_version >= '3.12'" }, - { name = "msgspec", marker = "python_full_version >= '3.12' and implementation_name == 'cpython'" }, - { name = "mulpyplexer", marker = "python_full_version >= '3.12'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "platformdirs", marker = "python_full_version >= '3.12'" }, - { name = "protobuf", marker = "python_full_version >= '3.12'" }, - { name = "psutil", marker = "python_full_version >= '3.12'" }, - { name = "pycparser", marker = "python_full_version >= '3.12'" }, - { name = "pydemumble", marker = "python_full_version >= '3.12'" }, - { name = "pypcode", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pyvex", version = "9.2.221", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, - { name = "rust-demangler", marker = "python_full_version >= '3.12'" }, - { name = "sortedcontainers", marker = "python_full_version >= '3.12'" }, - { name = "sympy", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "archinfo", version = "9.2.221", source = { registry = "https://pypi.org/simple" } }, + { name = "cachetools" }, + { name = "capstone" }, + { name = "cffi" }, + { name = "claripy", version = "9.2.221", source = { registry = "https://pypi.org/simple" } }, + { name = "cle", version = "9.2.221", source = { registry = "https://pypi.org/simple" } }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "cxxheaderparser" }, + { name = "gitpython" }, + { name = "lmdb" }, + { name = "msgspec", marker = "implementation_name == 'cpython'" }, + { name = "mulpyplexer" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "psutil" }, + { name = "pycparser" }, + { name = "pydemumble" }, + { name = "pypcode", version = "4.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyvex", version = "9.2.221", source = { registry = "https://pypi.org/simple" } }, + { name = "rich" }, + { name = "rust-demangler" }, + { name = "sortedcontainers" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/94/d252480aace97c3806448a5f6b37945425fb19c1c8b0571e98b907d58bc7/angr-9.2.221.tar.gz", hash = "sha256:6bde1600b32eb91de556b1729ca5c5c2f7cf63f936afbdea8cafc047858894fd", size = 15051078, upload-time = "2026-06-03T23:38:37.846Z" } wheels = [ @@ -117,6 +159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/e9/5280f28664e1ae56206a9901e53cd4c296559ceee48b6b17888e8bf20140/angr-9.2.221-cp314-cp314-win_amd64.whl", hash = "sha256:35dbccaa244840194b11af1e80f9ded198903595dd9bf61a97ed25d82fc6a002", size = 18833522, upload-time = "2026-06-03T23:37:47.038Z" }, ] +[[package]] +name = "apkinspector" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/8d/dd382dd4fd58799634d66d8f6eafe2c951a35971aacad6f6f3df6f111383/apkinspector-1.3.7.tar.gz", hash = "sha256:ea5a62f9c6a3127a6191901fa0cc32e23b05caf2034d7062193bd4c0539c7888", size = 28853, upload-time = "2026-08-28T07:11:46.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7f/e958755a08d9331428990464ee86980d43c24b330c4ab59c2fadb125e892/apkinspector-1.3.7-py3-none-any.whl", hash = "sha256:fa3242baa7ba8e0eecd20a2fcf427fcfd34f4043902542d12110c7297649c590", size = 31163, upload-time = "2026-08-28T07:11:45.835Z" }, +] + [[package]] name = "archinfo" version = "9.2.213" @@ -126,7 +177,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "backports-strenum", marker = "python_full_version < '3.11'" }, + { name = "backports-strenum", marker = "python_full_version != '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/d9/1773ff1b73db291dcf5ec74bd3280fe28af14a12ac5cf5c95d5b4231b33c/archinfo-9.2.213.tar.gz", hash = "sha256:aa575be5955346ea2e4804acad25a3e2f4fe45275d847c2b102dbe84d2fb9a40", size = 41289, upload-time = "2026-04-28T18:04:55.59Z" } wheels = [ @@ -151,6 +202,24 @@ version = "1.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d2/15/09fbfc0d43b66a3845098377249539aa44edcd541e17eabe2e11b4e3855e/arpy-1.1.1.tar.gz", hash = "sha256:3ec36309d2234648ef8dcd2118fe7d81c30195087e0353473546583f3434e776", size = 6774, upload-time = "2013-06-30T21:31:27.411Z" } +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + [[package]] name = "backports-strenum" version = "1.3.1" @@ -388,9 +457,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "cachetools", marker = "python_full_version < '3.12'" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, - { name = "z3-solver", marker = "python_full_version < '3.12'" }, + { name = "cachetools" }, + { name = "typing-extensions" }, + { name = "z3-solver" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4f/fc/1995734fd0ac68503c7aba645555454f7b2aa94bc995f9c36eba0ab9d346/claripy-9.2.213.tar.gz", hash = "sha256:2205ab333f26d03271951584e6bfd8c9bd4247e9a93f27ba4078ba745baabf2f", size = 147567, upload-time = "2026-04-28T18:04:56.55Z" } wheels = [ @@ -405,8 +474,8 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "cachetools", marker = "python_full_version >= '3.12'" }, - { name = "z3-solver", marker = "python_full_version >= '3.12'" }, + { name = "cachetools" }, + { name = "z3-solver" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/a6/4c83471b5f442c26f3b4c98d4ab4eb612173cbdc4edb6be54bc574440b29/claripy-9.2.221.tar.gz", hash = "sha256:7b11a9aca9b07f8ea01f746f725f3148f2a9548efc91dc51e181da885f37650f", size = 147340, upload-time = "2026-06-03T23:38:46.788Z" } wheels = [ @@ -422,17 +491,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "archinfo", version = "9.2.213", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "arpy", marker = "python_full_version < '3.12'" }, - { name = "cart", marker = "python_full_version < '3.12'" }, - { name = "minidump", marker = "python_full_version < '3.12'" }, - { name = "pefile", marker = "python_full_version < '3.12'" }, - { name = "pyelftools", marker = "python_full_version < '3.12'" }, - { name = "pyvex", version = "9.2.213", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pyxbe", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pyxdia", marker = "python_full_version < '3.12'" }, - { name = "sortedcontainers", marker = "python_full_version < '3.12'" }, - { name = "uefi-firmware", marker = "python_full_version < '3.12'" }, + { name = "archinfo", version = "9.2.213", source = { registry = "https://pypi.org/simple" } }, + { name = "arpy" }, + { name = "cart" }, + { name = "minidump" }, + { name = "pefile" }, + { name = "pyelftools" }, + { name = "pyvex", version = "9.2.213", source = { registry = "https://pypi.org/simple" } }, + { name = "pyxbe", version = "1.0.3", source = { registry = "https://pypi.org/simple" } }, + { name = "pyxdia" }, + { name = "sortedcontainers" }, + { name = "uefi-firmware" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/55/cec8283b3ea283468d455239e573c56d75dbacf05c5a41e992e99cd38f76/cle-9.2.213.tar.gz", hash = "sha256:696ec95931d17ec19686efc436eea88152a72d355fbcee428452ac8fadb94335", size = 226878, upload-time = "2026-04-28T18:04:57.661Z" } wheels = [ @@ -447,16 +516,16 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "archinfo", version = "9.2.221", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "arpy", marker = "python_full_version >= '3.12'" }, - { name = "cart", marker = "python_full_version >= '3.12'" }, - { name = "minidump", marker = "python_full_version >= '3.12'" }, - { name = "pefile", marker = "python_full_version >= '3.12'" }, - { name = "pyelftools", marker = "python_full_version >= '3.12'" }, - { name = "pyvex", version = "9.2.221", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pyxbe", version = "1.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pyxdia", marker = "python_full_version >= '3.12'" }, - { name = "sortedcontainers", marker = "python_full_version >= '3.12'" }, + { name = "archinfo", version = "9.2.221", source = { registry = "https://pypi.org/simple" } }, + { name = "arpy" }, + { name = "cart" }, + { name = "minidump" }, + { name = "pefile" }, + { name = "pyelftools" }, + { name = "pyvex", version = "9.2.221", source = { registry = "https://pypi.org/simple" } }, + { name = "pyxbe", version = "1.0.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pyxdia" }, + { name = "sortedcontainers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/c6/3017a258bdc1c9307173bd437d9c1547bec902fbd76e7db9e911e635fa72/cle-9.2.221.tar.gz", hash = "sha256:27d39e1cb852b8b19cf759effecebce01b7538b617d0b3453687548357692d8a", size = 423633, upload-time = "2026-06-03T23:38:47.848Z" } wheels = [ @@ -476,6 +545,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/77/ab9f823f42e829248a38ccb028188565e49cf4c5f532f45699f6df8bee15/cle-9.2.221-cp314-cp314-win_amd64.whl", hash = "sha256:488dac1934657456959bde942076415886cda040afe43ed1609f0d4759c37712", size = 484940, upload-time = "2026-06-03T23:38:14.79Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -485,6 +563,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "cxxheaderparser" version = "1.8.1" @@ -494,18 +629,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/3b/c20ed70839937311fa5a7cc511be4e369d7143b1e13366ba78e6bff53c45/cxxheaderparser-1.8.1-py3-none-any.whl", hash = "sha256:0f58c74b9a879b49a5df680a6c501f23c0d49c0379d7a89c14f62d0fe0b456ec", size = 62128, upload-time = "2026-06-13T19:44:20.04Z" }, ] +[[package]] +name = "dataset" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/10/0113e396ac92652cfab96663e2a98eb8ddf33766c24c586d79d3ea87eaa3/dataset-2.0.0.tar.gz", hash = "sha256:0e13d309dfbaafb3e67b991605ce6767867262908864455505c415442c0bba45", size = 183331, upload-time = "2026-04-12T09:59:37.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/22/2a12be390bb1bc92e6930a9180f27a4cba002cb09fad9b68b03ca345e94f/dataset-2.0.0-py3-none-any.whl", hash = "sha256:c08354c068002c2beb7811b37b8abf434e1fc333898f8cfc2be32df3f14099b3", size = 21126, upload-time = "2026-04-12T09:59:36.741Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "droidasc" +version = "0.1.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "androguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/a4/f352ffa2533dbb75fc9620112a8386cfc60abc9966ad3d7366e4fadfc599/droidasc-0.1.1.post1.tar.gz", hash = "sha256:ac38066e43f1df5a32a3268523df45cd83103de4426e239498d1033b7e0db509", size = 89334, upload-time = "2026-09-16T18:50:55.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/b8/a6c266f294ff16e26dae9594d11580ffc05a2f4ec91fdf8c219b201ee02a/droidasc-0.1.1.post1-py3-none-any.whl", hash = "sha256:5034b5bb70879580cf579af18f70e15b8ff2af3326fddc85c583d3c7b65c2092", size = 95814, upload-time = "2026-09-16T18:50:54.272Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "future" version = "1.0.0" @@ -539,6 +717,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4f/4af258e1ce388eb64be82d6466b7d0b3bc3eede154e14c3360a76a759b71/greenlet-3.5.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:95e7c44d072db623a1aab04ce488cf9533294a77ed9d072cd503a3596f4106ac", size = 292967, upload-time = "2026-09-14T14:26:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/1d/05/dc0d54e90af2f192724936799cee990687792ff54a4638d3ae0e0a0145c0/greenlet-3.5.6-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7d501d5eb5d4f67207df364752ad697465b834268744be7581c18d81d35d41d", size = 609284, upload-time = "2026-09-14T15:11:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/826b4fe7bc39c3f910c95e889a981c827f72a611fc035f31816a256cf043/greenlet-3.5.6-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a364c1ea75dc51b83a17f52fe0c79cf8bc4ddf740403bebd4581c7666eea017d", size = 622648, upload-time = "2026-09-14T15:20:39.198Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/092682ae7ca1aadd44aa36b0bc35c48c9ed55f9ffebb5794980b1e2ae74b/greenlet-3.5.6-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eed88b64a5e5da72d6a71cdc5aaeefaa5ced9b748f8d19f89800b339961dad39", size = 622813, upload-time = "2026-09-14T14:35:54.845Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/9c6b723a559b53bee0980a5dc57577d434891dd454f0fe54ed6e22d4e4ce/greenlet-3.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:874cea8bb1ec1ddccbacbd027856f6bf496f6bc18aba97a918c20e067edab236", size = 1585817, upload-time = "2026-09-14T15:10:03.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/d4/2bceaa305ff0ecf99511480da8f0a866eb2469a68da8715008a0d3e8f65f/greenlet-3.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:128813fc29f2336a21b4d06eedd5e16bcc7ea46f59e9ff1cb30ea70e48195d88", size = 1650177, upload-time = "2026-09-14T14:35:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/7a/33/c57855a6abada0c7bfb9c6c0f33df1fbed8e5224708478dbcdff6c5db490/greenlet-3.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:dad3d233d441a022c1f7155f0fb9d5aff7b97c1ea8c7dfa02cce586b16ab2d0b", size = 322896, upload-time = "2026-09-14T14:24:52.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "ida-domain" version = "0.5.0" @@ -571,6 +819,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/32/99451b1283ec5d92ad77073f12e1c667dc10775384d8f15c2914207149dd/ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529", size = 4539289, upload-time = "2026-09-01T08:29:32.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl", hash = "sha256:6d1645743cfd1a07eb695d85aa2b5fa66721f8cbae9431d4049f7084bbf06509", size = 639038, upload-time = "2026-09-01T08:29:30.673Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + [[package]] name = "lmdb" version = "2.1.1" @@ -610,6 +933,185 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/c0/f617cf8a6062b3343151ed45b5e093daff8ec2107d72ab637ffc9bd23970/lmdb-2.1.1-pp310-pypy310_pp73-manylinux_2_38_x86_64.whl", hash = "sha256:8ff72667a8cef22a71fbd91b4c078b94ab45f38881b0bb64481b4477fc779d80", size = 91155, upload-time = "2026-03-19T13:56:24.204Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/6b/a7d5c08e19a8e69887ed722fffaefdbaffc8959d5ef5c370a65e52c895ac/lxml-6.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221", size = 8575497, upload-time = "2026-09-02T14:46:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/96/dd/c25a32f9f6039a96cfd52296a4630075868aa16e71858b3076699a059201/lxml-6.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9", size = 4619233, upload-time = "2026-09-02T14:46:08.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/d49375a47644369d84f90a9fe4ff1924faad58d4f95563831eca84ca29ae/lxml-6.1.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a", size = 5015387, upload-time = "2026-09-02T14:46:10.797Z" }, + { url = "https://files.pythonhosted.org/packages/76/0f/d1b1f52925442f7b4b1abd81a41905987322f6df6a5dd42fab8579415828/lxml-6.1.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb", size = 5168571, upload-time = "2026-09-02T14:46:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/13/e5d8291a68a27e564e4e1eefba08c3844c6800bcb43f3e72a32b20971132/lxml-6.1.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf", size = 5068024, upload-time = "2026-09-02T14:46:15.325Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ce/dbea34cd115ae9b8ef53816daa912563615adf4daed42531878a2fb29c77/lxml-6.1.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07", size = 5296830, upload-time = "2026-09-02T14:46:17.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/12a2ab6e8c8afecb82a3f0e9a518952b6a1cddf405ad8542883bd71e6096/lxml-6.1.3-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702", size = 5424696, upload-time = "2026-09-02T14:46:19.706Z" }, + { url = "https://files.pythonhosted.org/packages/02/3f/5670e198266c764595687a234fdaed33837f487b95a596262b2548e48933/lxml-6.1.3-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed", size = 4783635, upload-time = "2026-09-02T14:46:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/70/24/007ce6b7bffb61a6ca88c3a8f21b26f3f0aa3b3f6bb648a56e328c994a14/lxml-6.1.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3", size = 5373212, upload-time = "2026-09-02T14:46:23.572Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/cf09f38cf9005bd5cfa4fd452b03b290b1c48c403fe0319a8013f4b3cae0/lxml-6.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6", size = 5116476, upload-time = "2026-09-02T14:46:26.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/83/eb021e5db4336f0bb1438cba6f053ea135aa00b9f4ef0439473d6b986308/lxml-6.1.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739", size = 4814172, upload-time = "2026-09-02T14:46:28.3Z" }, + { url = "https://files.pythonhosted.org/packages/c8/4e/147b6f9088cc191713249ac547b0af2fece489c8cdff1f2801ab47dda8a9/lxml-6.1.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53", size = 5361711, upload-time = "2026-09-02T14:46:31.035Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d9/8cfdac0d7d771e25af2c1f4bc874032f025a4b59e0b6917c3c7858070795/lxml-6.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08", size = 5321598, upload-time = "2026-09-02T14:46:33.165Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5b/d2413c71f312dccdd07ed985be356657fc624d822ba7e2c87e8722646156/lxml-6.1.3-cp310-cp310-win32.whl", hash = "sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65", size = 3604471, upload-time = "2026-09-02T14:46:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/7a/bf/74b6785beac6488fd395e78796339bc197fbad6fd6103b41b15a4009dc4b/lxml-6.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a", size = 4029086, upload-time = "2026-09-02T14:46:37.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a5/ddf6e1744cd76fc9f0ce11cb16b117d6eaac46ebaeca01968e9014e8770c/lxml-6.1.3-cp310-cp310-win_arm64.whl", hash = "sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5", size = 3674608, upload-time = "2026-09-02T14:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/f1/95133bde7af7afb1f5ba6090b674d826b7a518318bba54bbbb633b27865a/lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11", size = 8563141, upload-time = "2026-09-02T14:46:42.334Z" }, + { url = "https://files.pythonhosted.org/packages/80/54/5a79ee2181ac773ee13e48205411845feec69e1c3d097e985c1343171712/lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4", size = 4613690, upload-time = "2026-09-02T14:46:45.253Z" }, + { url = "https://files.pythonhosted.org/packages/ab/29/8c24672f56807f119312f073f24204368574bd16b384ede861b5104b3a2b/lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b", size = 4935630, upload-time = "2026-09-02T14:46:48.071Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/ce2436d854c848c19fc9287143991f3fc76b8b4e9a0dbba8452e51dff264/lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13", size = 5079033, upload-time = "2026-09-02T14:46:50.483Z" }, + { url = "https://files.pythonhosted.org/packages/91/ec/b66f66f6499ad800265d57540b51e6632e3232d3526f42f2f8fd4b14e0ea/lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41", size = 5012298, upload-time = "2026-09-02T14:46:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/94/2a/25d128872f4d51753542bfc3feb482c2ea7c8a2d6d81a0bc5c6a00779ed4/lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0", size = 5211431, upload-time = "2026-09-02T14:46:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/0a41bbef074a556110f84fafb6d8c2998293c7d3bfbe1ce74515bc65393b/lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867", size = 5343417, upload-time = "2026-09-02T14:46:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/7b/cd/16116c3f91791aeeeab1cbe6e7eb6e646f127be7b0158b262eb526a21a0c/lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054", size = 4673219, upload-time = "2026-09-02T14:46:59.604Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/4dff849f443ef70221676aec938bc41e8bae6430aa2ca13b041319e14b98/lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6", size = 5281246, upload-time = "2026-09-02T14:47:02.375Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4aa7dd059420bfd35278c7fe819e9d319ee36a0453b7bbde1907a7832d91/lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c", size = 5055451, upload-time = "2026-09-02T14:47:05.883Z" }, + { url = "https://files.pythonhosted.org/packages/de/44/20d90cf6f4234de9cd9eeb4f519419885fdb087fa80d073c7b57be342021/lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48", size = 4722694, upload-time = "2026-09-02T14:47:08.461Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0e/6bee12325e53dd6613fe1e107def07583b6182ade03e94bfef8976622e44/lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0", size = 5269179, upload-time = "2026-09-02T14:47:10.647Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5d/54d269ce5cd0787c0424d9cef449ee794d4097725d13dd2acd6181c44e9c/lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12", size = 5235559, upload-time = "2026-09-02T14:47:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f7/5a3095f187f1bec293591616a1677781acc265c5b313c009f8a19c471a09/lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633", size = 3600377, upload-time = "2026-09-02T14:47:15.957Z" }, + { url = "https://files.pythonhosted.org/packages/45/5a/15531a0d307c96282fe8b639b3d74e8bd783e4ab4cb2b0781146ac4161b8/lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559", size = 4032700, upload-time = "2026-09-02T14:47:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/8de76314955545ceaaa7c0305017b8aaa217905dee59c62c0e2c1e44a68f/lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6", size = 3674431, upload-time = "2026-09-02T14:47:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, + { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, + { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, + { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, + { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, + { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, + { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/dc1fdf3a53f84ca88b6e942277ddb47954844a0ececea8cc5fa3c1324831/lxml-6.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75", size = 3947704, upload-time = "2026-09-02T14:46:22.27Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ed/e36d547d6c958b5693b873504735cb4d0388d545945d66a7aed8983a720b/lxml-6.1.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765", size = 4220149, upload-time = "2026-09-02T14:46:24.907Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/7f51e6b6cc0755f9b5fc6637748279e9f48289d917b3a47ac9fedf3318d3/lxml-6.1.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94", size = 4329391, upload-time = "2026-09-02T14:46:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/840b0d2e25c10c491b010d555b46e6e5264d3ad73a91557405fceb738c35/lxml-6.1.3-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c", size = 4262125, upload-time = "2026-09-02T14:46:29.199Z" }, + { url = "https://files.pythonhosted.org/packages/69/8f/42a41571dfc772c12628747f883d24c978053856825b99d7a187117b8079/lxml-6.1.3-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e", size = 4410104, upload-time = "2026-09-02T14:46:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/f3/aa/27d93812be916f1f674b2035edd86d41c77745ff2ad84f58c25a7445a397/lxml-6.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4", size = 3510724, upload-time = "2026-09-02T14:46:34.122Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/2433176de263cc3f51fd2c303f993d5bb7f1da3139a0f7d168116c0bfa7a/lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962", size = 3942969, upload-time = "2026-09-02T14:46:36.55Z" }, + { url = "https://files.pythonhosted.org/packages/7c/71/de7759096f480180fd9e43ff7c017860e2d2a9a43741ab093cbdf1820f07/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a", size = 4213008, upload-time = "2026-09-02T14:46:38.784Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/c2d09af47a34fa6c0c27473083812b449a411680bd04bbe609cde291ddc8/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167", size = 4322012, upload-time = "2026-09-02T14:46:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/68/f3/bf56fee0403ebd995be8e78ec9aca566016487d1b3cbf755ebea8ccffbdb/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a", size = 4257402, upload-time = "2026-09-02T14:46:43.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1d/6da9cc086a20d9dd6bcbf7c5d9575f0331cca9a05e67dab02d15e828170b/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f", size = 4410889, upload-time = "2026-09-02T14:46:46.975Z" }, + { url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -622,6 +1124,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -711,6 +1310,57 @@ version = "0.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/6f/f036d4fb9f5a511262345bb18215c70fa69a250aca2e5ffe1a9c7e4cb85c/mulpyplexer-0.09.tar.gz", hash = "sha256:144e9e9bf66d3988f60542c9d3d4c94857438f7908f60e53f4c1cb1622fbbd30", size = 2846, upload-time = "2021-01-11T21:10:21.68Z" } +[[package]] +name = "mutf8" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/74/bfce3ab421b39503c6cd11a6bc812a763154082a4f69d55831d762e58fcd/mutf8-1.1.0.tar.gz", hash = "sha256:279796be8926ea53ac226a831727725387f3acadcfc5aa723f8a5ada559dae3a", size = 11827, upload-time = "2026-07-10T18:23:34.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/03/22e4f2f7feb962825dafa11a07d743ac67052c5aca5e1c80119f245a0d03/mutf8-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6767a8e1e3dea2b8d9c98d14906049b6b63dcddc87c79e60d9c38a72b19640b", size = 13189, upload-time = "2026-07-11T15:30:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/f3/02/8a0072308d9a0b60eb18c24356cc40320b046c376ae464cc47d8a610a86e/mutf8-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:afd923aaf773542d2cbfb8dbe6cbc2045498efa1e72e86d1ac4d3aacf7e14941", size = 13501, upload-time = "2026-07-10T18:22:47.189Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cc/ad0e4c15567e58c9409e77f35bf209db0b2acfd715055c7a63e2755ec87c/mutf8-1.1.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:072ad993d20b244755a8d6ab0ba6fee5c974741dd0acf96df6cc585f0c3c61ad", size = 23408, upload-time = "2026-07-10T18:22:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/51/7d/31df8202123f6d61ec001b4612bf180a04d994988d5fb7c105ef229dd47f/mutf8-1.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea1eaa3fc7e9da366b9199d19540d311eb2c953436936b96c6c1f9e81ebf501", size = 24441, upload-time = "2026-07-10T18:22:49.301Z" }, + { url = "https://files.pythonhosted.org/packages/5e/06/8eeb7d5b713d95718cd556137161806a6da3840e972ec7018ceb85a2abcc/mutf8-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4aa9af7ce710850dae09ff5ef9bfcc27a3f48cd301b0b14d6b30a2ac12970cbf", size = 23889, upload-time = "2026-07-10T18:22:50.227Z" }, + { url = "https://files.pythonhosted.org/packages/f7/bc/a9070513ffbc221a2983bf5657a55f47b26ef350f1218ff65b86bf1912ed/mutf8-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:350cf53ff2922e322be8141a3b67e4455dc7df2832545113039a85697bb1b802", size = 23321, upload-time = "2026-07-10T18:22:51.123Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/b9e45da2f00b0120f2f43fd1781dd139175bb5e1051e13af208858e639e0/mutf8-1.1.0-cp310-cp310-win32.whl", hash = "sha256:f7e59cbf2e959029aecbcdb8bcb64fb6db56bf62945c44cf9a8c7b495ad36236", size = 14471, upload-time = "2026-07-10T18:22:52.015Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/da17d272c5c393580a4038332f2a569be8fcc3dc168e5245de6e83213212/mutf8-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3e3cb461153512a22567ee03a2e3104c6359443161ae83ec386107fb4d09124a", size = 14898, upload-time = "2026-07-10T18:22:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5b/4deca08eea67d85e6ba14e7cbe0060f16a04536119499e249ff5528efa08/mutf8-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:258e6fc37cbdb90f9ee571eb993b4632a6dd9d408159c33b72161cb5fecd00d6", size = 13506, upload-time = "2026-07-10T18:22:53.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/03/eb72e6d0587fc0daeb6162258efadf2ab3a3e90d2f07a7f88c7ca7a57180/mutf8-1.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8af3095f7afd5dfc32fb0a662eba659472357e83259b7b4f1f3298fb6d4f904d", size = 24685, upload-time = "2026-07-10T18:22:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/3a9456bf13ba225e588adf4b091c4053b3f20099623d7dcba9883e5252c4/mutf8-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbcc40de7b02f81a0c1a8bbd43ebbf9e45c7f8bf57cf9d46176c6cc9fa65e17b", size = 25606, upload-time = "2026-07-10T18:22:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/3f/47/c27bc134ad4643303d99388d6b4762b42b734fd097d5d0614c390af47110/mutf8-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f748e3f7dcb9f6b7e642bdf6168b72a7fd5674182a827236bf8c7165672d322b", size = 25013, upload-time = "2026-07-10T18:22:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/05/29/31ff1ddcb34be6b6a80e84e0836daf2cce5de4420f6bda5b2dd555682350/mutf8-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d2a46bf14e16011a113b7b839e3be6c1d5ccd47bc5a4897799af83309cc4ffc", size = 24603, upload-time = "2026-07-10T18:22:57.766Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/dd5425ebc2c330d3b5dd69bb19d7d1ec7ab0984de30614c19066e0bfbe66/mutf8-1.1.0-cp311-cp311-win32.whl", hash = "sha256:ce85b33e06592c457163c03e1856e21504286b7d3cab7dbe45cc51b8931e3b01", size = 14497, upload-time = "2026-07-10T18:22:58.858Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/ab528d9f43c640eece2d3940368fa96021c9d227466752f377c21de0fe9b/mutf8-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf764c02c9c6a1806cb6523052340dbf1c45b676a180230e85ac85144aea048d", size = 14899, upload-time = "2026-07-10T18:22:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/83/35/a3074929620a649c6ad0557d55eeb25d2c22d1b2470b9d1bd9dd332bede7/mutf8-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d6682df2b8a7d6e8a769ae6b3295adce8ee032c020ce98f7bb31692b664882c", size = 13508, upload-time = "2026-07-10T18:23:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/667233e5871f4fd1cb4b4cfe6d540b5f88ff644b143bc78367cf68621eed/mutf8-1.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d5e852720303c709f0b8150603032518818c56d378d17c403cb42cfe399b9bb8", size = 24900, upload-time = "2026-07-10T18:23:01.635Z" }, + { url = "https://files.pythonhosted.org/packages/18/0d/b234f2ba0a9b91cfaf9ce1386c967a0b8284e5b1e101ee8162cf09803dd9/mutf8-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e1abf471047c80793a0d29b4dccdc2b93e052245f38280c852cc14f966b1b28", size = 25811, upload-time = "2026-07-10T18:23:02.695Z" }, + { url = "https://files.pythonhosted.org/packages/26/63/5843c0b540884cee9b72658451dc156bcd1f30ea87ad4477f6ac48b1aa6e/mutf8-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3eb8e73167356159bfecbfbe30fccb5aa72167e4e56220e3e0ac0f34703a7554", size = 25191, upload-time = "2026-07-10T18:23:03.809Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/f4e3e6284ad5e7164c9110786f4498d5f4ac870e142d271f2d99d53c4281/mutf8-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:640f14ccb0b61c8f74de6d20291bac6512e6f1852888e3b9b2277e38291131da", size = 24809, upload-time = "2026-07-10T18:23:04.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/d7/13c3730942c8c93c8b856330e8976d6271f6dc05f6f40ffc59314316b6c9/mutf8-1.1.0-cp312-cp312-win32.whl", hash = "sha256:2cd6b7f78a1a9a1041e9c1b2b565cf6586aaa64cb0ab6fb20b3e08dddb759384", size = 14504, upload-time = "2026-07-10T18:23:05.696Z" }, + { url = "https://files.pythonhosted.org/packages/de/91/22f6f24ed635082c349a53893cffb149b61d2223be6a7651b23d6747d1fc/mutf8-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:fce9b80cd05214a2c886ee936e587db381ea10f4c4d45ba81689c0b9b76e6ebb", size = 14895, upload-time = "2026-07-10T18:23:06.562Z" }, + { url = "https://files.pythonhosted.org/packages/16/df/dd8db9d0318e1dbaf98cac4acc6b4a83efe4cff130b561f643bdb05a6e65/mutf8-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fe4c83cacf6fbd230a5a429a51afcdf56efbb7692938358b9eb572ef0cfa1f5", size = 13508, upload-time = "2026-07-10T18:23:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b2/042128bbcd8df8388fa370686a2ee1f770aed44d1f9c8d6aeda63cd10a8d/mutf8-1.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7569229befe0317125802d9220e5735771e3f0ac90bda0fa4ef2dac7d27bcd3a", size = 24895, upload-time = "2026-07-10T18:23:08.572Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5e/f3d343ac37c9818f1e7a74675b040439b024b68458a9c07265a32007d541/mutf8-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f904baade198aaf87910166ac0e0d8c42970a63ccc3d230e7fc626456cf22d30", size = 25809, upload-time = "2026-07-10T18:23:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/46/7c/946842eaea8772f99d44f921eb25322d3fca6126a799607a26c4a3fc19a8/mutf8-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6914897b0b682322ef85df9ded02869207174f4ae58434b060c58e7da9adb913", size = 25214, upload-time = "2026-07-10T18:23:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/66/51/9ea7a069459be457278cc0355c3b45ca30b4b9b930aa3095c48e8d3e4a76/mutf8-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e208659e973213c24bd78f84787adb23c0eb8beaceef77911e006a59a3bebce3", size = 24840, upload-time = "2026-07-10T18:23:11.515Z" }, + { url = "https://files.pythonhosted.org/packages/46/9a/8231b544b67a0c9383ab1e5c418526ed321eec1c432fab6aefbea7366944/mutf8-1.1.0-cp313-cp313-win32.whl", hash = "sha256:85ad5bb6c40ba10d18d7bf407c13a25f9fadeb27641fc691e04529609cdbcbd5", size = 14500, upload-time = "2026-07-10T18:23:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/a9ee3bf536f45c7ab5d144fc240abc215c683d88e6fdbb63edd0bb64ef35/mutf8-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:867270fa62a507c2a80591d5f444d96f8739507cb4a135b03319e37ca727f3c7", size = 14893, upload-time = "2026-07-10T18:23:13.418Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/8114ed0298fd2d90a6a9fe24ff8b2519db800c9c8523af2b5a8b39fddb53/mutf8-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:81dcc89c127bf4bf7453c9790032ab2fd2881a5edd6bf464d9e737e7d8a9dcdb", size = 13514, upload-time = "2026-07-10T18:23:14.389Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/ff71996313f10fac965c99c1b2780d92105cae5ed349b4aa30d94a6b37a7/mutf8-1.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:31e8c701128647f8795054e30b0e2f1bb0bcd6e89ab50f0cbf9803413adf98d9", size = 24937, upload-time = "2026-07-10T18:23:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/3b/14/30f26bbe63c0206206d06b7ab65a44ddb62408050670b01ef35a4df5693c/mutf8-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51b2297b9e0908d21ee41962305c0552d918bcbcb5733c018e98afc4c5d0da4", size = 25871, upload-time = "2026-07-10T18:23:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/11b0b4bff9d7cfc585c9d6974e2d4846dc50c722294a06c1c0287bbac3b1/mutf8-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:42abec0a8f96b03d27edd01a9e18fc375fd1be3166d8fd03b164f5b3cab0abd4", size = 25276, upload-time = "2026-07-10T18:23:17.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/5541bc1d05c153ab617dbe7e2620db01653cb4dcab753c76cea6fce59283/mutf8-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea196f3a49186a3a28e583244fe893d11f1077285b939fcfecc3b5eaf58882eb", size = 24854, upload-time = "2026-07-10T18:23:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/ecf865df12af83033a585bee2c750ba7f8e919e5c5d76af3e1f91759aa37/mutf8-1.1.0-cp314-cp314-win32.whl", hash = "sha256:e7801dbdd5f22763f21558e817804f9eb0c8fc94f7ea238c8f5380c2e1b23b62", size = 14692, upload-time = "2026-07-10T18:23:19.124Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3d/3afe3481fe99959a0dc0e7122d8114d50c044e227d4693ed366d96faf2ca/mutf8-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:2696f757d799fa6f43584cc201fe8bf7a882de13459c5522b169c91ce4e09560", size = 15122, upload-time = "2026-07-10T18:23:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/e7b9b40db43806ab5cdac20deb705f08b874a6af0ec7d4614e191334c368/mutf8-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3364bd2f0b302be14d4d7e2ab4617bf81e99bcbd16d36cc135b344bb2aa46f84", size = 13644, upload-time = "2026-07-10T18:23:20.9Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/04886543e51cf665f7cabaf1f9fed0988141c0cce9eb499ad9b60c9b3b03/mutf8-1.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63bdc07d838c05a5579ab00222192cf573057364e89471f8e64fbf453cfe8145", size = 25452, upload-time = "2026-07-10T18:23:21.842Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3f/c3ba1c00f8ec49c29e77ce49e061cc1058f8c48d9ec74b7795361a3b2b0e/mutf8-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e28f11e5b8ab759fe662bad8c6cbbdab7420639613725b6875cc2c73995032d0", size = 26443, upload-time = "2026-07-10T18:23:22.809Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/501f5b35728ede5b74460401c3c281ec54dfdd4acefd682aa7362d0575fb/mutf8-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7c257c6c88821c067a167accde6f5b4af6440d641d993a3a557e80314c076f4d", size = 25819, upload-time = "2026-07-10T18:23:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8e/f5fc3a213ed3ebe5d8912cd6ebc29c735661237a51226839d4d0aabc1fae/mutf8-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c4ef2a7c2c807973e4e5c063cb5459f3979efe8f86f9e71eccdd86fc77b306e", size = 25397, upload-time = "2026-07-10T18:23:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/29/fc/f75fefacb5cb7d42170628422827202770386ad98e27a0f2b59df1c605c8/mutf8-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3ba488375e86d185c187b39d90f8894b996128f721e8ff3eae72dbf6c66d92e8", size = 14828, upload-time = "2026-07-10T18:23:25.902Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7f/8505e1bfa0fa6110f5aeef3a5e62bb234862da3c18ad129fb8e499116ae7/mutf8-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a441d6542d874062e8223ef868195940f281051f4eba82c2d420de12bb133415", size = 15272, upload-time = "2026-07-10T18:23:26.951Z" }, +] + [[package]] name = "networkx" version = "3.4.2" @@ -745,6 +1395,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + [[package]] name = "pefile" version = "2024.8.26" @@ -754,6 +1413,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -784,6 +1455,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl", hash = "sha256:27a454980f91689aae8f18242a36daaf2636219171cf0e6a849744aa1d6fff85", size = 164460, upload-time = "2026-06-08T14:44:04.75Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + [[package]] name = "protobuf" version = "7.35.1" @@ -827,6 +1510,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/9f/abfd2959e9261dd5217ca8551d4de211ca6ab26fe9b72cf44731ff6c4442/pure_eval-0.2.4.tar.gz", hash = "sha256:260c2774686e651b79f8b8e7fc9d80b3599ea6a66334b47d5f4abb69fc2c0ea1", size = 20563, upload-time = "2026-09-10T21:41:22.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/18/83376915176eb058cb86470eb7396388a13350b09a8f233a79303bbcbc5b/pure_eval-0.2.4-py3-none-any.whl", hash = "sha256:96cae060a313cfaad51bb761278bfb0e62dc0248d9315a81173752dc546cd37a", size = 11893, upload-time = "2026-09-10T21:41:21.532Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -915,6 +1616,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/80/37ffc60afbf56bbc8eacbdd1956a69032b121a8355552a22ceb823a1e550/pydemumble-0.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:04e7c5bd6750dcfb899c5098faa204c2db98c7affdb47b1b9e490fb4e4b7cf07", size = 171299, upload-time = "2025-02-15T05:19:09.494Z" }, ] +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + [[package]] name = "pyelftools" version = "0.33" @@ -933,6 +1646,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pypcode" version = "3.3.3" @@ -1023,8 +1745,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "bitstring", marker = "python_full_version < '3.12'" }, - { name = "cffi", marker = "python_full_version < '3.12' and implementation_name == 'cpython'" }, + { name = "bitstring" }, + { name = "cffi", marker = "implementation_name == 'cpython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/d5/4df75aa08d1759bc67df8d3c1e1828605d38d2185dea81baff805c8a8742/pyvex-9.2.213.tar.gz", hash = "sha256:772a3588b5fdff1ca3b57379b2906a2f1ca1ee68290cd9c75a730183245d38fc", size = 3649139, upload-time = "2026-04-28T18:04:59.109Z" } wheels = [ @@ -1058,8 +1780,8 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "bitstring", marker = "python_full_version >= '3.12'" }, - { name = "cffi", marker = "python_full_version >= '3.12' and implementation_name == 'cpython'" }, + { name = "bitstring" }, + { name = "cffi", marker = "implementation_name == 'cpython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/9e/59100c676aad44c39537d4ce5ec0fd753211b9b82a03ea3bb9ee4130c0b4/pyvex-9.2.221.tar.gz", hash = "sha256:f33a02903eafa7da4bf6cafa804cd2bb8707602eea8e9e445642590dd84bf1e2", size = 3649005, upload-time = "2026-06-03T23:38:49.23Z" } wheels = [ @@ -1114,6 +1836,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/ea/34d31e0e4484d2acdd25a24d56a26be7855f9119244fd9ea16d00095b01e/pyxdia-0.1.0-py3-none-win_amd64.whl", hash = "sha256:5bd17cec300f53d0bb7926f64095e3806674c6a82f8c2a5c6be24aa6fc1384a1", size = 958051, upload-time = "2025-06-13T20:03:42.789Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "r2pipe" version = "1.9.8" @@ -1172,6 +1958,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/271aa905cf2964f841371a97f3e63ab692bf51b4423d0491e67bc7f64037/sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b", size = 9969559, upload-time = "2026-09-15T21:06:57.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/2f/7a427d7e10bc5f285d41fa1cadf46031ffc6c84f35a9d6e19737587a4eb0/sqlalchemy-2.0.54-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24ae093dec196ba37fc2beb0316de53e7871d3d246a50faecbbb53034e41ded2", size = 2189267, upload-time = "2026-09-15T22:11:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/80122de187c0f80cb47addce97609160004a8605010afdd815e608fcf003/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cc6532f930c27974e9239e5ce5abebe7600ba9807cea4fcf42f1b6cab18fe7", size = 3358362, upload-time = "2026-09-15T22:33:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/c8/93/9d26b109f5472017bf6e7a1b9dac12ac5f6165ba37a297834d0690c42cf3/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e7a76d5dce712ce50435d0f97181eb955ec27d138c004176f01282e063bac52", size = 3357692, upload-time = "2026-09-15T22:33:14.781Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d4/63e800b45bd4fa59cb4b14c013130e0aa92c7a019a0b8bf68115643bf32b/sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f5c09090b1a7c4d389d1431f820931e8df318f82caafc53f9a72c872fef467c5", size = 3308258, upload-time = "2026-09-15T22:33:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fe/6c96fe018b9db15476b46619228dc0749cdd9b4dc8baab89aca59205b605/sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:762cfe4d340c56368256d936a98b620a9a5650e49c1c84eba51d6edd17ffefb2", size = 3336734, upload-time = "2026-09-15T22:33:16.533Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f1/8d225d6b77863ae83ba51e913fe5954bcf58b49dd54229f0b5ba0bc983f0/sqlalchemy-2.0.54-cp310-cp310-win32.whl", hash = "sha256:6b6d4e601c4f6d85e99bb3416107cc9418c5603ca73d4ee0f5f8d79c2a1ed9e8", size = 2144294, upload-time = "2026-09-15T22:41:20.424Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f6/4247fdd087bf1b39be90930bb2b276a1c55041540628c8736512b4242669/sqlalchemy-2.0.54-cp310-cp310-win_amd64.whl", hash = "sha256:03cbf8d9a67da618bd65500a5eb3ddac89caf4c61e99b2f03fa4a1952a0725a9", size = 2170318, upload-time = "2026-09-15T22:41:21.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/7339b18ef05c03335a32f077e450c8040eab0c952c2964e32cd889eafc22/sqlalchemy-2.0.54-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d03084f3352dd92048cb19c71d90f116d076c9c7937e0ebc7752c4685de6d38", size = 2187776, upload-time = "2026-09-15T22:32:35.535Z" }, + { url = "https://files.pythonhosted.org/packages/9b/bb/3b6f5f3f51582d63af4c88b1e70da7888e936dee6b86a237b7633fb3f216/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92622fbbda1b1fe1632f3402a6e516a93c0e41d9158839c6b3dfb12117f26b72", size = 3428952, upload-time = "2026-09-15T22:40:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/ffd6b59795ebec81d582ad866f71ec7c73d356768e0442c6a07bba61c40c/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5800ddea045c2c860ef1d359a07a3066c7c0c426f45e3abc3874e116cb3c6937", size = 3428566, upload-time = "2026-09-15T22:35:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/63/ff/1f0bffb0653b30711e2445a48968eff9412b4e5bf0870f1e72e8a69595ef/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019abef05a4b5eafc8eae6fb483167fa28a4dbe5f518d577b744f31a5276a37", size = 3377697, upload-time = "2026-09-15T22:40:16.494Z" }, + { url = "https://files.pythonhosted.org/packages/97/76/774906be0d41cc4a14ecfbc015112fb7d0a3f1c3393f2c179d10388f8f0b/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b67749f7da3985a529cefbb1474783cb91ef44371cb9713630bade3de908760d", size = 3402966, upload-time = "2026-09-15T22:35:35.726Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bb/1d7fe50ace612bf342d3440d8b09544746949120326c3dc487cfc2592fba/sqlalchemy-2.0.54-cp311-cp311-win32.whl", hash = "sha256:2f61a70b3b82e2ec7ad6a4f2301422b9ca93ff06917983e41317bcae878bddf6", size = 2142297, upload-time = "2026-09-15T21:25:19.917Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/18fe80f64b1f6bcd422e649e40a78031f8624b2fde22ca54e950dfa6e25a/sqlalchemy-2.0.54-cp311-cp311-win_amd64.whl", hash = "sha256:1d887fbd5d248e250807bd801e697fc73e3b44866ce5f093dbc90512e75bde25", size = 2169222, upload-time = "2026-09-15T21:25:21.114Z" }, + { url = "https://files.pythonhosted.org/packages/c6/30/75504fd1d70458000e85a3e772333dd0fbf80254b0dc4d41c99922e4d112/sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2", size = 2187596, upload-time = "2026-09-15T22:32:37.031Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/5dfbb9528a391186a99986daecc5cbe003f34408dee91db8f5cdcf917040/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b", size = 3448745, upload-time = "2026-09-15T22:40:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/d9/93/34fdc4a4faced77037a6b3ba1db1acd92e9bd12c21e229b327edd1d3e881/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19", size = 3466644, upload-time = "2026-09-15T22:35:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/66/68/4beab40ae60ac3d679dbbb46bc0d2bb277013264a0bde37ecaf4b6780e98/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5", size = 3401519, upload-time = "2026-09-15T22:40:19.907Z" }, + { url = "https://files.pythonhosted.org/packages/e5/df/a24757e3249b1c7c1c5f0317666d16a8ab901ba239819ecef29eb4ed172e/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b", size = 3432590, upload-time = "2026-09-15T22:35:39.434Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0c/69559e3d90d200fbfe9c44aff39a9ff4e506b1a3e9557a203d69df721244/sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e", size = 2140825, upload-time = "2026-09-15T21:25:22.444Z" }, + { url = "https://files.pythonhosted.org/packages/d9/10/4a0f7113664c2877906db143709c52ddbd28a88e4d51f0b520bc30048ba7/sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf", size = 2170005, upload-time = "2026-09-15T21:25:23.885Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/021ccd7a838425f52e67c02edc541e8d53be8a27a103e684e14b18aac17b/sqlalchemy-2.0.54-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b374e3bc91e246a942592a98ba6a23be76fff21358b00546ac8c0ebc0fd0e00b", size = 2184259, upload-time = "2026-09-15T22:28:59.153Z" }, + { url = "https://files.pythonhosted.org/packages/74/8f/f95de908a4af7ac3a6925cdcb837a93274f608925a0fbc786df38811bccf/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d5458672a6f72db2c087f4a5098b3c8503ea0254186ff29205d63afa9401a4", size = 3398792, upload-time = "2026-09-15T22:29:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/84/26/bd327a1a6be223e438c98ac8faf299307d83797225a81f3b209607a9fd98/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cad78d04254967bdbcccbed5e631d88fe4868530946ab0929aa45e9032849518", size = 3417562, upload-time = "2026-09-15T22:40:32.637Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b1/f13a8fe8e8e167d8e65484b91b827897b8a5ca1efd7ba6e87a5f63412fed/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:48611087a75d26d798003645c688c7d3cfc26b89dbe4a2c568d6b378d330deae", size = 3349138, upload-time = "2026-09-15T22:29:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/5fd58f03281a74c57f02f509433e1be58a79d9f5b0e14020e665e0dcb614/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d6adf80277372a89910a0f3ccfe960b846d279dc55b366dd5c5ec07f41c84758", size = 3382943, upload-time = "2026-09-15T22:40:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/4b/73/e29fa88dfc809857a55e6523911d345d00cab4adf87032c21ade8874223c/sqlalchemy-2.0.54-cp313-cp313-win32.whl", hash = "sha256:264460333ed0b177cbb1956355d0ee4e0cab83fb415c934ce12a25db2e7be39c", size = 2138931, upload-time = "2026-09-15T22:42:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/89/9d/885e7491836f3dad368c74f0d5b93551d37b0ec66c9e8753240ee988308f/sqlalchemy-2.0.54-cp313-cp313-win_amd64.whl", hash = "sha256:cf89e92bf0d4204a6afcc17af27b9271ed9c7e34e17d6f80c085d431ea4a1747", size = 2166496, upload-time = "2026-09-15T22:42:53.603Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c0/4a6503c9d22d6d00a5631082ab1484222ecf7d573db791e0f53161bf7745/sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0", size = 2185899, upload-time = "2026-09-15T22:29:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/f4424f618bd1f373761a32a821d53ce2c257350e9894bae9b968cb03d8fd/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139", size = 3394763, upload-time = "2026-09-15T22:29:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/59/d2/7f0c77f8e042cb5f28275fea29c3080b4ac6fd4b3fdd7f59ff1ef3e28c11/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e", size = 3402800, upload-time = "2026-09-15T22:40:36.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/32/3eaa930bcf71d17a72587081d2706a5fb97ab3f11a7e0fb838f581f7cff1/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912", size = 3341469, upload-time = "2026-09-15T22:29:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/eb/cc/cddb6cbd4408e5c55b3bf722be26b9d3b54d42509f901b7ecc15debd3d1f/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615", size = 3373232, upload-time = "2026-09-15T22:40:39.181Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/9c2aa5efc642b7f3b985d13565cd1a5e78856e079ef3022796fea5180498/sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44", size = 2142018, upload-time = "2026-09-15T22:42:55.118Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0b/3594f1f51769feb3022d686135dc5d8682a12345ed15ae61d0c0ca42cbee/sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835", size = 2169220, upload-time = "2026-09-15T22:42:56.727Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a5/c211a9a7af83222509519407e16a4db760c6df3d03be69ebc5414d465321/sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a", size = 2208458, upload-time = "2026-09-15T22:30:05.718Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2e/490ad7b3731116cb48ba170f7722eaa99a89707193e54389ca84b7ad55af/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77", size = 3660585, upload-time = "2026-09-15T22:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/eb/25/15dfe6814847eeda773bd58ab6cf42a94b0176e5cf25a578fc1165777160/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd", size = 3624442, upload-time = "2026-09-15T22:36:38.377Z" }, + { url = "https://files.pythonhosted.org/packages/aa/19/724d0a6a2fb2a86ff2d6008e581c258f722d9b7d8e52adc7b79085febdd4/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f", size = 3562972, upload-time = "2026-09-15T22:36:08.581Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/9df1bd81c2f2d000cf5e7a1b1a9b331468a3aab939ad983355e701fa42b2/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb", size = 3576479, upload-time = "2026-09-15T22:36:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/b5775465d3b89061d7c46057c31c56ff8fb6c509550b2b0c6570ffc248b3/sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51", size = 2174794, upload-time = "2026-09-15T22:31:45.925Z" }, + { url = "https://files.pythonhosted.org/packages/77/f8/296c2e46b4ccd3f29b00b954ef2f195dde32f98e352ed21de1d292cedc0d/sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970", size = 2211942, upload-time = "2026-09-15T22:31:47.368Z" }, + { url = "https://files.pythonhosted.org/packages/24/a1/bd5e3e99bc9c8863b51ac5b9b03008a7f2da8c6b59695992f5c654e1265b/sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2", size = 1958015, upload-time = "2026-09-15T22:24:22.95Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -1227,6 +2082,7 @@ name = "tocode-cli" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "droidasc" }, { name = "ida-domain" }, { name = "idapro" }, { name = "r2pipe" }, @@ -1246,6 +2102,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "angr", marker = "extra == 'angr'", specifier = ">=9.2" }, + { name = "droidasc", specifier = "==0.1.1.post1" }, { name = "ida-domain", specifier = "==0.5.0" }, { name = "idapro", specifier = "==0.0.9" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.2" }, @@ -1321,6 +2178,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "traitlets" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1335,7 +2201,7 @@ name = "uefi-firmware" version = "1.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "future", marker = "python_full_version < '3.12'" }, + { name = "future" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/d2/ee12d6dac85e403779ab4de6904dad2053502d0a2c96a962255e9e40734f/uefi_firmware-1.16.tar.gz", hash = "sha256:1626b9930b1006f9ec10b75ef889ffc17c632e0b43a4aba07a1090d9023133ea", size = 212873, upload-time = "2026-06-04T14:31:34.606Z" } wheels = [ @@ -1365,6 +2231,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/77/e31b46ae9d3ad1afbb23a4708f804bc7257740a4b54e5e6574e5c59b2c37/uefi_firmware-1.16-cp314-cp314t-win_arm64.whl", hash = "sha256:32a5f01c3fc07c6223fe47e5909bce7dc4ec8a6750af969074c1e0b86713bde0", size = 202380, upload-time = "2026-06-04T14:31:33.188Z" }, ] +[[package]] +name = "wcwidth" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/7a/f98d4ada7c499565ab0c0fcef28a4e54fafa72b8228a6309803c80493c92/wcwidth-0.8.4.tar.gz", hash = "sha256:2dae09efa25253ae2874188e86d6861af3b1652aef4118cdf3f0bda288a957fb", size = 414609, upload-time = "2026-09-17T23:31:45.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/cc/9c0adc5861020de426ec71716b6c1bef8d90694b7d6d0a16202cdb1647f5/wcwidth-0.8.4-py3-none-any.whl", hash = "sha256:2097bb1d28a0ba8fe177c2eb607317f9b1627b03f33ebebfd3da670b337a65ba", size = 299474, upload-time = "2026-09-17T23:31:43.816Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "z3-solver" version = "4.13.0.0" From 430a75711bc3774b9d4f18e96eff2354b05d09c6 Mon Sep 17 00:00:00 2001 From: buzzer-re <22428720+buzzer-re@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:05:51 +0900 Subject: [PATCH 2/3] Tell agents which APK a nested native export came from The AGENTS.md of each native/// export now opens with an Origin section naming the APK package, the library, its ABI, source entry and sha256, and how to pair it with the parent APK export's JNI metadata. export_binary/build_export_agents accept an optional origin paragraph for that. The mirrored native progress counter now only counts completed function lines (the IDA path also logs a start line per function). Co-Authored-By: Claude Opus 5 --- src/tocode/apk.py | 1 + src/tocode/apk_native.py | 26 +++++++++++++++++++- src/tocode/exporter.py | 17 +++++++++++-- tests/test_apk_export.py | 53 ++++++++++++++++++++++++++++++++++++++++ tests/test_exporter.py | 13 ++++++++++ 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/tocode/apk.py b/src/tocode/apk.py index af44568..0526dbf 100644 --- a/src/tocode/apk.py +++ b/src/tocode/apk.py @@ -289,6 +289,7 @@ def _extract_entries(context: ApkContext) -> None: context.entries = entries context.libs = extract_native_libs(apk_set.apks, root, sha256=sha256_file) for lib in context.libs: + lib.package = context.package for entry in entries: if entry.name == lib.entry and entry.apk == lib.source_apk: entry.exported_path = lib.path.relative_to(root).as_posix() diff --git a/src/tocode/apk_native.py b/src/tocode/apk_native.py index 872f1c0..bbb3b3a 100644 --- a/src/tocode/apk_native.py +++ b/src/tocode/apk_native.py @@ -42,7 +42,11 @@ NATIVE_HEARTBEAT_SECONDS = 20.0 _LOG_TIMESTAMP_RX = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\s+") _RENDER_TOTAL_RX = re.compile(r"Rendering and writing (\d+) functions") -_FUNCTION_LINE_RX = re.compile(r"^export \S+ 0x[0-9a-f]+ - ") +# Per-function render lines: "export 0x - bytes done|failed: ...". +# The parent also logs a bare "export 0x - bytes" when it hands a +# function to a worker; only completed ones count toward the bar. +_FUNCTION_LINE_RX = re.compile(r"^export \S+ 0x[0-9a-f]+ - \d+ bytes (done|failed)") +_FUNCTION_START_RX = re.compile(r"^export \S+ 0x[0-9a-f]+ - \d+ bytes$") KNOWN_ABIS = ("arm64-v8a", "armeabi-v7a", "armeabi", "x86_64", "x86", "mips", "mips64") @@ -62,6 +66,8 @@ class NativeLib: failure_count: int | None = None status: str = "pending" seconds: float | None = None + # Package of the APK the library was extracted from (for the nested AGENTS.md). + package: str = "" @property def stem(self) -> str: @@ -134,6 +140,21 @@ def native_export_dir(root: Path, lib: NativeLib) -> Path: NativeExporter = Callable[[NativeLib, Path, Progress], tuple[str, str, int, int]] +def describe_origin(lib: NativeLib) -> str: + """Provenance paragraph for the nested export's AGENTS.md.""" + package = f"`{lib.package}`" if lib.package else "an Android app" + return ( + f"This is a decompiled native library from the {package} APK: " + f"`{lib.name}` ({lib.abi}, `{lib.entry}` in `{lib.source_apk}`, " + f"sha256 `{lib.sha256}`). " + "The APK export that owns it is three directories up (`../../..`): its " + "`exports.json` and `triage.json` list the Java `native` methods with the " + "`Java___` JNI symbols this library is expected to " + "export, `native-libs.json` lists the other libraries of the app, and " + "`src/raw//` holds the decompiled Java that calls into this code." + ) + + def run_native_export( lib: NativeLib, out_dir: Path, options: NativeOptions, progress: Progress ) -> tuple[str, str, int, int]: @@ -157,6 +178,7 @@ def run_native_export( jobs=options.jobs, tree=options.tree, entropy=options.entropy, + origin=describe_origin(lib), ) return ( analyzer.backend_name, @@ -381,6 +403,8 @@ def _handle_line(self, line: str) -> None: if self._bar is not None: self._bar.update(1) return + if _FUNCTION_START_RX.match(line): + return self.last_line = line total = _RENDER_TOTAL_RX.search(line) if total is not None: diff --git a/src/tocode/exporter.py b/src/tocode/exporter.py index 5c67f25..646dbbb 100644 --- a/src/tocode/exporter.py +++ b/src/tocode/exporter.py @@ -532,6 +532,9 @@ class ExportContext: tree_enabled: bool entropy_enabled: bool = False restart: bool = False + # Free-form provenance shown at the top of the generated AGENTS.md (e.g. a + # native library extracted from an APK export). + origin: str | None = None analysis: ProgramAnalysis | None = None root: Path | None = None raw_dir: Path | None = None @@ -592,6 +595,7 @@ def export_binary( tree: bool = False, entropy: bool = False, restart: bool = False, + origin: str | None = None, ) -> ExportSummary: progress = progress or analyzer.progress context = ExportContext( @@ -602,6 +606,7 @@ def export_binary( tree_enabled=tree, entropy_enabled=entropy, restart=restart, + origin=origin, ) try: _prepare_tree(context) @@ -2514,7 +2519,10 @@ def write_docs() -> None: write_text_atomic( root / "AGENTS.md", build_export_agents( - analysis, context.header_name, tree_enabled=context.tree_enabled + analysis, + context.header_name, + tree_enabled=context.tree_enabled, + origin=context.origin, ), ) write_text_atomic(root / "CLAUDE.md", "@./AGENTS.md\n") @@ -2897,7 +2905,11 @@ def write_manifest(context: ExportContext) -> Path: def build_export_agents( - analysis: ProgramAnalysis, header_name: str, *, tree_enabled: bool = True + analysis: ProgramAnalysis, + header_name: str, + *, + tree_enabled: bool = True, + origin: str | None = None, ) -> str: section_files = sorted( [ @@ -2916,6 +2928,7 @@ def build_export_agents( "You are working inside a ToCode binary export.", "Treat the recovered source, assembly, metadata, and raw section data as evidence for reverse engineering.", "", + *(["## Origin", "", origin, ""] if origin else []), "## Mission", "", "Reverse the binary, answer user questions, or serve as an oracle/helper to the user regarding this recovered source code.", diff --git a/tests/test_apk_export.py b/tests/test_apk_export.py index 6df2d5c..1541dfc 100644 --- a/tests/test_apk_export.py +++ b/tests/test_apk_export.py @@ -878,6 +878,7 @@ def test_nested_log_tail_mirrors_lines_and_counts_functions(tmp_path: Path) -> N ) handle.write("2026-09-19T20:41:15 export sub_1000 0x1000 - 10 bytes done\n") handle.write("2026-09-19T20:41:15 export sub_2000 0x2000 - 10 bytes done\n") + handle.write("2026-09-19T20:41:15 export sub_3000 0x3000 - 10 bytes\n") # start handle.write("plain backend stderr line\n") handle.write("2026-09-19T20:41:16 partial line without newline") tail.stop() @@ -889,6 +890,9 @@ def test_nested_log_tail_mirrors_lines_and_counts_functions(tmp_path: Path) -> N assert "native[libfoo.so]: Rendering and writing 3 functions" in mirrored assert "native[libfoo.so]: plain backend stderr line" in mirrored assert "export sub_1000" not in mirrored # per-function lines feed the bar only + assert ( + "export sub_3000" not in mirrored + ) # start lines are neither counted nor shown assert "partial line" not in mirrored @@ -934,3 +938,52 @@ def slow( assert status.startswith("native: libfoo.so (arm64-v8a) 0/5 functions") assert "0/1 libraries finished" in status assert runner.describe_current() is None + + +def test_describe_origin_names_the_apk_package(tmp_path: Path) -> None: + from tocode.apk_native import describe_origin + + lib = NativeLib( + "arm64-v8a", + "libfoo.so", + "lib/arm64-v8a/libfoo.so", + "base.apk", + 1, + "abc", + tmp_path / "libfoo.so", + package="com.example.app", + ) + + text = describe_origin(lib) + + assert text.startswith( + "This is a decompiled native library from the `com.example.app` APK: " + "`libfoo.so` (arm64-v8a, `lib/arm64-v8a/libfoo.so` in `base.apk`, sha256 `abc`)." + ) + assert "Java___" in text + assert "an Android app" in describe_origin( + NativeLib("x86", "l.so", "lib/x86/l.so", "a.apk", 1, "h", tmp_path / "l.so") + ) + + +def test_export_apk_tags_native_libs_with_the_package( + tmp_path: Path, fake_backend: None +) -> None: + apk = _build_apk(tmp_path / "base.apk") + seen: list[str] = [] + + def exporter( + lib: NativeLib, out_dir: Path, progress: Progress + ) -> tuple[str, str, int, int]: + seen.append(lib.package) + return ("r2", "radare2 pdc", 1, 0) + + export_apk( + apk, + options=ApkExportOptions(out_dir=tmp_path / "export", jobs=1), + progress=Progress(enabled=False), + native_exporter=exporter, + decompiler_factory=FakeDecompiler, + ) + + assert seen == ["com.example.app"] * 3 diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 09bcb34..da05dd9 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -1258,3 +1258,16 @@ def test_tree_safe_function_preserves_scanner_calls() -> None: assert "strcpy(dest, a1);" in tree_source assert "strcpy__GLIBC_2_17" not in tree_source assert "return 0LL;" in tree_source + + +def test_build_export_agents_includes_origin_section() -> None: + from tocode.exporter import build_export_agents + + analysis = _single_routine_analysis() + with_origin = build_export_agents( + analysis, "sample.h", tree_enabled=False, origin="From the `com.x` APK." + ) + without = build_export_agents(analysis, "sample.h", tree_enabled=False) + + assert "## Origin\n\nFrom the `com.x` APK.\n\n## Mission" in with_origin + assert "## Origin" not in without From 3350b0a236540a5ced172111b7c01ef4b25545d5 Mon Sep 17 00:00:00 2001 From: buzzer-re <22428720+buzzer-re@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:13:59 +0900 Subject: [PATCH 3/3] Document the APK-only export paths in the README layout Co-Authored-By: Claude Opus 5 --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0c2249b..4e70536 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,22 @@ sample_decompiler/ src/raw/**/*.c src/raw/**/*.asm src/raw/**/*.summary + src/raw//**/*.java # Only for APK files (replaces the .c/.asm/.summary tree) include/*.h include/*.types.h data/*.bin data/variables.json data/variables_interesting.json + data/apk//** # Only for APK files: every non-code APK entry, verbatim + data/res//**/*.xml # Only for APK files: binary XML resources decoded to text + data/resources.json # Only for APK files: decoded resources.arsc + lib//*.so # Only for APK files: extracted native libraries + native/// # Only for APK files: full nested ToCode export per .so + AndroidManifest.xml # Only for APK files + manifest.json # Only for APK files + classes.json # Only for APK files + package-graph.json # Only for APK files (replaces cluster-graph.json) + native-libs.json # Only for APK files function-index.json functions.json types.json @@ -47,11 +58,19 @@ sample_decompiler/ | Path | Description | | --- | --- | | `src/raw` | Decompiled C-like output, assembly, and summaries. Grouped by call-graph cluster, or by the original source file/directory when the binary has debug info (DWARF). | -| `include` | Generated headers, including `*.types.h` with the structs/enums/typedefs recovered from the binary. | +| `src/raw//**/*.java` | **Only for APK files.** Decompiled Java (ASC + androguard DAD), one file per class, folders follow Java packages; inner classes are `Outer$Inner.java`. No clustering, no `.summary` files. | +| `include` | Generated headers, including `*.types.h` with the structs/enums/typedefs recovered from the binary. Not written for APK files. | | `data` | Raw section dumps and variable metadata. | +| `data/apk`, `data/res`, `data/resources.json` | **Only for APK files.** Every non-code entry of each APK in the set verbatim, `res/**/*.xml` decoded from binary XML, and the decoded `resources.arsc` tables. | +| `lib//*.so` | **Only for APK files.** Every native library found in the APK set (all ABIs), always extracted. | +| `native///` | **Only for APK files.** A complete nested ToCode export for each native library (own `AGENTS.md` with an Origin section naming the APK, `src/raw/*.c`, `functions.json`, `exports.json`, ...). Skipped with `--no-native`. | +| `AndroidManifest.xml` / `manifest.json` | **Only for APK files.** Decoded manifest and its parsed form: package, versions, SDKs, permissions, components with intent filters and exported state, application attributes, split manifests. | +| `classes.json` | **Only for APK files.** Every class with superclass, interfaces, access flags, fields, methods, source file, and Java file/line ranges. | +| `package-graph.json` | **Only for APK files.** Inter-package call graph (the APK counterpart of `cluster-graph.json`). | +| `native-libs.json` | **Only for APK files.** ABI, hash, source APK, export directory, and decompilation status of every native library. | | `types.json` | Catalog of types recovered from the binary's debug info or type library, with C declarations. | -| `*.json` | Functions (with recovered types and original source decl file/line), sections, strings, imports, exports, relocations, reachability, clusters, triage, project metadata, and export manifest. | -| `tocode.log` | Export log with checkpoint, resume, and per-function render history. | +| `*.json` | Functions (with recovered types and original source decl file/line), sections, strings, imports, exports, relocations, reachability, clusters, triage, project metadata, and export manifest. For APK files the same documents describe DEX methods, strings, framework imports, exported components/JNI methods, and reachability from manifest components; `relocations.json`, `cluster-graph.json`, and `types.json` are not written. | +| `tocode.log` | Export log with checkpoint, resume, and per-function render history. For APK files it also carries the native library export status. | | `AGENTS.md` / `CLAUDE.md` | Instructions for agents analyzing the exported binary. | | `src/tree` | Optional scanner-friendly C output when `--tree` is used. |