diff --git a/README.md b/README.md index 4839a54..0d51396 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,60 @@ bootstraps the `sa` login — additional logins are a known limitation. the same category as `psql` for the Postgres path) rather than a Python REPL. +## `pgdb migrate` + +Applies numbered, forward-only SQL migration files from a directory to a live +Postgres database, tracking each one in a `schema.table` (default +`public.schema_migrations`) so repeat runs only apply what's pending. Postgres only — +not available for `--dialect mssql`. + +```bash +pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db +pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db +``` + +`--entra-user` works the same as `pgdb compare` (see above). The tracking +table needs `filename text primary key, applied_at timestamptz not null +default now(), applied_by text not null default current_user` (a migration +file that creates it, in the same directory, is the usual way to bootstrap +it — inserting into a not-yet-existing tracking table is tolerated so that +migration can still run). + +The tracking table defaults to `public.schema_migrations`. Override it per-project in +`pyproject.toml`: + +```toml +[tool.pgdevkit] +migrations_table = "myschema.migrations" +``` + +or per-invocation with `--tracking-table`, which takes precedence over the +pyproject.toml value. + +`--ask` prints each pending file and asks yes/no/already-done/quit before +running it. Answering yes queues the file on a background worker and moves +straight to the next prompt — you can keep reviewing while earlier files are +still executing, instead of waiting on each one before seeing the next. A +`tqdm` progress bar tracks the queue; migrations still run one at a time, in +file order. Without `--ask`, `apply` queues every pending file up front and +shows the same progress bar. `--file ` applies a single file (still +through the same verify-and-track path) instead of walking all pending ones. +Pass `--yes` to skip the "about to run migrations against ..." confirmation +prompt (e.g. in CI). + +After each file's DDL is applied, `apply` re-checks that every `CREATE TABLE` +statement's target actually exists (via `to_regclass`) before recording the +file as applied — catching a migration that silently rolled back. That check +parses each statement with `sqlglot` and only falls back to a regex (run +against comment-stripped SQL) for statements sqlglot's postgres dialect can't +parse, so a `CREATE TABLE` mentioned only in a `--` comment is never mistaken +for a real one. + +`pgdevkit.migrate` is also usable directly as a library — `list_migration_files`, +`applied_migrations`, `pending_migrations`, and `apply_migration` are the same +functions the CLI calls, so a project can script around them without shelling +out. + ## `pgdevkit.db` — helpers for application code Install with the `db` extra: `pip install pgdevkit[db]`. diff --git a/pgdevkit/cli.py b/pgdevkit/cli.py index 916e3a4..e5906ad 100644 --- a/pgdevkit/cli.py +++ b/pgdevkit/cli.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import queue +import threading from pathlib import Path import psycopg @@ -8,8 +10,9 @@ from rich.console import Console from rich.table import Table from rich import box +from tqdm import tqdm -from . import testdb +from . import migrate, testdb from .backends import get_backend from .connection import build_conninfo from .diff import DiffKind, compute_diff @@ -23,6 +26,11 @@ testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container") app.add_typer(testdb_app, name="testdb") +migrate_app = typer.Typer( + name="migrate", help="Apply numbered, forward-only SQL migration files, tracked in a DB table" +) +app.add_typer(migrate_app, name="migrate") + @app.command() def compare( @@ -224,3 +232,177 @@ def testdb_clean( """Drop this workspace's database (or every database of this project with --all).""" testdb.clean_testdb(all=all) console.print("[green]Cleaned.[/green]") + + +@migrate_app.command("check") +def migrate_check( + migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"), + url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"), + entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"), + tracking_table: str | None = typer.Option( + None, + "--tracking-table", + help="schema.table recording applied migrations " + "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)", + ), +) -> None: + """List which migration files under migrations_dir are applied vs. pending.""" + if not migrations_dir.is_dir(): + err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory") + raise typer.Exit(2) + + conninfo = build_conninfo(url, entra_user) + tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir) + local_files = migrate.list_migration_files(migrations_dir) + try: + applied = migrate.applied_migrations(conninfo, tracking_table) + except migrate.TrackingTableMissing: + err_console.print(f"[yellow]⚠[/yellow] {tracking_table} not found — nothing recorded as applied yet") + applied = {} + + table = Table(box=box.SIMPLE, show_header=True, header_style="bold") + table.add_column("File") + table.add_column("Status") + table.add_column("Applied at", style="dim") + pending = [] + for f in local_files: + if f.name in applied: + applied_at, applied_by = applied[f.name] + table.add_row(f.name, "[green]applied[/green]", f"{applied_at:%Y-%m-%d %H:%M} ({applied_by})") + else: + table.add_row(f.name, "[yellow]pending[/yellow]", "") + pending.append(f.name) + console.print(table) + console.print(f"\n{len(pending)} pending, {len(applied)} applied, {len(local_files)} total") + + +@migrate_app.command("apply") +def migrate_apply( + migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"), + url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"), + entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"), + tracking_table: str | None = typer.Option( + None, + "--tracking-table", + help="schema.table recording applied migrations " + "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)", + ), + file: str | None = typer.Option( + None, "--file", help="Apply only this one filename (relative to migrations_dir) instead of all pending" + ), + ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"), +) -> None: + """Apply pending migration files, in filename order, tracking each in tracking_table.""" + if not migrations_dir.is_dir(): + err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory") + raise typer.Exit(2) + + conninfo = build_conninfo(url, entra_user) + tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir) + target_desc = url.rsplit("@", 1)[-1] if "@" in url else url + if not yes: + typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True) + + if file: + targets = [migrations_dir / file] + else: + try: + targets = migrate.pending_migrations(migrations_dir, conninfo, tracking_table) + except migrate.TrackingTableMissing: + targets = migrate.list_migration_files(migrations_dir) + + if not targets: + console.print("No pending migrations.") + return + + # A single background worker applies queued migrations in file order (each still waits + # for the previous one to land) while --ask keeps prompting for the *next* file, instead + # of the review blocking on every execution. + work_q: queue.Queue[tuple[Path, bool] | None] = queue.Queue() + failure: Exception | None = None + stop = threading.Event() + outcomes: list[tuple[str, str]] = [] + bar = tqdm(total=len(targets), unit="migration", desc="Applying") + + def worker() -> None: + nonlocal failure + for path, already_done in iter(work_q.get, None): + if not stop.is_set(): + try: + result = migrate.apply_migration(conninfo, path, tracking_table, already_done=already_done) + except Exception as e: # noqa: BLE001 + failure = e + stop.set() + bar.write(f"FAILED {path.name}: {e}") + outcomes.append((path.name, "failed")) + else: + if result.executed: + for tbl in result.verified_tables: + bar.write(f" table {tbl} exists") + bar.write(f"Applied {path.name}") + outcomes.append((path.name, "applied")) + else: + bar.write(f"Recorded {path.name} as already applied (not executed)") + outcomes.append((path.name, "recorded")) + bar.update(1) + work_q.task_done() + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + + quit_requested = False + for path in targets: + if stop.is_set(): + break + already_done = False + if ask: + bar.write(f"\n=== {path.name} ===") + bar.write(path.read_text(encoding="utf-8")) + answer = typer.prompt("[Y]es execute / [n]o skip / [a]lready done / [q]uit", default="y").strip().lower() + if answer in ("q", "quit"): + quit_requested = True + break + if answer in ("n", "no"): + bar.write(f"Skipped {path.name}") + outcomes.append((path.name, "skipped")) + bar.update(1) + continue + if answer in ("a", "already", "already done"): + already_done = True + elif answer not in ("", "y", "yes"): + bar.write(f"Skipped {path.name}") + outcomes.append((path.name, "skipped")) + bar.update(1) + continue + + work_q.put((path, already_done)) + + work_q.put(None) + thread.join() + bar.close() + + processed = {name for name, _ in outcomes} + for path in targets: + if stop.is_set() and path.name not in processed: + outcomes.append((path.name, "not run (stopped after earlier failure)")) + + table = Table(box=box.SIMPLE, show_header=True, header_style="bold") + table.add_column("File") + table.add_column("Result") + status_style = { + "applied": "[green]applied[/green]", + "recorded": "[green]recorded (already done)[/green]", + "skipped": "[yellow]skipped[/yellow]", + "failed": "[red]failed[/red]", + } + for name, status in outcomes: + table.add_row(name, status_style.get(status, status)) + console.print(table) + + if quit_requested: + console.print("Aborted.") + raise typer.Exit(1) + if failure is not None: + err_console.print(f"[red]✗[/red] {failure}") + raise typer.Exit(1) diff --git a/pgdevkit/migrate.py b/pgdevkit/migrate.py new file mode 100644 index 0000000..a088238 --- /dev/null +++ b/pgdevkit/migrate.py @@ -0,0 +1,292 @@ +"""Apply numbered, forward-only SQL migration files to a live Postgres database, +tracked in a `schema.table` (default `public.schema_migrations`) so re-runs only +apply what's pending. Ported from a hand-rolled per-project script — MSSQL is not +supported yet. +""" + +from __future__ import annotations + +import re +import tomllib +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import LiteralString, cast + +import psycopg +import sqlglot +from psycopg import errors as pg_errors +from psycopg import sql as pg_sql + +_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*" +_DEFAULT_TRACKING_TABLE = "public.schema_migrations" + + +class MigrationVerificationError(RuntimeError): + """A CREATE TABLE statement's target doesn't exist after applying the migration.""" + + +class TrackingTableMissing(RuntimeError): + """The migration-tracking table doesn't exist yet (e.g. before it's bootstrapped).""" + + def __init__(self, tracking_table: str) -> None: + super().__init__(tracking_table) + self.tracking_table = tracking_table + + +def _tracking_table_identifier(tracking_table: str) -> pg_sql.Identifier: + """Parse 'schema.table' into a safely-quoted, injection-proof identifier.""" + if not re.fullmatch(rf"{_IDENTIFIER}\.{_IDENTIFIER}", tracking_table): + raise ValueError(f"tracking_table must look like schema.table, got {tracking_table!r}") + schema, _, table = tracking_table.partition(".") + return pg_sql.Identifier(schema, table) + + +def _find_pyproject(start: Path) -> Path | None: + for directory in [start, *start.parents]: + candidate = directory / "pyproject.toml" + if candidate.exists(): + return candidate + return None + + +def default_tracking_table(start: Path | None = None) -> str: + """The project's configured tracking table: `[tool.pgdevkit].migrations_table` in the + nearest pyproject.toml at or above `start` (default: cwd), or "public.schema_migrations" + if neither is set. Lets a project fix its tracking table once instead of passing + --tracking-table on every `pgdb migrate` invocation.""" + pyproject = _find_pyproject((start or Path.cwd()).resolve()) + if pyproject is None: + return _DEFAULT_TRACKING_TABLE + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + section = data.get("tool", {}).get("pgdevkit", {}) + return section.get("migrations_table", _DEFAULT_TRACKING_TABLE) + + +def _split_sql(sql: str) -> list[str]: + """Split SQL on semicolons, ignoring those inside comments, '...' strings, or $$...$$ blocks.""" + stmts: list[str] = [] + buf: list[str] = [] + i = 0 + in_string = False + in_line_comment = False + dollar_tag: str | None = None + + while i < len(sql): + c = sql[i] + if in_line_comment: + if c == "\n": + in_line_comment = False + buf.append(c) + elif dollar_tag is not None: + buf.append(c) + if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag: + buf.extend(list(dollar_tag[1:])) + i += len(dollar_tag) + dollar_tag = None + continue + elif in_string: + if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'": + buf.append(c) + buf.append(sql[i + 1]) + i += 2 + continue + elif c == "'": + in_string = False + buf.append(c) + elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-": + in_line_comment = True + buf.append(c) + elif c == "$": + m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:]) + if m: + dollar_tag = m.group(0) + buf.extend(list(dollar_tag)) + i += len(dollar_tag) + continue + buf.append(c) + elif c == "'": + in_string = True + buf.append(c) + elif c == ";": + stmt = "".join(buf).strip() + if stmt: + stmts.append(stmt) + buf = [] + else: + buf.append(c) + i += 1 + remainder = "".join(buf).strip() + if remainder: + stmts.append(remainder) + return stmts + + +def _strip_line_comments(sql: str) -> str: + """Drop '--' line comments, respecting string literals and $$...$$ blocks.""" + buf: list[str] = [] + i = 0 + in_string = False + in_line_comment = False + dollar_tag: str | None = None + + while i < len(sql): + c = sql[i] + if in_line_comment: + if c == "\n": + in_line_comment = False + buf.append(c) + elif dollar_tag is not None: + buf.append(c) + if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag: + buf.extend(list(dollar_tag[1:])) + i += len(dollar_tag) + dollar_tag = None + continue + elif in_string: + if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'": + buf.append(c) + buf.append(sql[i + 1]) + i += 2 + continue + elif c == "'": + in_string = False + buf.append(c) + elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-": + in_line_comment = True + elif c == "$": + m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:]) + if m: + dollar_tag = m.group(0) + buf.extend(list(dollar_tag)) + i += len(dollar_tag) + continue + buf.append(c) + elif c == "'": + in_string = True + buf.append(c) + else: + buf.append(c) + i += 1 + return "".join(buf) + + +def _created_table_names(stmts: list[str]) -> list[str]: + """Names of tables any CREATE TABLE statement targets, parsed via sqlglot (falls back to + regex on comment-stripped text for statements sqlglot's postgres dialect can't parse).""" + names: list[str] = [] + for stmt in stmts: + stripped = _strip_line_comments(stmt) + if not re.search(r"CREATE\s+TABLE", stripped, re.IGNORECASE): + continue # skip sqlglot entirely for statements that can't be a CREATE TABLE + try: + parsed = sqlglot.parse_one(stmt, dialect="postgres") + except Exception: + parsed = None + if parsed is not None and isinstance(parsed, sqlglot.exp.Create) and parsed.kind == "TABLE": + table = parsed.this.this if isinstance(parsed.this, sqlglot.exp.Schema) else parsed.this + if isinstance(table, sqlglot.exp.Table): + names.append(table.sql(dialect="postgres")) + continue + names.extend( + m.group(1) + for m in re.finditer( + r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\S+)", stripped, re.IGNORECASE + ) + ) + return names + + +def list_migration_files(migrations_dir: Path) -> list[Path]: + return sorted(migrations_dir.glob("*.sql")) + + +def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]: + """Filename -> (applied_at, applied_by) for every migration recorded in the tracking table.""" + table = _tracking_table_identifier(tracking_table) + with psycopg.connect(conninfo) as con: + try: + rows = con.execute( + pg_sql.SQL("select filename, applied_at, applied_by from {} order by applied_at").format(table) + ).fetchall() + except pg_errors.UndefinedTable as e: + raise TrackingTableMissing(tracking_table) from e + return {r[0]: (r[1], r[2]) for r in rows} + + +def pending_migrations(migrations_dir: Path, conninfo: str, tracking_table: str) -> list[Path]: + applied = applied_migrations(conninfo, tracking_table) + return [p for p in list_migration_files(migrations_dir) if p.name not in applied] + + +def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool: + """Best-effort insert into the tracking table. Returns False without raising if the + tracking table doesn't exist yet — e.g. this migration is the one that creates it.""" + table = _tracking_table_identifier(tracking_table) + with psycopg.connect(conninfo) as con: + try: + con.execute( + pg_sql.SQL("insert into {} (filename) values (%s) on conflict do nothing").format(table), + (filename,), + ) + con.commit() + return True + except pg_errors.UndefinedTable: + con.rollback() + return False + + +def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]: + """Table names from this migration's CREATE TABLE statements that do NOT exist in the + database. Empty means everything landed.""" + tables = _created_table_names(stmts) + if not tables: + return [] + missing = [] + with psycopg.connect(conninfo) as con: + for tbl in tables: + row = con.execute("select to_regclass(%s)", (tbl,)).fetchone() + if not (row and row[0]): + missing.append(tbl) + return missing + + +@dataclass +class ApplyResult: + filename: str + executed: bool # False if the caller marked it "already done" instead of running it + verified_tables: list[str] + + +def apply_migration( + conninfo: str, + path: Path, + tracking_table: str, + *, + already_done: bool = False, +) -> ApplyResult: + sql = path.read_text(encoding="utf-8") + filename = path.name + stmts = _split_sql(sql) + + if not already_done: + # DDL in its own committed transaction. + with psycopg.connect(conninfo) as con: + for stmt in stmts: + con.execute(cast(LiteralString, stmt)) + con.commit() + + # Tracking insert is a separate connection/transaction so a missing tracking table + # never rolls back the DDL that was just applied. + record_applied(conninfo, tracking_table, filename) + + if already_done: + return ApplyResult(filename, executed=False, verified_tables=[]) + + missing = verify_created_tables(conninfo, stmts) + if missing: + raise MigrationVerificationError( + f"{filename}: table(s) not found after apply — migration may have rolled back: " + + ", ".join(missing) + ) + return ApplyResult(filename, executed=True, verified_tables=_created_table_names(stmts)) diff --git a/pyproject.toml b/pyproject.toml index 6b3eaf8..f120603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ azure = ["azure-identity>=1.19.0"] cli = [ "rich>=13.0.0", + "tqdm>=4.66.0", "typer>=0.26.7", ] db = [ diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..d80a608 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pgdevkit.migrate import _created_table_names, _split_sql, _strip_line_comments, default_tracking_table + + +def test_created_table_names_ignores_create_table_mentioned_in_a_comment(): + sql = """ + -- with no CREATE TABLE checked in under database/ or _migration_scripts/. Adding + -- it here (and as schema-as-code) so a fresh/reset test DB actually has it. + CREATE TABLE IF NOT EXISTS app.widgets ( + id text NOT NULL, + CONSTRAINT widgets_pkey PRIMARY KEY (id) + ); + """ + assert _created_table_names(_split_sql(sql)) == ["app.widgets"] + + +def test_created_table_names_finds_multiple_tables(): + sql = """ + CREATE TABLE a.b (id int); + CREATE TABLE IF NOT EXISTS c.d (id int); + """ + assert _created_table_names(_split_sql(sql)) == ["a.b", "c.d"] + + +def test_created_table_names_ignores_non_create_table_statements(): + sql = """ + GRANT SELECT ON ALL TABLES IN SCHEMA app TO some_role; + ALTER TABLE app.widgets ADD COLUMN name text; + """ + assert _created_table_names(_split_sql(sql)) == [] + + +def test_created_table_names_falls_back_to_regex_for_unparseable_statements(): + # sqlglot's postgres dialect can't parse DO $$ ... $$ blocks — the fallback + # regex must still run against comment-stripped text, not raw text. + sql = """ + -- DO NOT CREATE TABLE this manually, use the migration + DO $$ + BEGIN + CREATE TABLE app.dynamic_table (id int); + END $$; + """ + assert _created_table_names(_split_sql(sql)) == ["app.dynamic_table"] + + +def test_strip_line_comments_preserves_string_literals_containing_dashes(): + sql = "SELECT '--not-a-comment' AS x -- a real comment\nFROM t;" + stripped = _strip_line_comments(sql) + assert "--not-a-comment" in stripped + assert "a real comment" not in stripped + + +def test_default_tracking_table_falls_back_when_no_pyproject_section(tmp_path): + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n") + assert default_tracking_table(tmp_path) == "public.schema_migrations" + + +def test_default_tracking_table_reads_pyproject_override(tmp_path): + (tmp_path / "pyproject.toml").write_text( + "[tool.pgdevkit]\nmigrations_table = 'myschema.migrations'\n" + ) + assert default_tracking_table(tmp_path) == "myschema.migrations" + + +def test_default_tracking_table_searches_parent_directories(tmp_path): + (tmp_path / "pyproject.toml").write_text( + "[tool.pgdevkit]\nmigrations_table = 'myschema.migrations'\n" + ) + nested = tmp_path / "database" / "_migration_scripts" + nested.mkdir(parents=True) + assert default_tracking_table(nested) == "myschema.migrations" + + +def test_default_tracking_table_falls_back_with_no_pyproject_at_all(tmp_path): + assert default_tracking_table(tmp_path / "nonexistent") == "public.schema_migrations" diff --git a/uv.lock b/uv.lock index 6c9d264..b113c74 100644 --- a/uv.lock +++ b/uv.lock @@ -327,6 +327,7 @@ azure = [ ] cli = [ { name = "rich" }, + { name = "tqdm" }, { name = "typer" }, ] db = [ @@ -358,6 +359,7 @@ requires-dist = [ { name = "pydantic", marker = "extra == 'db'", specifier = ">=2.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" }, { name = "sqlglot", specifier = ">=30.11.0" }, + { name = "tqdm", marker = "extra == 'cli'", specifier = ">=4.66.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.26.7" }, ] provides-extras = ["azure", "cli", "db", "mssql"] @@ -624,6 +626,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/86/53edf106e8cd3c883ccd0c6b470bf00ddf877a86e667665343b2d597329d/sqlglot-30.11.0-py3-none-any.whl", hash = "sha256:cffdee57d1f2f5472dc9f13087e618cf795841172b7d5ef78b63a051a52d2710", size = 698721, upload-time = "2026-06-11T17:11:35.737Z" }, ] +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + [[package]] name = "ty" version = "0.0.59"