diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 5ede913..539ef84 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - name: Install uv run: curl -LsSf https://astral.sh/uv/install.sh | sh - name: Install dependencies diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 3a2ab9f..7fd6eae 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -14,7 +14,9 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + # pgdevkit (a real dependency as of `bdt cleanup orphaned-dbs`'s + # pgdevkit delegation) requires Python >=3.14. + python-version: ["3.14"] steps: - uses: actions/checkout@v4 diff --git a/.python-version b/.python-version index e4fba21..6324d40 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.14 diff --git a/README.md b/README.md index baed34e..eb4c642 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # bmsdna-devtools Shared developer tooling for BMS projects: PR build/check status, PR -creation, issue/work item creation and comments, git worktrees, a -commit-and-push helper with pre-flight checks, and Azure log queries. +creation, issue/work item creation and comments, git worktrees (creation and +merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with +pre-flight checks, and Azure log queries. `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin` remote is Azure DevOps or GitHub and use `az`/`gh` accordingly. Consolidates near-duplicate scripts that used to be copy-pasted across @@ -239,6 +240,93 @@ Creates `.worktrees/` branched from `--base`, initializes submodules (unless `--no-submodules`), and copies an env file into the new worktree as `.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given). +## `bdt cleanup worktrees` / `bdt cleanup orphaned-dbs` / `bdt cleanup db` / `bdt cleanup worktree` + +```bash +bdt cleanup worktrees [root] [--remote origin] [--keep-dbs] [--yes] +bdt cleanup orphaned-dbs [root] [--include-caution] [--yes] +bdt cleanup db [path] [--confirm] +bdt cleanup worktree [path] [--keep-db] [--confirm] +``` + +The first two recursively scan every git repo under `root` (default: `.`) +for worktrees. `bdt cleanup worktrees` prunes the ones fully merged into +`/main`/`/test` (falling back to local `main`/`test` if no +such remote refs exist) — e.g. a tree of `.worktrees/` directories +accumulated across several repos over time. `bdt cleanup orphaned-dbs` has no +`--remote`/merge-status notion at all: it just finds pgdevkit test DBs with +no matching *live* git worktree, regardless of whether that worktree was +ever merged anywhere. Like `bdt issue delete`, neither command has an +interactive prompt — both only ever *print* what they would remove/drop; +pass `--yes` to actually do it. + +`bdt cleanup db` / `bdt cleanup worktree` instead target one specific, +still-live worktree — `path` defaults to `.`, so both are meant to be run +from inside the worktree in question, regardless of its merge status. +`cleanup db` only drops that worktree's own pgdevkit test DB(s), leaving the +worktree itself alone; `cleanup worktree` removes the worktree too (and, +unless `--keep-db`, its DB(s) along with it) — refusing the main checkout, a +protected branch (`main`/`test`), a locked worktree, or a dirty one +(submodules included). Since these two act on a single worktree a human +picked out by hand, rather than scanning for candidates, they default to an +interactive `y/N` confirmation instead of `--yes`; pass `--confirm` to skip +it for non-interactive use. + +Every one of these four commands, before dropping any database, additionally +requires typing `yes` at an interactive prompt whenever `--pg-host` isn't +`localhost`/`127.0.0.1`/`::1` — this specific check has no flag to bypass it +(not even `--yes`/`--confirm`), so a script or agent can never drop a +database on a shared/remote Postgres instance without a human confirming it +directly. + +The DB-naming algorithm and orphan detection are entirely +[pgdevkit](https://github.com/bmsuisse/pgdevkit)'s own +(`pgdevkit.testdb.workspace_db_names()` / +`pgdevkit.testdb.find_orphaned_dbs()`) — this used to be a hand-rolled +reimplementation here (to avoid an import), which risked drifting out of +sync with pgdevkit's actual naming; now that pgdevkit exposes both directly, +bdt just calls them. If a repo's root `pyproject.toml` has a +`[tool.pgdevkit].engine = "postgres"` (the default once `[tool.pgdevkit]` +exists at all), removing one of its worktrees also drops the Postgres test +database(s) pgdevkit created for that branch — pass `--keep-dbs` to skip +that. A DB whose name ends in a bare branch name +(`main`/`test`/`dev`/`head`/`i18n`, rather than a slugified feature branch) +is flagged `⚠ possibly a standing reference DB` and excluded even with +`--yes`, since that might be an intentional baseline DB rather than an +orphaned leftover — pass `--include-caution` too if you've verified it +really is safe to drop. That flagging (bdt's own heuristic, not pgdevkit's) +is the one bit of naming-adjacent logic still here. + +A repo can additionally own **sibling** test DBs (e.g. a second DB for a +vendored mock service) and **nested** ones (an unrelated per-branch DB, under +a different pgdevkit project name, that happens to share the same branch). +Siblings are pgdevkit's own concern now — configure +`[tool.pgdevkit].extra_db_suffixes` in the *consuming* repo (see pgdevkit's +README) and both `ensure_testdb`-side tooling and `bdt cleanup` pick it up +automatically. Nested projects have no pgdevkit equivalent (it's a wholly +separate project name/pyproject.toml, not a literal suffix of the same +project's DB), so that stays configured here: + +```toml +[tool.bdt.worktree] +db_nested_projects = ["akeneo_editor"] # a wholly separate per-branch DB, + # its own (possibly section-less) + # pyproject.toml, sharing this + # worktree's branch +``` + +`--pg-port`/`--pg-user` (all four commands; env vars `PGPORT`/`PGUSER`, no +fallback to `$USER`/`$LOGNAME` — those are set in virtually every shell, +which would make the pgdevkit-user default below never fire) default to +*pgdevkit's own* test-container port/user, not the OS user or Postgres' +standard `5432` — `bdt cleanup orphaned-dbs`'s listing step always connects +via pgdevkit's own +`PGDEVKIT_TESTDB_*`-driven resolution (it's calling straight into pgdevkit), +so its own `psql`-based DROP step defaults to matching that, rather than +silently targeting a different Postgres instance than the one that was just +queried. Pass `--pg-port`/`--pg-user` explicitly if your setup deliberately +differs. + ## `bdt commit` ```bash diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index e8f690b..db9420c 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -10,6 +10,7 @@ import requests import typer +from pgdevkit.testdb import constants as pgdevkit_constants from . import ado_issue, app_service_logs, commit as commit_mod from . import env_config @@ -66,6 +67,35 @@ def main( logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries") app.add_typer(logs_app, name="logs") +cleanup_app = typer.Typer( + name="cleanup", + help="Prune merged git worktrees and their orphaned pgdevkit Postgres test databases", +) +app.add_typer(cleanup_app, name="cleanup") + +# Shared `--pg-port`/`--pg-user` defaults for `bdt cleanup *`: pgdevkit's own +# test-container port/user (its `find_orphaned_dbs()`/`workspace_db_names()` +# connect via *its* PGDEVKIT_TESTDB_* env vars, not these flags -- these only +# drive bdt's own `psql`-based DROP DATABASE step, see testdb.py's module +# docstring -- so defaulting to anything else would make the two halves of +# `bdt cleanup orphaned-dbs` silently target different Postgres instances). +_PG_HOST_OPTION = typer.Option( + pgdevkit_constants.HOST, "--pg-host", envvar="PGHOST", help="Postgres host to connect to (default: pgdevkit's own test-container host)" +) +_PG_PORT_OPTION = typer.Option( + pgdevkit_constants.PORT, "--pg-port", envvar="PGPORT", help="Postgres port to connect to (default: pgdevkit's own test-container port)" +) +_PG_USER_OPTION = typer.Option( + None, + "--pg-user", + # Deliberately just PGUSER, not also $USER/$LOGNAME: those generic OS + # envvars are set on virtually every shell, which would make `pg_user or + # pgdevkit_constants.USER`'s fallback never fire and silently connect as + # the wrong role on everyone's machine. + envvar="PGUSER", + help="Postgres user to connect as (default: pgdevkit's own test-container user)", +) + def _resolve_ado_pr(pat: str | None, remote: AdoRemote, source_branch: str, target: str) -> tuple[requests.Session, dict]: session = requests.Session() @@ -608,6 +638,77 @@ def worktree( worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd) +@cleanup_app.command("worktrees") +def cleanup_worktrees( + root: Path = typer.Argument(Path("."), help="Root folder to scan for git repositories (recursively)"), + remote: str = typer.Option("origin", "--remote", help="Remote name whose main/test branches count as 'merged into' (falls back to local main/test if no such remote refs exist)"), + keep_dbs: bool = typer.Option(False, "--keep-dbs", help="Don't drop a removed worktree's pgdevkit test DB(s) along with it"), + yes: bool = typer.Option(False, "--yes", help="Actually remove; without this, only prints what would be removed"), + pg_host: str = _PG_HOST_OPTION, + pg_port: int = _PG_PORT_OPTION, + pg_user: str | None = _PG_USER_OPTION, +) -> None: + """Find and remove git worktrees fully merged into main/test (and their pgdevkit test DB(s)), across every repo under root.""" + worktree_mod.clean_worktrees( + root, remote=remote, keep_dbs=keep_dbs, yes=yes, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER + ) + + +@cleanup_app.command("orphaned-dbs") +def cleanup_orphaned_dbs( + root: Path = typer.Argument(Path("."), help="Root folder to scan for git repositories (recursively)"), + include_caution: bool = typer.Option( + False, "--include-caution", help="Also drop DBs flagged as possibly a standing reference DB (verify those first!)" + ), + yes: bool = typer.Option(False, "--yes", help="Actually drop; without this, only prints what would be dropped"), + pg_host: str = _PG_HOST_OPTION, + pg_port: int = _PG_PORT_OPTION, + pg_user: str | None = _PG_USER_OPTION, +) -> None: + """Find and drop pgdevkit test DBs whose worktree is already gone (e.g. removed by hand before this command existed).""" + worktree_mod.clean_orphaned_dbs( + root, include_caution=include_caution, yes=yes, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER + ) + + +@cleanup_app.command("db") +def cleanup_db( + root: Path = typer.Argument(Path("."), help="Worktree/repo whose own pgdevkit test DB(s) to drop (default: current directory)"), + confirm: bool = typer.Option(False, "--confirm", help="Drop without an interactive confirmation prompt"), + pg_host: str = _PG_HOST_OPTION, + pg_port: int = _PG_PORT_OPTION, + pg_user: str | None = _PG_USER_OPTION, +) -> None: + """Drop this worktree's own pgdevkit test DB(s), without touching the worktree itself. + + Meant to be run from inside a worktree (default root is '.'). Always requires an + explicit confirmation -- pass --confirm to skip the interactive prompt. + """ + worktree_mod.clean_current_db( + root.resolve(), confirm=confirm, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER + ) + + +@cleanup_app.command("worktree") +def cleanup_worktree( + path: Path = typer.Argument(Path("."), help="Worktree to remove (default: current directory)"), + keep_db: bool = typer.Option(False, "--keep-db", help="Don't drop this worktree's pgdevkit test DB(s) along with it"), + confirm: bool = typer.Option(False, "--confirm", help="Remove without an interactive confirmation prompt"), + pg_host: str = _PG_HOST_OPTION, + pg_port: int = _PG_PORT_OPTION, + pg_user: str | None = _PG_USER_OPTION, +) -> None: + """Remove this one worktree (and, unless --keep-db, its pgdevkit test DB(s)) -- regardless of merge status. + + Meant to be run from inside the worktree to remove (default path is '.'). Refuses to touch + the main checkout, a protected branch (main/test), a locked worktree, or a dirty one. Always + requires an explicit confirmation -- pass --confirm to skip the interactive prompt. + """ + worktree_mod.clean_current_worktree( + path, confirm=confirm, keep_db=keep_db, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER + ) + + @app.command() def commit( message: str, diff --git a/bmsdna/devtools/cli_tools.py b/bmsdna/devtools/cli_tools.py index c7b2428..4468d59 100644 --- a/bmsdna/devtools/cli_tools.py +++ b/bmsdna/devtools/cli_tools.py @@ -1,4 +1,4 @@ -"""Locating required external CLIs (az, gh) with clear errors when missing. +"""Locating required external CLIs (az, gh, psql) with clear errors when missing. Uses `shutil.which` rather than a bare command name so Windows .cmd/.bat/.exe shims (e.g. az.cmd from the MSI installer) resolve correctly via PATHEXT — @@ -16,6 +16,7 @@ AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli" GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com" +PSQL_INSTALL_HINT = "Install the PostgreSQL client tools (psql): https://www.postgresql.org/download/" def is_claude_code() -> bool: @@ -153,3 +154,7 @@ def require_az() -> str: def require_gh() -> str: return require_tool("gh", GH_INSTALL_HINT) + + +def require_psql() -> str: + return require_tool("psql", PSQL_INSTALL_HINT) diff --git a/bmsdna/devtools/testdb.py b/bmsdna/devtools/testdb.py new file mode 100644 index 0000000..d813c45 --- /dev/null +++ b/bmsdna/devtools/testdb.py @@ -0,0 +1,227 @@ +"""pgdevkit test-database cleanup helpers. + +The DB-naming algorithm (`workspace_db_name`, per-branch/per-suffix +expected-name computation) and the live-worktree-vs-Postgres orphan diff +used to be reimplemented here so `bdt cleanup` didn't have to import +pgdevkit. Now that pgdevkit itself exposes `find_orphaned_dbs()` and +`workspace_db_names()` (see pgdevkit's `testdb/naming.py` and +`testdb/api.py`), this module just calls into those instead of keeping a +second copy of the algorithm to drift out of sync. + +What's still bdt's own concern, because pgdevkit has no notion of it: + +- `db_nested_projects` -- a repo may have a wholly separate pgdevkit + project nested in a subdirectory sharing the same branch (e.g. MDMApp's + `akeneo_editor/`, its own `pyproject.toml`/`[tool.pgdevkit]` section). + pgdevkit's `extra_db_suffixes` only covers a literally-suffixed sibling of + the *same* project's main DB, not a wholly separate project name -- so + bdt calls pgdevkit's per-project functions a second time with + `project_root` pointed at each configured nested subdirectory instead. + Still configured under `[tool.bdt.worktree]` in the *consuming* repo's + pyproject.toml (as opposed to `extra_db_suffixes`, which moved to + `[tool.pgdevkit]` in that same file -- see pgdevkit's README). + + [tool.bdt.worktree] + db_nested_projects = ["akeneo_editor"] + +- `is_caution_db` -- flagging a DB whose suffix looks like a bare branch + name (main/test/dev/...) rather than a slugified feature branch, since + that might be a standing reference DB rather than an orphaned leftover. + pgdevkit's orphan detection has no opinion on this; it's purely a bdt + cleanup-command safety heuristic layered on top of pgdevkit's results. + +- Actually dropping a database by name via `psql` -- pgdevkit has no public + "drop this arbitrary already-known db name" call (`clean_testdb()` + resolves its own name internally, and its non-`orphaned`/`all` path + doesn't fold in `extra_db_suffixes`), so bdt still shells out to `psql` + itself for this, same as before. +""" + +from __future__ import annotations + +import os +import subprocess +import tomllib +from pathlib import Path + +import pgdevkit.testdb as pgdevkit_testdb +from pgdevkit.testdb import constants as pgdevkit_constants +from pgdevkit.testdb.config import load_config +from pgdevkit.testdb.naming import slugify + +from .bdt_config import load_bdt_table +from .cli_tools import require_psql + +# A DB name ending in one of these (after stripping any configured +# `extra_db_suffixes` entry) looks like it's named after a bare branch +# rather than a slugified feature branch -- it might be a standing +# reference/baseline DB rather than an orphaned per-worktree leftover, so +# callers should flag it instead of silently treating it as safe to drop. +CAUTION_BRANCH_NAMES = {"main", "test", "dev", "head", "i18n"} + + +def has_pgdevkit_project(repo: Path) -> bool: + """Whether `repo` opts into a pgdevkit-managed *Postgres* test DB: a + `[tool.pgdevkit]` section in its pyproject.toml with no `engine = + "mssql"`. bdt's cleanup commands only know how to list/drop databases + via `psql`, so an mssql-engine project (or one with no pgdevkit config + at all) is out of scope here -- unlike `pgdevkit.testdb.load_config()`, + which always returns *some* config (falling back to the directory + name), this distinguishes "not a pgdevkit-postgres project" from "is + one, just using defaults".""" + pyproject = repo / "pyproject.toml" + if not pyproject.is_file(): + return False + try: + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return False + section = data.get("tool", {}).get("pgdevkit") + # `is None` on purpose, not `not section` -- an empty `[tool.pgdevkit]` + # (accepting every default) parses to `{}`, which is falsy but very much + # still opted in. + if section is None: + return False + return section.get("engine", "postgres") == "postgres" + + +def db_nested_projects(repo: Path) -> list[str]: + """`[tool.bdt.worktree].db_nested_projects` -- subdirectory names that + are wholly separate pgdevkit projects (their own `[tool.pgdevkit]` + section) sharing this worktree's branch.""" + raw = load_bdt_table("worktree", repo).get("db_nested_projects", []) + return [s for s in raw if isinstance(s, str)] if isinstance(raw, list) else [] + + +def project_roots(repo: Path) -> list[Path]: + """`repo` itself (if it's a pgdevkit-postgres project), plus every + configured `db_nested_projects` subdirectory that exists. + + Unlike `repo` itself, a configured nested project is *not* also gated + on `has_pgdevkit_project()` -- being named in `db_nested_projects` at + all is the opt-in. pgdevkit's own `load_config()` falls back to the + directory name whenever a `[tool.pgdevkit]` section is missing (as long + as *some* pyproject.toml exists there), and real nested projects rely + on exactly that: MDMApp's actual `akeneo_editor/pyproject.toml` has no + `[tool.pgdevkit]` section at all, yet its own tests call + `pgdevkit.testdb.ensure_testdb()` directly and it works, resolving to + project name "akeneo_editor" via the directory-name fallback. Requiring + an explicit section here too would silently exclude it.""" + roots = [repo] if has_pgdevkit_project(repo) else [] + roots += [nested_root for nested in db_nested_projects(repo) if (nested_root := repo / nested).is_dir()] + return roots + + +def project_name(repo: Path) -> str | None: + """This repo's pgdevkit project name, or None if it isn't a + pgdevkit-managed Postgres project at all.""" + if not has_pgdevkit_project(repo): + return None + return load_config(repo).name + + +def workspace_db_names(repo: Path) -> frozenset[str]: + """Every DB name this exact worktree at `repo` owns right now -- + `repo`'s own main DB plus any configured `extra_db_suffixes` siblings + (both resolved by pgdevkit), plus the same for any configured + `db_nested_projects`. Requires `repo` to still be a valid git checkout + (pgdevkit resolves the current branch from it), so this must be called + before the worktree is removed.""" + names: set[str] = set() + for root in project_roots(repo): + names |= pgdevkit_testdb.workspace_db_names(project_root=root) + return frozenset(names) + + +def find_orphaned(repo: Path) -> list[tuple[str, str, bool]]: + """(db_name, project_name, caution) for every database with no matching + live git worktree, across this repo's own pgdevkit project and any + configured nested projects -- delegates the actual diffing entirely to + pgdevkit's own `find_orphaned_dbs()`, once per project root; `caution` + is bdt's own heuristic on top (see `is_caution_db`).""" + results: list[tuple[str, str, bool]] = [] + for root in project_roots(repo): + config = load_config(root) + suffixes = list(config.extra_db_suffixes) + for name in pgdevkit_testdb.find_orphaned_dbs(project_root=root): + results.append((name, config.name, is_caution_db(name, config.name, suffixes))) + return results + + +def is_caution_db(db_name: str, project_name: str, sibling_suffixes: list[str]) -> bool: + """Flag DBs whose suffix is a bare branch name (main/test/dev/...) + rather than a slugified feature branch -- these may be standing + reference DBs, not per-worktree leftovers. + + `db_name` is always pgdevkit's *slugified* name (lowercased, invalid + chars collapsed), so `project_name` must be slugified the same way + before comparing -- otherwise a project name with uppercase/special + characters (e.g. "MDMApp") never matches its own DBs (e.g. + "mdmapp_main"), and this silently never fires.""" + prefix = f"{slugify(project_name)}_" + if not db_name.startswith(prefix): + return False + tail = db_name[len(prefix) :] + for suffix in sibling_suffixes: + if tail.endswith(suffix): + tail = tail[: -len(suffix)] + return tail in CAUTION_BRANCH_NAMES + + +def _run_psql(args: list[str], pg_host: str, pg_port: int, pg_user: str) -> subprocess.CompletedProcess: + """Run psql against the given host/port/user, authenticating with + pgdevkit's own test-container password over TCP -- matching exactly how + pgdevkit's own find_orphaned_dbs()/workspace_db_names() connect, so the + listing half and this DROP step can't end up silently targeting two + different Postgres instances (no -h means psql defaults to the unix + socket, which is peer- not password-authenticated).""" + psql = require_psql() + env = {**os.environ, "PGPASSWORD": pgdevkit_constants.PASSWORD} + return subprocess.run( + [psql, "-h", pg_host, "-p", str(pg_port), "-U", pg_user, "-d", "postgres", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + + +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def is_local_host(pg_host: str) -> bool: + """Whether `pg_host` is the local machine -- the only case where bdt's + cleanup commands may drop a database without an explicit, interactive + human confirmation (see `confirm_remote_host`).""" + return pg_host in _LOCAL_HOSTS + + +def confirm_remote_host(pg_host: str, pg_port: int) -> bool: + """Require an interactive yes from a human before any cleanup command + drops a database on a non-local Postgres host. + + Deliberately has no --yes/--confirm-style flag to bypass it: pgdevkit's + own test-container host is always local, so a non-local --pg-host means + someone pointed bdt at a shared/remote Postgres instance, and a + script/agent blindly passing --yes must never be able to drop a + database there without a human actually reading this prompt. A + non-interactive caller (no stdin to read, e.g. CI) safely fails closed + instead of hanging or defaulting to "yes". + """ + if is_local_host(pg_host): + return True + print(f"\npg-host '{pg_host}:{pg_port}' is not localhost -- this may be a shared/remote database.") + try: + answer = input("Type 'yes' to confirm dropping database(s) on this host: ") + except EOFError: + return False + return answer.strip().lower() == "yes" + + +def drop_database(name: str, pg_host: str, pg_port: int, pg_user: str) -> subprocess.CompletedProcess: + # Escape embedded `"` by doubling it, per Postgres quoted-identifier + # rules -- `name` comes from a live `pg_database` listing (via + # pgdevkit), not a bdt-validated slug, so it isn't guaranteed to already + # be injection-safe. + escaped = name.replace('"', '""') + return _run_psql(["-c", f'DROP DATABASE IF EXISTS "{escaped}"'], pg_host, pg_port, pg_user) diff --git a/bmsdna/devtools/worktree.py b/bmsdna/devtools/worktree.py index b79fd93..62a9da7 100644 --- a/bmsdna/devtools/worktree.py +++ b/bmsdna/devtools/worktree.py @@ -1,12 +1,51 @@ -"""Create a git worktree for a new branch, mirroring the `just worktree` recipes.""" +"""Create a git worktree for a new branch, mirroring the `just worktree` +recipes -- and, separately, `bdt cleanup worktrees` / `bdt cleanup +orphaned-dbs` / `bdt cleanup db` / `bdt cleanup worktree`: pruning worktrees +already merged into main/test (and their pgdevkit-managed Postgres test +DB(s), see testdb.py), sweeping for test DBs whose worktree is already gone +some other way, dropping a single still-live worktree's own test DB(s) on +demand, and tearing down one specific still-live worktree (regardless of +merge status) plus its DB(s) in one go. + +Every path here that drops a database also runs it past +`testdb.confirm_remote_host()` first -- an unconditional, un-overridable +interactive check for any non-local `--pg-host` (see that function's +docstring). +""" from __future__ import annotations +import os import shutil import subprocess import sys +from dataclasses import dataclass, field from pathlib import Path +from . import testdb + +# Heavy/vendor/build directories that are never themselves a separate repo +# worth discovering when scanning for repos under a root. Dotdirs (.venv, +# .git, .cache, .tox, .idea, ...) are skipped separately, so this only needs +# non-dot names. +_SKIP_DIRS = { + "node_modules", + "venv", + "dist", + "build", + "out", + "target", + "vendor", + "bower_components", + "coverage", + "bin", + "obj", +} + +# A worktree on one of these branches is never offered for removal, merged +# or not -- they're the long-lived branches everything else merges into. +PROTECTED_BRANCHES = {"main", "test"} + def _run(cmd: list[str], cwd: Path) -> None: try: @@ -15,6 +54,10 @@ def _run(cmd: list[str], cwd: Path) -> None: sys.exit(f"'{cmd[0]}' is required for this command but wasn't found on PATH.") +def _run_capture(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False) + + def create( name: str, base: str = "dev", @@ -47,3 +90,467 @@ def create( print(f"worktree ready at {path}") return path + + +# --- Discovery: repos and their worktrees ------------------------------- + + +def find_repos(root: Path) -> list[Path]: + """Recursively find repo roots (dirs with a `.git` directory) under root. + + Stops descending as soon as it hits a `.git` (whether a real repo or a + linked worktree/submodule's `.git` file) and skips node_modules-style + build/vendor dirs and all dotdirs, so it never wastes time walking into + the huge trees those tend to contain. + """ + repos: list[Path] = [] + stack = [str(root)] + while stack: + current = stack.pop() + git_path = os.path.join(current, ".git") + if os.path.isdir(git_path): + repos.append(Path(current)) + continue + if os.path.exists(git_path): + # A `.git` file means this is a linked worktree or submodule + # checkout; its worktrees are already reachable from the repo + # it belongs to, so there's nothing new to find by descending. + continue + try: + with os.scandir(current) as it: + subdirs = [ + entry.path + for entry in it + if entry.is_dir(follow_symlinks=False) and not entry.name.startswith(".") and entry.name not in _SKIP_DIRS + ] + except (PermissionError, NotADirectoryError, FileNotFoundError): + continue + stack.extend(subdirs) + return repos + + +def _main_repo_root(worktree_path: Path) -> Path | None: + """Resolve the main checkout that owns the (possibly linked) worktree at + `worktree_path` -- its parent directory of `--git-common-dir`, which + always points at the main repo's `.git`, regardless of which worktree + it's queried from. `git worktree remove`/`list` need to run with a cwd + inside *some* still-existing worktree of the repo; using the main + checkout rather than `worktree_path` itself keeps that true even after + `worktree_path` is deleted mid-call.""" + result = _run_capture(["git", "-C", str(worktree_path), "rev-parse", "--path-format=absolute", "--git-common-dir"]) + if result.returncode != 0: + return None + return Path(result.stdout.strip()).parent + + +def _parse_worktree_list(repo: Path) -> list[dict]: + result = _run_capture(["git", "-C", str(repo), "worktree", "list", "--porcelain"]) + if result.returncode != 0: + return [] + entries: list[dict] = [] + current: dict = {} + for line in result.stdout.splitlines(): + if not line.strip(): + if current: + entries.append(current) + current = {} + continue + if line.startswith("worktree "): + if current: + entries.append(current) + current = {"path": line[len("worktree ") :].strip()} + elif line.startswith("HEAD "): + current["head"] = line[len("HEAD ") :].strip() + elif line.startswith("branch "): + ref = line[len("branch ") :].strip() + current["branch"] = ref.removeprefix("refs/heads/") + elif line == "bare": + current["bare"] = True + elif line.startswith("locked"): + current["locked"] = True + if current: + entries.append(current) + return entries + + +def _ref_exists(repo: Path, ref: str) -> bool: + result = _run_capture(["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", ref]) + return result.returncode == 0 + + +def _is_ancestor(repo: Path, commit: str, ref: str) -> bool: + result = _run_capture(["git", "-C", str(repo), "merge-base", "--is-ancestor", commit, ref]) + return result.returncode == 0 + + +def _is_dirty(worktree_path: Path) -> bool: + # No --ignore-submodules here: a bare `--ignore-submodules` flag means + # `--ignore-submodules=all` to git, which would hide real uncommitted + # changes inside a submodule and let a genuinely dirty worktree look + # clean. The default (equivalent to --ignore-submodules=none for our + # purposes) reports those correctly. + result = _run_capture(["git", "-C", str(worktree_path), "status", "--porcelain"]) + return bool(result.stdout.strip()) + + +@dataclass +class Worktree: + repo: Path + path: Path + head: str + branch: str | None + is_main: bool + locked: bool + dirty: bool = False + merged_into: list[str] = field(default_factory=list) + db_names: frozenset[str] = frozenset() + + @property + def removable(self) -> bool: + if self.is_main or self.locked or self.dirty: + return False + if self.branch in PROTECTED_BRANCHES: + return False + return bool(self.merged_into) + + +def collect_worktrees(repo: Path, remote: str) -> list[Worktree]: + """List every worktree of `repo`, each annotated with which of + `/main`, `/test` (falling back to local `main`/`test` if + no such remote refs exist) its HEAD is merged into, and which pgdevkit + test DB name(s) it's expected to own.""" + entries = _parse_worktree_list(repo) + if not entries: + return [] + + candidate_refs = [f"{remote}/main", f"{remote}/test", "main", "test"] + existing_refs = [r for r in candidate_refs if _ref_exists(repo, r)] + + worktrees: list[Worktree] = [] + for i, e in enumerate(entries): + if e.get("bare"): + continue + path = Path(e["path"]) + head = e.get("head", "") + branch = e.get("branch") + # testdb.workspace_db_names() resolves the project name and current + # branch itself from `path`'s own pyproject.toml/git checkout, so + # this needs `path` to still exist -- fine here since a worktree + # only gets removed (and its DB(s) dropped) later in this same + # collect-then-remove flow, well after this runs. + db_names = testdb.workspace_db_names(path) if path.exists() else frozenset() + wt = Worktree( + repo=repo, + path=path, + head=head, + branch=branch, + is_main=(i == 0), + locked=bool(e.get("locked")), + db_names=db_names, + ) + if not wt.is_main and path.exists(): + wt.dirty = _is_dirty(path) + if head: + for ref in existing_refs: + if _is_ancestor(repo, head, ref): + wt.merged_into.append(ref) + worktrees.append(wt) + return worktrees + + +# --- `bdt cleanup worktrees` --------------------------------------------- + + +def remove_worktree(wt: Worktree, *, drop_dbs: bool, pg_host: str, pg_port: int, pg_user: str) -> tuple[subprocess.CompletedProcess, list[tuple[str, bool]]]: + """Remove a worktree, retrying with --force if git blocks it solely + because the worktree contains submodules. + + Git unconditionally refuses `worktree remove` on any worktree that has + submodules ("fatal: working trees containing submodules cannot be moved + or removed"), even when it's perfectly clean. Since callers only reach + here for a worktree that's already been verified as clean (submodules + included, see _is_dirty) and merged, forcing past that specific refusal + is safe. + + Returns the git result plus a (db_name, dropped_ok) list for any + associated Postgres test DBs dropped along with it. + """ + result = _run_capture(["git", "-C", str(wt.repo), "worktree", "remove", str(wt.path)]) + if result.returncode != 0 and "submodules cannot be moved or removed" in result.stderr: + result = _run_capture(["git", "-C", str(wt.repo), "worktree", "remove", "--force", str(wt.path)]) + db_results: list[tuple[str, bool]] = [] + if drop_dbs and result.returncode == 0: + for db in sorted(wt.db_names): + dr = testdb.drop_database(db, pg_host, pg_port, pg_user) + db_results.append((db, dr.returncode == 0)) + return result, db_results + + +def clean_worktrees(root: Path, *, remote: str, keep_dbs: bool, yes: bool, pg_host: str, pg_port: int, pg_user: str) -> None: + """Find every worktree merged into `/main`/`/test` (or + local main/test) across every repo found under `root`, and remove them + (and, unless `keep_dbs`, their pgdevkit test DB(s)) -- but only when + `yes` is set. Without it, this just prints what would be removed, same + as `bdt issue delete` requiring an explicit `--yes` rather than an + interactive prompt (bdt is also invoked by AI-agent callers).""" + root = root.resolve() + repos = find_repos(root) + if not repos: + print(f"No git repositories found under {root}.") + return + + by_repo = {repo: collect_worktrees(repo, remote) for repo in sorted(repos)} + + candidates: list[Worktree] = [] + skipped_notes: list[str] = [] + for worktrees in by_repo.values(): + for wt in worktrees: + if wt.is_main or wt.branch in PROTECTED_BRANCHES: + continue + if wt.removable: + candidates.append(wt) + continue + reason = [] + if wt.dirty: + reason.append("dirty") + if wt.locked: + reason.append("locked") + if not wt.merged_into: + reason.append("not merged into main/test") + if reason: + skipped_notes.append(f" skip {wt.path} ({', '.join(reason)})") + + if skipped_notes: + print("Not offered for removal:") + print("\n".join(skipped_notes)) + print() + + if not candidates: + print("No worktrees are fully merged into main/test. Nothing to do.") + return + + print(f"Worktrees merged into {remote}/main or {remote}/test (removable):") + for wt in candidates: + db_note = f", dbs: {', '.join(sorted(wt.db_names))}" if wt.db_names and not keep_dbs else "" + print(f" {wt.path} (repo: {wt.repo}, branch: {wt.branch}{db_note})") + + if not yes: + print("\nPass --yes to remove the above.") + return + + drop_dbs = not keep_dbs and any(wt.db_names for wt in candidates) + if drop_dbs and not testdb.confirm_remote_host(pg_host, pg_port): + print("Aborted: database host not confirmed.") + return + + touched_repos: set[Path] = set() + for wt in candidates: + result, db_results = remove_worktree(wt, drop_dbs=not keep_dbs, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user) + if result.returncode == 0: + print(f"removed {wt.path}") + else: + print(f"FAILED to remove {wt.path}: {result.stderr.strip()}") + for db, ok in db_results: + print(f" {'dropped' if ok else 'FAILED to drop'} db {db}") + touched_repos.add(wt.repo) + + for repo in touched_repos: + _run_capture(["git", "-C", str(repo), "worktree", "prune"]) + + +# --- `bdt cleanup orphaned-dbs` ------------------------------------------ + + +@dataclass(frozen=True) +class OrphanedDb: + name: str + project: str + caution: bool + + +def find_orphaned_dbs(root: Path) -> list[OrphanedDb]: + """Sweep every pgdevkit-postgres repo found under `root` (and any + `db_nested_projects` it configures) for databases with no matching live + git worktree. The actual diffing is entirely pgdevkit's own + `find_orphaned_dbs()` (one call per project root) -- this just fans it + out across every repo under `root` and layers bdt's own caution-DB + heuristic on top (see `testdb.py`'s module docstring).""" + orphaned: list[OrphanedDb] = [] + for repo in sorted(find_repos(root)): + orphaned.extend(OrphanedDb(name, project, caution) for name, project, caution in testdb.find_orphaned(repo)) + return orphaned + + +def clean_orphaned_dbs(root: Path, *, include_caution: bool, yes: bool, pg_host: str, pg_port: int, pg_user: str) -> None: + """Sweep for pgdevkit test DBs whose worktree no longer exists (e.g. + removed by hand, or before this tool existed) across every repo found + under `root`, and drop them -- but only when `yes` is set. DBs whose + suffix looks like a bare branch name (main/test/dev/...) rather than a + slugified feature branch are flagged as possibly-standing-reference and + excluded unless `include_caution` is also set, since those might be + intentional baseline DBs rather than orphaned leftovers. + + Unlike `clean_worktrees`, this has no notion of a `remote` to check + "merged into" status against -- pgdevkit's orphan detection only cares + whether a live git worktree exists for a branch at all, not whether + that branch has been merged anywhere.""" + root = root.resolve() + if not find_repos(root): + print(f"No git repositories found under {root}.") + return + + orphaned = find_orphaned_dbs(root) + if not orphaned: + print("No orphaned pgdevkit test DBs found.") + return + + print("Orphaned pgdevkit test DBs (no matching live worktree):") + for o in sorted(orphaned, key=lambda o: o.name): + warn = " ⚠ possibly a standing reference DB, verify first (pass --include-caution to include it)" if o.caution else "" + print(f" {o.name} ({o.project}){warn}") + + to_drop = [o for o in orphaned if include_caution or not o.caution] + skipped = [o for o in orphaned if o not in to_drop] + + if not yes: + hint = "\nPass --yes to drop the above" + hint += "." if include_caution else " (excluding flagged ones; add --include-caution to include those too)." + print(hint) + return + + if to_drop and not testdb.confirm_remote_host(pg_host, pg_port): + print("Aborted: database host not confirmed.") + return + + for o in to_drop: + result = testdb.drop_database(o.name, pg_host, pg_port, pg_user) + print(f"{'dropped' if result.returncode == 0 else 'FAILED to drop'} db {o.name}") + + if skipped: + print(f"\n{len(skipped)} flagged DB(s) not dropped (pass --include-caution to include them): {', '.join(o.name for o in skipped)}") + + +# --- `bdt cleanup db` ------------------------------------------------------ + + +def clean_current_db(repo: Path, *, confirm: bool, pg_host: str, pg_port: int, pg_user: str) -> None: + """Drop the pgdevkit test DB(s) owned by `repo` -- meant to be run from + inside a live worktree (default `repo` is `.`), unlike `clean_worktrees` + this never touches the worktree itself, only its database(s). + + Requires an explicit human confirmation: either `confirm=True` (bdt's + `--confirm` flag) or an interactive 'yes' typed at the prompt below. On + top of that, dropping anything on a non-local `--pg-host` additionally + requires `testdb.confirm_remote_host()`'s own interactive confirmation, + which `confirm=True` does *not* bypass. + """ + db_names = testdb.workspace_db_names(repo) + if not db_names: + print(f"No pgdevkit test DB(s) found for {repo}.") + return + + print(f"Test DB(s) for {repo}:") + for name in sorted(db_names): + print(f" {name}") + + if not confirm: + try: + answer = input("\nDrop the above database(s)? [y/N]: ") + except EOFError: + answer = "" + if answer.strip().lower() not in ("y", "yes"): + print("Aborted.") + return + + if not testdb.confirm_remote_host(pg_host, pg_port): + print("Aborted: database host not confirmed.") + return + + for name in sorted(db_names): + result = testdb.drop_database(name, pg_host, pg_port, pg_user) + print(f"{'dropped' if result.returncode == 0 else 'FAILED to drop'} db {name}") + + +# --- `bdt cleanup worktree` ------------------------------------------------- + + +def clean_current_worktree(path: Path, *, confirm: bool, keep_db: bool, pg_host: str, pg_port: int, pg_user: str) -> None: + """Remove the single worktree at `path` (default: the current directory) + plus, unless `keep_db`, its own pgdevkit test DB(s) -- meant to be run + from inside that worktree, mirroring `clean_current_db`. Unlike + `clean_worktrees`, this is an explicit, single-worktree teardown: it + doesn't require "merged into main/test" status, only that `path` isn't + the main checkout, isn't on a protected branch, and is clean (submodules + included -- see `_is_dirty`). + + Removal itself goes through `remove_worktree()`, so a worktree + containing submodules is handled the same way as in `clean_worktrees`: + git's blanket "working trees containing submodules cannot be moved or + removed" refusal is retried once with `--force`, since this worktree + has already been verified clean before that point. + + Requires an explicit human confirmation: either `confirm=True` (bdt's + `--confirm` flag) or an interactive 'yes' typed at the prompt below. On + top of that, dropping its DB(s) on a non-local `--pg-host` additionally + requires `testdb.confirm_remote_host()`'s own interactive confirmation, + which `confirm=True` does *not* bypass. + """ + path = path.resolve() + main_root = _main_repo_root(path) + if main_root is None: + sys.exit(f"{path} is not inside a git worktree.") + + entries = _parse_worktree_list(main_root) + if not entries: + sys.exit(f"Could not list worktrees for {main_root}.") + if Path(entries[0]["path"]).resolve() == path: + sys.exit(f"Refusing to remove the main checkout at {path}.") + + entry = next((e for e in entries if Path(e["path"]).resolve() == path), None) + if entry is None: + sys.exit(f"{path} is not a registered worktree of {main_root}.") + + branch = entry.get("branch") + if branch in PROTECTED_BRANCHES: + sys.exit(f"Refusing to remove {path}: branch '{branch}' is protected.") + if entry.get("locked"): + sys.exit(f"Refusing to remove {path}: it is locked.") + if _is_dirty(path): + sys.exit(f"Refusing to remove {path}: it has uncommitted changes (submodules included).") + + db_names = frozenset() if keep_db else testdb.workspace_db_names(path) + wt = Worktree( + repo=main_root, + path=path, + head=entry.get("head", ""), + branch=branch, + is_main=False, + locked=False, + db_names=db_names, + ) + + print(f"Worktree: {wt.path} (repo: {wt.repo}, branch: {wt.branch})") + if db_names: + print(f"Test DB(s): {', '.join(sorted(db_names))}") + + if not confirm: + suffix = " and its database(s)" if db_names else "" + try: + answer = input(f"\nRemove the above worktree{suffix}? [y/N]: ") + except EOFError: + answer = "" + if answer.strip().lower() not in ("y", "yes"): + print("Aborted.") + return + + if db_names and not testdb.confirm_remote_host(pg_host, pg_port): + print("Aborted: database host not confirmed.") + return + + result, db_results = remove_worktree(wt, drop_dbs=bool(db_names), pg_host=pg_host, pg_port=pg_port, pg_user=pg_user) + if result.returncode != 0: + sys.exit(f"FAILED to remove {wt.path}: {result.stderr.strip()}") + print(f"removed {wt.path}") + for db, ok in db_results: + print(f" {'dropped' if ok else 'FAILED to drop'} db {db}") + _run_capture(["git", "-C", str(main_root), "worktree", "prune"]) diff --git a/pyproject.toml b/pyproject.toml index c07f134..29ef807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,14 +10,19 @@ packages = ["bmsdna"] [project] name = "bmsdna-devtools" -version = "0.16.0" +version = "0.17.0" description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency dependencies = [ "requests>=2.32.0", "typer>=0.26.7", "rich>=13.0.0", + # [db] extra: pgdevkit.testdb unconditionally imports pgdevkit.db.complex_types + # (schema-apply machinery), which needs psycopg-pool/pydantic -- plain + # `pgdevkit` alone fails to import pgdevkit.testdb. Possibly worth reporting + # upstream, but harmless to depend on here either way. + "pgdevkit[db]>=0.7.1", ] [project.scripts] diff --git a/tests/test_cli_cleanup.py b/tests/test_cli_cleanup.py new file mode 100644 index 0000000..b21c515 --- /dev/null +++ b/tests/test_cli_cleanup.py @@ -0,0 +1,104 @@ +from pathlib import Path + +from pgdevkit.testdb import constants as pgdevkit_constants +from typer.testing import CliRunner + +from bmsdna.devtools.cli import app + +runner = CliRunner() + + +def test_cleanup_worktrees_cli_passes_options_through(monkeypatch, tmp_path) -> None: + captured: dict = {} + monkeypatch.setattr( + "bmsdna.devtools.cli.worktree_mod.clean_worktrees", + lambda root, **kwargs: captured.update(root=root, **kwargs), + ) + + result = runner.invoke( + app, + [ + "cleanup", + "worktrees", + str(tmp_path), + "--remote", + "upstream", + "--keep-dbs", + "--yes", + "--pg-host", + "otherhost", + "--pg-port", + "54322", + "--pg-user", + "tester", + ], + ) + + assert result.exit_code == 0, result.output + assert captured == { + "root": Path(tmp_path), + "remote": "upstream", + "keep_dbs": True, + "yes": True, + "pg_host": "otherhost", + "pg_port": 54322, + "pg_user": "tester", + } + + +def test_cleanup_worktrees_cli_falls_back_to_pgdevkits_own_user_when_no_pg_user_given(monkeypatch, tmp_path) -> None: + # No --pg-user and no PGUSER env var: falls back to pgdevkit's own + # test-container user, not the current OS user -- these DBs were created + # by pgdevkit in the first place, so its own default is the one actually + # likely to work. + monkeypatch.delenv("PGUSER", raising=False) + + captured: dict = {} + monkeypatch.setattr( + "bmsdna.devtools.cli.worktree_mod.clean_worktrees", + lambda root, **kwargs: captured.update(root=root, **kwargs), + ) + + result = runner.invoke(app, ["cleanup", "worktrees", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert captured["pg_user"] == pgdevkit_constants.USER + + +def test_cleanup_worktrees_cli_falls_back_to_pgdevkits_own_user_even_when_shell_user_env_is_set(monkeypatch, tmp_path) -> None: + # $USER/$LOGNAME are set by the OS shell on virtually every real + # session -- unlike PGUSER, they must NOT feed the --pg-user fallback, + # or the intended default (pgdevkit's own test-container user) would + # never fire in practice. Regression test for that exact bug. + monkeypatch.delenv("PGUSER", raising=False) + monkeypatch.setenv("USER", "someone") + monkeypatch.setenv("LOGNAME", "someone") + + captured: dict = {} + monkeypatch.setattr( + "bmsdna.devtools.cli.worktree_mod.clean_worktrees", + lambda root, **kwargs: captured.update(root=root, **kwargs), + ) + + result = runner.invoke(app, ["cleanup", "worktrees", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert captured["pg_user"] == pgdevkit_constants.USER + + +def test_cleanup_orphaned_dbs_cli_passes_options_through(monkeypatch, tmp_path) -> None: + captured: dict = {} + monkeypatch.setattr( + "bmsdna.devtools.cli.worktree_mod.clean_orphaned_dbs", + lambda root, **kwargs: captured.update(root=root, **kwargs), + ) + + result = runner.invoke( + app, + ["cleanup", "orphaned-dbs", str(tmp_path), "--include-caution", "--yes", "--pg-user", "tester"], + ) + + assert result.exit_code == 0, result.output + assert captured["include_caution"] is True + assert captured["yes"] is True + assert captured["pg_user"] == "tester" diff --git a/tests/test_testdb.py b/tests/test_testdb.py new file mode 100644 index 0000000..5a6590a --- /dev/null +++ b/tests/test_testdb.py @@ -0,0 +1,373 @@ +import subprocess +from unittest.mock import MagicMock + +import pytest +from pgdevkit.testdb import constants as pgdevkit_constants + +from bmsdna.devtools.testdb import ( + confirm_remote_host, + db_nested_projects, + drop_database, + find_orphaned, + has_pgdevkit_project, + is_caution_db, + is_local_host, + project_name, + project_roots, + workspace_db_names, +) + + +def write_pyproject(path, body: str): + (path / "pyproject.toml").write_text(body) + return path + + +def init_git_repo(path, branch="main"): + path.mkdir(parents=True, exist_ok=True) + for cmd in ( + ["git", "init", "-q", "-b", branch], + ["git", "config", "user.email", "test@example.com"], + ["git", "config", "user.name", "Test"], + ): + subprocess.run(cmd, cwd=path, check=True) + (path / "README.md").write_text("init") + subprocess.run(["git", "add", "."], cwd=path, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=path, check=True) + return path + + +# --- has_pgdevkit_project --------------------------------------------------- + + +def test_has_pgdevkit_project_false_when_no_pyproject(tmp_path) -> None: + assert has_pgdevkit_project(tmp_path) is False + + +def test_has_pgdevkit_project_false_when_no_pgdevkit_section(tmp_path) -> None: + write_pyproject(tmp_path, "[tool.bdt.ado]\nboard = 'x'\n") + assert has_pgdevkit_project(tmp_path) is False + + +def test_has_pgdevkit_project_false_for_non_postgres_engine(tmp_path) -> None: + write_pyproject(tmp_path, "[tool.pgdevkit]\nname = 'distributionplan'\nengine = 'mssql'\n") + assert has_pgdevkit_project(tmp_path) is False + + +def test_has_pgdevkit_project_true_for_postgres_engine(tmp_path) -> None: + write_pyproject(tmp_path, "[tool.pgdevkit]\nname = 'ccmt'\n") + assert has_pgdevkit_project(tmp_path) is True + + +def test_has_pgdevkit_project_true_for_an_entirely_empty_section(tmp_path) -> None: + # An empty `[tool.pgdevkit]` (accepting every default: name from the + # directory, engine=postgres, ...) parses to `{}`, which is falsy but + # still means "opted in" -- must not be confused with "no section at + # all" (see has_pgdevkit_project's `is None` check). + write_pyproject(tmp_path, "[tool.pgdevkit]\n") + assert has_pgdevkit_project(tmp_path) is True + + +# --- db_nested_projects ------------------------------------------------------ + + +def test_db_nested_projects_empty_when_not_configured(tmp_path) -> None: + assert db_nested_projects(tmp_path) == [] + + +def test_db_nested_projects_reads_configured_list(tmp_path) -> None: + write_pyproject(tmp_path, '[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + assert db_nested_projects(tmp_path) == ["akeneo_editor"] + + +# --- project_roots / project_name ------------------------------------------- + + +def test_project_roots_empty_when_not_a_pgdevkit_project(tmp_path) -> None: + assert project_roots(tmp_path) == [] + + +def test_project_roots_is_just_the_repo_with_no_nested_projects_configured(tmp_path) -> None: + write_pyproject(tmp_path, "[tool.pgdevkit]\nname = 'ccmt'\n") + assert project_roots(tmp_path) == [tmp_path] + + +def test_project_roots_includes_a_configured_nested_project_that_has_its_own_pgdevkit_section(tmp_path) -> None: + write_pyproject(tmp_path, '[tool.pgdevkit]\nname = "mdm"\n\n[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + nested = tmp_path / "akeneo_editor" + nested.mkdir() + write_pyproject(nested, "[tool.pgdevkit]\n") + + assert project_roots(tmp_path) == [tmp_path, nested] + + +def test_project_roots_includes_a_configured_nested_project_with_no_pgdevkit_section_at_all(tmp_path) -> None: + # Mirrors MDMApp's real akeneo_editor/: a pyproject.toml with no + # [tool.pgdevkit] section of its own at all. Being named in + # db_nested_projects is itself the opt-in -- pgdevkit's own + # load_config() falls back to the directory name for the rest. + write_pyproject(tmp_path, '[tool.pgdevkit]\nname = "mdm"\n\n[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + nested = tmp_path / "akeneo_editor" + nested.mkdir() + write_pyproject(nested, '[project]\nname = "akeneo-editor"\n') + + assert project_roots(tmp_path) == [tmp_path, nested] + + +def test_project_roots_excludes_a_configured_nested_project_directory_that_does_not_exist(tmp_path) -> None: + write_pyproject(tmp_path, '[tool.pgdevkit]\nname = "mdm"\n\n[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + + assert project_roots(tmp_path) == [tmp_path] + + +def test_project_name_none_when_not_a_pgdevkit_project(tmp_path) -> None: + assert project_name(tmp_path) is None + + +def test_project_name_reads_configured_name(tmp_path) -> None: + write_pyproject(tmp_path, "[tool.pgdevkit]\nname = 'ccmt'\n") + assert project_name(tmp_path) == "ccmt" + + +def test_project_name_falls_back_to_directory_name(tmp_path) -> None: + # Mirrors pgdevkit's own load_config() fallback (see its README): a + # `[tool.pgdevkit]` section with no explicit `name` uses the containing + # directory's name instead. + akeneo_editor = tmp_path / "akeneo_editor" + akeneo_editor.mkdir() + write_pyproject(akeneo_editor, "[tool.pgdevkit]\n") + assert project_name(akeneo_editor) == "akeneo_editor" + + +# --- workspace_db_names (real pgdevkit calls -- no DB connection needed, -- +# --- pure naming from git branch + pyproject.toml) -- + + +def test_workspace_db_names_is_just_the_main_db_with_no_extra_config(tmp_path) -> None: + repo = init_git_repo(tmp_path / "repo") + write_pyproject(repo, "[tool.pgdevkit]\nname = 'ccmt'\n") + assert workspace_db_names(repo) == {"ccmt_main"} + + +def test_workspace_db_names_includes_pgdevkits_own_extra_db_suffixes(tmp_path) -> None: + repo = init_git_repo(tmp_path / "repo") + write_pyproject(repo, '[tool.pgdevkit]\nname = "ccmt"\nextra_db_suffixes = ["_onetrade"]\n') + assert workspace_db_names(repo) == {"ccmt_main", "ccmt_main_onetrade"} + + +def test_workspace_db_names_includes_a_configured_nested_project_on_the_same_branch(tmp_path) -> None: + # Mirrors MDMApp: akeneo_editor/ is a real subdirectory sharing the + # parent worktree's git checkout (and so its branch), with its own + # pyproject.toml/[tool.pgdevkit] section. + repo = init_git_repo(tmp_path / "repo", branch="my-feature") + write_pyproject(repo, '[tool.pgdevkit]\nname = "mdm"\n\n[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + nested = repo / "akeneo_editor" + nested.mkdir() + write_pyproject(nested, "[tool.pgdevkit]\n") + + assert workspace_db_names(repo) == {"mdm_my_feature", "akeneo_editor_my_feature"} + + +def test_workspace_db_names_includes_a_nested_project_with_no_pgdevkit_section_at_all(tmp_path) -> None: + # Mirrors the real MDMApp/akeneo_editor case exactly (see project_roots' + # docstring): the nested project's pyproject.toml has no + # [tool.pgdevkit] section, only a bare [project] table. + repo = init_git_repo(tmp_path / "repo", branch="my-feature") + write_pyproject(repo, '[tool.pgdevkit]\nname = "mdm"\n\n[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + nested = repo / "akeneo_editor" + nested.mkdir() + write_pyproject(nested, '[project]\nname = "akeneo-editor"\n') + + assert workspace_db_names(repo) == {"mdm_my_feature", "akeneo_editor_my_feature"} + + +def test_workspace_db_names_empty_when_not_a_pgdevkit_project(tmp_path) -> None: + repo = init_git_repo(tmp_path / "repo") + assert workspace_db_names(repo) == frozenset() + + +# --- find_orphaned ----------------------------------------------------------- + + +def test_find_orphaned_delegates_to_pgdevkit_per_project_root(tmp_path, monkeypatch) -> None: + repo = init_git_repo(tmp_path / "repo") + write_pyproject(repo, "[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr( + "bmsdna.devtools.testdb.pgdevkit_testdb.find_orphaned_dbs", + lambda project_root: ["ccmt_old_removed_feature", "ccmt_dev"], + ) + + assert find_orphaned(repo) == [ + ("ccmt_old_removed_feature", "ccmt", False), + ("ccmt_dev", "ccmt", True), + ] + + +def test_find_orphaned_covers_a_configured_nested_project_too(tmp_path, monkeypatch) -> None: + repo = init_git_repo(tmp_path / "repo") + write_pyproject(repo, '[tool.pgdevkit]\nname = "mdm"\n\n[tool.bdt.worktree]\ndb_nested_projects = ["akeneo_editor"]\n') + nested = repo / "akeneo_editor" + nested.mkdir() + write_pyproject(nested, "[tool.pgdevkit]\n") + + def fake_find_orphaned_dbs(project_root): + return ["mdm_ghost"] if project_root == repo else ["akeneo_editor_ghost"] + + monkeypatch.setattr("bmsdna.devtools.testdb.pgdevkit_testdb.find_orphaned_dbs", fake_find_orphaned_dbs) + + assert find_orphaned(repo) == [ + ("mdm_ghost", "mdm", False), + ("akeneo_editor_ghost", "akeneo_editor", False), + ] + + +def test_find_orphaned_empty_when_not_a_pgdevkit_project(tmp_path, monkeypatch) -> None: + repo = init_git_repo(tmp_path / "repo") + monkeypatch.setattr( + "bmsdna.devtools.testdb.pgdevkit_testdb.find_orphaned_dbs", + lambda project_root: pytest.fail("should not be called for a non-pgdevkit repo"), + ) + assert find_orphaned(repo) == [] + + +# --- is_caution_db ----------------------------------------------------------- + + +def test_is_caution_db_flags_bare_branch_name_suffix() -> None: + assert is_caution_db("ccmt_main", "ccmt", []) is True + assert is_caution_db("ccmt_test", "ccmt", []) is True + + +def test_is_caution_db_false_for_slugified_feature_branch() -> None: + assert is_caution_db("ccmt_bonus_rule_customer_group", "ccmt", []) is False + + +def test_is_caution_db_strips_configured_sibling_suffix_before_checking() -> None: + assert is_caution_db("ccmt_main_onetrade", "ccmt", ["_onetrade"]) is True + + +def test_is_caution_db_false_when_project_prefix_does_not_match() -> None: + assert is_caution_db("mdm_main", "ccmt", []) is False + + +def test_is_caution_db_slugifies_project_name_before_comparing() -> None: + # db_name is always pgdevkit's *slugified* name (e.g. "mdmapp_main" for + # project name "MDMApp") -- comparing against the raw, unslugified + # project name would never match and silently never flag anything. + assert is_caution_db("mdmapp_main", "MDMApp", []) is True + assert is_caution_db("mdmapp_test", "MDMApp", []) is True + + +# --- drop_database ----------------------------------------------------------- + + +def test_drop_database_issues_drop_database_if_exists(monkeypatch) -> None: + captured_cmd: list[str] = [] + + monkeypatch.setattr("bmsdna.devtools.testdb.require_psql", lambda: "/usr/bin/psql") + + def fake_run(cmd, **kwargs): + captured_cmd[:] = cmd + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("bmsdna.devtools.testdb.subprocess.run", fake_run) + + drop_database("ccmt_my_feature", pg_host="localhost", pg_port=54322, pg_user="tester") + + assert captured_cmd[0] == "/usr/bin/psql" + assert "-h" in captured_cmd + assert captured_cmd[captured_cmd.index("-h") + 1] == "localhost" + assert "-c" in captured_cmd + assert captured_cmd[captured_cmd.index("-c") + 1] == 'DROP DATABASE IF EXISTS "ccmt_my_feature"' + + +def test_drop_database_authenticates_with_pgdevkits_own_password_over_tcp(monkeypatch) -> None: + # No -h means psql defaults to the unix socket (peer auth), which + # doesn't match how pgdevkit's own find_orphaned_dbs()/ + # workspace_db_names() connect (TCP + password) -- so the listing half + # and this DROP step could silently target two different Postgres + # instances, or fail outright on peer auth. + captured_env: dict = {} + + monkeypatch.setattr("bmsdna.devtools.testdb.require_psql", lambda: "/usr/bin/psql") + + def fake_run(cmd, **kwargs): + captured_env.update(kwargs.get("env") or {}) + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("bmsdna.devtools.testdb.subprocess.run", fake_run) + + drop_database("ccmt_my_feature", pg_host="localhost", pg_port=54322, pg_user="tester") + + assert captured_env["PGPASSWORD"] == pgdevkit_constants.PASSWORD + + +def test_drop_database_escapes_embedded_double_quotes_in_db_name(monkeypatch) -> None: + # `name` comes from a live pg_database listing, not a bdt-validated + # slug -- an unescaped embedded `"` would let it break out of the + # quoted identifier and inject a second SQL statement into `psql -c`. + captured_cmd: list[str] = [] + + monkeypatch.setattr("bmsdna.devtools.testdb.require_psql", lambda: "/usr/bin/psql") + + def fake_run(cmd, **kwargs): + captured_cmd[:] = cmd + return MagicMock(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("bmsdna.devtools.testdb.subprocess.run", fake_run) + + drop_database('evil"; drop database postgres; --', pg_host="localhost", pg_port=54322, pg_user="tester") + + dropped_sql = captured_cmd[captured_cmd.index("-c") + 1] + assert dropped_sql == 'DROP DATABASE IF EXISTS "evil""; drop database postgres; --"' + + +def test_drop_database_exits_with_a_friendly_message_when_psql_is_missing(monkeypatch) -> None: + def fake_require_psql(): + raise SystemExit("'psql' is required for this command but wasn't found on PATH.\nsome hint") + + monkeypatch.setattr("bmsdna.devtools.testdb.require_psql", fake_require_psql) + + with pytest.raises(SystemExit, match="'psql' is required"): + drop_database("ccmt_my_feature", pg_host="localhost", pg_port=54322, pg_user="tester") + + +# --- is_local_host / confirm_remote_host ------------------------------------- + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "::1"]) +def test_is_local_host_true_for_local_addresses(host: str) -> None: + assert is_local_host(host) is True + + +@pytest.mark.parametrize("host", ["db.example.com", "10.0.0.5", "prod-postgres"]) +def test_is_local_host_false_for_non_local_addresses(host: str) -> None: + assert is_local_host(host) is False + + +def test_confirm_remote_host_skips_prompt_for_local_host(monkeypatch) -> None: + monkeypatch.setattr("builtins.input", lambda *_: (_ for _ in ()).throw(AssertionError("should not prompt for a local host"))) + + assert confirm_remote_host("localhost", 54322) is True + + +def test_confirm_remote_host_requires_typed_yes_for_remote_host(monkeypatch) -> None: + monkeypatch.setattr("builtins.input", lambda *_: "yes") + + assert confirm_remote_host("db.example.com", 5432) is True + + +def test_confirm_remote_host_rejects_anything_other_than_yes(monkeypatch) -> None: + monkeypatch.setattr("builtins.input", lambda *_: "y") + + assert confirm_remote_host("db.example.com", 5432) is False + + +def test_confirm_remote_host_fails_closed_when_input_is_not_interactive(monkeypatch) -> None: + def raise_eof(*_): + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + + assert confirm_remote_host("db.example.com", 5432) is False diff --git a/tests/test_worktree_clean.py b/tests/test_worktree_clean.py new file mode 100644 index 0000000..4bac4a1 --- /dev/null +++ b/tests/test_worktree_clean.py @@ -0,0 +1,611 @@ +import subprocess + +import pytest + +from bmsdna.devtools.worktree import ( + OrphanedDb, + clean_current_db, + clean_current_worktree, + clean_orphaned_dbs, + clean_worktrees, + collect_worktrees, + find_orphaned_dbs, + find_repos, +) + + +def _git(args, cwd, check=True): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=check) + + +def init_repo(path, pyproject: str | None = None): + path.mkdir(parents=True, exist_ok=True) + _git(["init", "-q", "-b", "main"], cwd=path) + _git(["config", "user.email", "test@example.com"], cwd=path) + _git(["config", "user.name", "Test"], cwd=path) + if pyproject is not None: + (path / "pyproject.toml").write_text(pyproject) + (path / "README.md").write_text("init") + _git(["add", "."], cwd=path) + _git(["commit", "-q", "-m", "init"], cwd=path) + return path + + +def add_worktree(repo, name, base="main"): + path = repo / ".worktrees" / name + _git(["worktree", "add", str(path), "-b", name, base], cwd=repo) + return path + + +def add_submodule(repo, sub_repo, sub_path="vendor/sub"): + """Add `sub_repo` as a submodule of `repo` at `sub_path`, committed on + whatever branch is currently checked out in `repo`.""" + _git(["-c", "protocol.file.allow=always", "submodule", "add", str(sub_repo), sub_path], cwd=repo) + _git(["commit", "-q", "-m", "add submodule"], cwd=repo) + + +# --- find_repos ----------------------------------------------------------- + + +def test_find_repos_discovers_nested_repos_but_not_their_worktrees(tmp_path) -> None: + repo_a = init_repo(tmp_path / "a") + repo_b = init_repo(tmp_path / "nested" / "b") + add_worktree(repo_a, "feature-x") + + repos = find_repos(tmp_path) + + assert set(repos) == {repo_a, repo_b} + + +def test_find_repos_skips_vendor_and_dot_directories(tmp_path) -> None: + init_repo(tmp_path / "a") + (tmp_path / "node_modules" / "some-pkg").mkdir(parents=True) + init_repo(tmp_path / "node_modules" / "some-pkg" / "vendored-repo") + (tmp_path / ".cache" / "whatever").mkdir(parents=True) + init_repo(tmp_path / ".cache" / "whatever" / "another-repo") + + repos = find_repos(tmp_path) + + assert repos == [tmp_path / "a"] + + +# --- collect_worktrees / removability -------------------------------------- + + +def test_collect_worktrees_flags_unmodified_worktree_as_merged_and_removable(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + add_worktree(repo, "merged-feature") + + worktrees = collect_worktrees(repo, remote="origin") + feature = next(w for w in worktrees if w.branch == "merged-feature") + + assert feature.merged_into == ["main"] + assert feature.removable is True + + +def test_collect_worktrees_flags_worktree_with_unmerged_commit_as_not_removable(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "wip-feature") + (path / "new.txt").write_text("wip") + _git(["add", "."], cwd=path) + _git(["commit", "-q", "-m", "wip"], cwd=path) + + worktrees = collect_worktrees(repo, remote="origin") + feature = next(w for w in worktrees if w.branch == "wip-feature") + + assert feature.merged_into == [] + assert feature.removable is False + + +def test_collect_worktrees_flags_dirty_worktree_as_not_removable(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "dirty-feature") + (path / "uncommitted.txt").write_text("oops") + + worktrees = collect_worktrees(repo, remote="origin") + feature = next(w for w in worktrees if w.branch == "dirty-feature") + + assert feature.dirty is True + assert feature.removable is False + + +def test_collect_worktrees_never_offers_main_worktree_or_protected_branches(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + add_worktree(repo, "test", base="main") + + worktrees = collect_worktrees(repo, remote="origin") + + assert not any(w.removable for w in worktrees if w.is_main or w.branch == "test") + + +def test_collect_worktrees_computes_db_names_when_pgdevkit_configured(tmp_path) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + add_worktree(repo, "my-feature") + + worktrees = collect_worktrees(repo, remote="origin") + feature = next(w for w in worktrees if w.branch == "my-feature") + + assert feature.db_names == frozenset({"ccmt_my_feature"}) + + +def test_collect_worktrees_no_db_names_when_no_pgdevkit_config(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + add_worktree(repo, "my-feature") + + worktrees = collect_worktrees(repo, remote="origin") + feature = next(w for w in worktrees if w.branch == "my-feature") + + assert feature.db_names == frozenset() + + +# --- clean_worktrees -------------------------------------------------------- + + +def test_clean_worktrees_without_yes_only_previews(tmp_path, capsys) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "merged-feature") + + clean_worktrees(tmp_path, remote="origin", keep_dbs=False, yes=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + out = capsys.readouterr().out + assert str(path) in out + assert "Pass --yes to remove" in out + assert path.exists() + + +def test_clean_worktrees_with_yes_removes_merged_worktree_and_drops_its_db(tmp_path, monkeypatch, capsys) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "merged-feature") + + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_worktrees(tmp_path, remote="origin", keep_dbs=False, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + out = capsys.readouterr().out + assert not path.exists() + assert dropped == ["ccmt_merged_feature"] + assert "dropped db ccmt_merged_feature" in out + + +def test_clean_worktrees_keep_dbs_skips_db_drop(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + add_worktree(repo, "merged-feature") + + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_worktrees(tmp_path, remote="origin", keep_dbs=True, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert dropped == [] + + +def test_clean_worktrees_leaves_unmerged_worktree_alone(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "wip-feature") + (path / "new.txt").write_text("wip") + _git(["add", "."], cwd=path) + _git(["commit", "-q", "-m", "wip"], cwd=path) + + clean_worktrees(tmp_path, remote="origin", keep_dbs=False, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert path.exists() + + +def test_clean_worktrees_reports_nothing_to_do_when_no_repos(tmp_path, capsys) -> None: + clean_worktrees(tmp_path, remote="origin", keep_dbs=False, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert "No git repositories found" in capsys.readouterr().out + + +# --- find_orphaned_dbs / clean_orphaned_dbs -------------------------------- +# +# testdb.find_orphaned() (which these delegate to) is exercised directly and +# more thoroughly in test_testdb.py, including the pgdevkit-per-project-root +# fan-out; these tests only cover find_repos-driven discovery across +# multiple repos and the preview/drop/--include-caution CLI-flow behavior on +# top of it, via a monkeypatched testdb.find_orphaned. + + +def _fake_find_orphaned(by_repo: dict) -> object: + """Monkeypatch bmsdna.devtools.worktree.testdb.find_orphaned to return + `by_repo[repo]` (a list of (name, project, caution) tuples) for each + repo, [] for any repo not in by_repo.""" + return lambda repo: by_repo.get(repo, []) + + +def test_find_orphaned_dbs_fans_out_across_every_discovered_repo(tmp_path, monkeypatch) -> None: + repo_a = init_repo(tmp_path / "a", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + repo_b = init_repo(tmp_path / "b", pyproject="[tool.pgdevkit]\nname = 'mdm'\n") + + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.find_orphaned", + _fake_find_orphaned( + { + repo_a: [("ccmt_old_removed_feature", "ccmt", False)], + repo_b: [("mdm_dev", "mdm", True)], + } + ), + ) + + orphaned = find_orphaned_dbs(tmp_path) + + assert sorted(orphaned, key=lambda o: o.name) == [ + OrphanedDb("ccmt_old_removed_feature", "ccmt", False), + OrphanedDb("mdm_dev", "mdm", True), + ] + + +def test_clean_orphaned_dbs_without_yes_only_previews_and_excludes_caution_by_default(tmp_path, monkeypatch, capsys) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.find_orphaned", + _fake_find_orphaned({repo: [("ccmt_old_removed_feature", "ccmt", False), ("ccmt_dev", "ccmt", True)]}), + ) + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_orphaned_dbs(tmp_path, include_caution=False, yes=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + out = capsys.readouterr().out + assert "ccmt_old_removed_feature" in out + assert "ccmt_dev" in out + assert "possibly a standing reference DB" in out + assert "Pass --yes to drop" in out + assert dropped == [] + + +def test_clean_orphaned_dbs_with_yes_drops_only_non_caution_by_default(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.find_orphaned", + _fake_find_orphaned({repo: [("ccmt_old_removed_feature", "ccmt", False), ("ccmt_dev", "ccmt", True)]}), + ) + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_orphaned_dbs(tmp_path, include_caution=False, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert dropped == ["ccmt_old_removed_feature"] + + +def test_clean_orphaned_dbs_with_yes_and_include_caution_drops_everything(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.find_orphaned", + _fake_find_orphaned({repo: [("ccmt_old_removed_feature", "ccmt", False), ("ccmt_dev", "ccmt", True)]}), + ) + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_orphaned_dbs(tmp_path, include_caution=True, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert sorted(dropped) == ["ccmt_dev", "ccmt_old_removed_feature"] + + +def test_clean_orphaned_dbs_reports_none_found(tmp_path, monkeypatch, capsys) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + monkeypatch.setattr("bmsdna.devtools.worktree.testdb.find_orphaned", _fake_find_orphaned({repo: []})) + + clean_orphaned_dbs(tmp_path, include_caution=False, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert "No orphaned pgdevkit test DBs found" in capsys.readouterr().out + + +def test_clean_orphaned_dbs_reports_no_repos_found_distinctly_from_no_orphans(tmp_path, capsys) -> None: + """A typo'd/empty root (no repos at all) must be reported distinctly from + a scan that ran but found nothing -- otherwise a mistaken --root value + silently looks identical to a clean sweep.""" + clean_orphaned_dbs(tmp_path, include_caution=False, yes=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + out = capsys.readouterr().out + assert "No git repositories found" in out + assert "No orphaned pgdevkit test DBs found" not in out + + +# --- non-local --pg-host safety net ----------------------------------------- +# +# --yes alone must never be enough to drop anything on a non-local Postgres +# host -- testdb.confirm_remote_host() (exercised directly in +# test_testdb.py) additionally requires an interactive 'yes', which these +# tests simulate/deny via a monkeypatched builtins.input. + + +def test_clean_worktrees_with_yes_still_requires_interactive_confirmation_for_remote_host(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "merged-feature") + + monkeypatch.setattr("builtins.input", lambda *_: "no") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_worktrees(tmp_path, remote="origin", keep_dbs=False, yes=True, pg_host="db.example.com", pg_port=5432, pg_user="tester") + + assert dropped == [] + assert path.exists() # the worktree removal itself is gated behind the same abort + + +def test_clean_worktrees_with_yes_proceeds_for_remote_host_once_confirmed(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + add_worktree(repo, "merged-feature") + + monkeypatch.setattr("builtins.input", lambda *_: "yes") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_worktrees(tmp_path, remote="origin", keep_dbs=False, yes=True, pg_host="db.example.com", pg_port=5432, pg_user="tester") + + assert dropped == ["ccmt_merged_feature"] + + +def test_clean_orphaned_dbs_with_yes_still_requires_interactive_confirmation_for_remote_host(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.find_orphaned", + _fake_find_orphaned({repo: [("ccmt_old_removed_feature", "ccmt", False)]}), + ) + monkeypatch.setattr("builtins.input", lambda *_: "no") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_orphaned_dbs(tmp_path, include_caution=False, yes=True, pg_host="db.example.com", pg_port=5432, pg_user="tester") + + assert dropped == [] + + +# --- clean_current_db (`bdt cleanup db`) ------------------------------------ + + +def test_clean_current_db_previews_and_requires_interactive_yes_when_no_confirm_flag(tmp_path, monkeypatch, capsys) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr("builtins.input", lambda *_: "no") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_db(repo, confirm=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + out = capsys.readouterr().out + assert "ccmt_main" in out + assert dropped == [] + + +def test_clean_current_db_drops_when_interactively_confirmed(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr("builtins.input", lambda *_: "yes") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_db(repo, confirm=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert dropped == ["ccmt_main"] + + +def test_clean_current_db_with_confirm_flag_skips_interactive_prompt(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr("builtins.input", lambda *_: (_ for _ in ()).throw(AssertionError("should not prompt with --confirm"))) + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_db(repo, confirm=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert dropped == ["ccmt_main"] + + +def test_clean_current_db_confirm_flag_does_not_bypass_remote_host_check(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + + monkeypatch.setattr("builtins.input", lambda *_: "no") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_db(repo, confirm=True, pg_host="db.example.com", pg_port=5432, pg_user="tester") + + assert dropped == [] + + +def test_clean_current_db_reports_nothing_to_do_when_no_pgdevkit_config(tmp_path, capsys) -> None: + repo = init_repo(tmp_path / "repo") + + clean_current_db(repo, confirm=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert "No pgdevkit test DB(s) found" in capsys.readouterr().out + + +# --- clean_current_worktree (`bdt cleanup worktree`) ------------------------ + + +def test_clean_current_worktree_previews_and_requires_interactive_yes(tmp_path, monkeypatch, capsys) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "my-feature") + + monkeypatch.setattr("builtins.input", lambda *_: "no") + + clean_current_worktree(path, confirm=False, keep_db=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + out = capsys.readouterr().out + assert str(path) in out + assert "ccmt_my_feature" in out + assert path.exists() + + +def test_clean_current_worktree_removes_worktree_and_drops_db_when_confirmed(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "my-feature") + + monkeypatch.setattr("builtins.input", lambda *_: "yes") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_worktree(path, confirm=False, keep_db=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert not path.exists() + assert dropped == ["ccmt_my_feature"] + + +def test_clean_current_worktree_with_confirm_flag_skips_interactive_prompt(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "my-feature") + + monkeypatch.setattr("builtins.input", lambda *_: (_ for _ in ()).throw(AssertionError("should not prompt with --confirm"))) + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_worktree(path, confirm=True, keep_db=False, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert not path.exists() + assert dropped == ["ccmt_my_feature"] + + +def test_clean_current_worktree_keep_db_skips_db_drop(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "my-feature") + + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_worktree(path, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert not path.exists() + assert dropped == [] + + +def test_clean_current_worktree_confirm_flag_does_not_bypass_remote_host_check(tmp_path, monkeypatch) -> None: + repo = init_repo(tmp_path / "repo", pyproject="[tool.pgdevkit]\nname = 'ccmt'\n") + path = add_worktree(repo, "my-feature") + + monkeypatch.setattr("builtins.input", lambda *_: "no") + dropped: list[str] = [] + monkeypatch.setattr( + "bmsdna.devtools.worktree.testdb.drop_database", + lambda name, pg_host, pg_port, pg_user: (dropped.append(name), subprocess.CompletedProcess([], 0))[1], + ) + + clean_current_worktree(path, confirm=True, keep_db=False, pg_host="db.example.com", pg_port=5432, pg_user="tester") + + assert dropped == [] + assert path.exists() # aborted before the worktree removal itself, same as clean_worktrees + + +def test_clean_current_worktree_refuses_the_main_checkout(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + + with pytest.raises(SystemExit, match="main checkout"): + clean_current_worktree(repo, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert repo.exists() + + +def test_clean_current_worktree_refuses_a_protected_branch(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "test", base="main") + + with pytest.raises(SystemExit, match="protected"): + clean_current_worktree(path, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert path.exists() + + +def test_clean_current_worktree_refuses_a_dirty_worktree(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "my-feature") + (path / "uncommitted.txt").write_text("oops") + + with pytest.raises(SystemExit, match="uncommitted changes"): + clean_current_worktree(path, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert path.exists() + + +def test_clean_current_worktree_refuses_a_locked_worktree(tmp_path) -> None: + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "my-feature") + _git(["worktree", "lock", str(path)], cwd=repo) + + with pytest.raises(SystemExit, match="locked"): + clean_current_worktree(path, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert path.exists() + + +def test_clean_current_worktree_works_from_any_of_its_own_worktrees_as_cwd_equivalent(tmp_path) -> None: + """`clean_current_worktree` resolves the main repo via --git-common-dir + rather than assuming `path` itself doubles as a place to run git + commands from -- this exercises that resolution directly against a + linked worktree path (not the main checkout).""" + repo = init_repo(tmp_path / "repo") + path = add_worktree(repo, "my-feature") + + clean_current_worktree(path, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert not path.exists() + # git's own bookkeeping (main repo) must still be intact and usable + result = _git(["worktree", "list"], cwd=repo) + assert str(path) not in result.stdout + + +def test_clean_current_worktree_removes_a_worktree_containing_a_submodule(tmp_path, monkeypatch) -> None: + """Git unconditionally refuses `worktree remove` on any worktree with + submodules ("fatal: working trees containing submodules cannot be moved + or removed"), clean or not -- this exercises the same --force retry + `clean_worktrees` relies on (both go through `remove_worktree`).""" + sub_repo = init_repo(tmp_path / "sub") + repo = init_repo(tmp_path / "repo") + add_submodule(repo, sub_repo) + path = add_worktree(repo, "feature-with-submodule") + _git(["-c", "protocol.file.allow=always", "submodule", "update", "--init"], cwd=path) + + clean_current_worktree(path, confirm=True, keep_db=True, pg_host="localhost", pg_port=54322, pg_user="tester") + + assert not path.exists() diff --git a/uv.lock b/uv.lock index b8ed636..c2cd7f4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.11" +requires-python = ">=3.14" [[package]] name = "annotated-doc" @@ -11,11 +11,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "bmsdna-devtools" -version = "0.15.0" +version = "0.17.0" source = { editable = "." } dependencies = [ + { name = "pgdevkit", extra = ["db"] }, { name = "requests" }, { name = "rich" }, { name = "typer" }, @@ -32,6 +42,7 @@ test = [ [package.metadata] requires-dist = [ + { name = "pgdevkit", extras = ["db"], specifier = ">=0.7.1" }, { name = "requests", specifier = ">=2.32.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "typer", specifier = ">=0.26.7" }, @@ -59,45 +70,6 @@ version = "3.4.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, - { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, - { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, @@ -136,6 +108,20 @@ 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 = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -184,6 +170,26 @@ 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 = "pgdevkit" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "psycopg", extra = ["binary"] }, + { name = "sqlglot" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/9a/df3f1db747280150496b42226b8b2a5312b091eb0bc2487a2f037611f042/pgdevkit-0.7.1.tar.gz", hash = "sha256:eebcffbd65e0747728ed5299af474f6e21551d1f4237e9f7743a894efe3edf64", size = 141700, upload-time = "2026-09-14T18:40:05.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/b5/4cf4576c1ee096222ba108de9ab5d5c18d4e3f55d69aa04b9dbbf97a6f25/pgdevkit-0.7.1-py3-none-any.whl", hash = "sha256:c1b6b049d7f0ed72c0e94193dc82a729db57ab6d0556353fbb946d88354a44c5", size = 97534, upload-time = "2026-09-14T18:40:04.256Z" }, +] + +[package.optional-dependencies] +db = [ + { name = "psycopg-pool" }, + { name = "pydantic" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -193,6 +199,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/8fb739d0f6bba247b9b93c9840c402a4f88545be5f1d4b02b23366371c00/psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4", size = 166508, upload-time = "2026-08-31T22:45:43.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/2e/d0a645bcaadde68bd6d93c43f02f14b0191bdda367ce3f7722abe3da744a/psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e", size = 213598, upload-time = "2026-08-31T22:39:02.184Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/7f/4e2395da194558533bd9c31f35e4dc58ecbbae6a7176b0d2f72d629e8a51/psycopg_binary-3.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3", size = 4712612, upload-time = "2026-08-31T22:44:25.723Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/fdb2093c8ccd7048449156db526ad746740cf34fe9c01e5dc1b7a7a8b257/psycopg_binary-3.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9", size = 4775139, upload-time = "2026-08-31T22:44:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/31/52/5195e87960715f7be2005761b72d56fce4e7757d5333fd40384f071c2de1/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e", size = 5556807, upload-time = "2026-08-31T22:44:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/f7/42/2d616210a91e1327516ed5ae71961aaa31bb740e4a61d7190f2221685a40/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750", size = 5236206, upload-time = "2026-08-31T22:44:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/08/2e/e54b0d4cc263b3526e3728bb50a69660d5e79e52255ccd2a1a71e40e6f9a/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594", size = 6838066, upload-time = "2026-08-31T22:44:55.701Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/ec22a110f81a44c411965982097efeee86006bdd5a1f51f628318106c84c/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5", size = 5072036, upload-time = "2026-08-31T22:45:02.838Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/7bc3c3ac769ab4fe0c619f2179b4aab3ae043f4d478283dd8279d24c0be4/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75", size = 4604058, upload-time = "2026-08-31T22:45:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/8e7414f7dc10b2959e664330cdcf393e412f67355975dafa876cef265264/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60", size = 4284766, upload-time = "2026-08-31T22:45:17.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/33f293c1d3ee9114f47e9ef4880f31c9de864e84a9b09454c26e106c25ed/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43", size = 4011958, upload-time = "2026-08-31T22:45:24.792Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/8e38840e5a7d006987bbc9acb29951912253fa50549a4db92b3aa535f089/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04", size = 4323273, upload-time = "2026-08-31T22:45:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -218,6 +327,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -280,6 +402,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sqlglot" +version = "30.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/73/5b5ce3e23b3ded3ea1986c2c2991217460d5fd5161b3e00a8425f5dcb8a3/sqlglot-30.18.0.tar.gz", hash = "sha256:e57e1b205e341979d1df5b1212c1435c598a0437e4619e3f428b15d5bc3a5cc6", size = 6018496, upload-time = "2026-09-03T13:12:58.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/9f/3dd2ec8dd84a33e0092f1c815267bc055073f9e833b93e389c59c10f62cc/sqlglot-30.18.0-py3-none-any.whl", hash = "sha256:ee0f9a9f3e2193e763c326e52dfb377b96fdb4292f6305c3ff2d9a220e71c601", size = 748788, upload-time = "2026-09-03T13:12:56.467Z" }, +] + [[package]] name = "ty" version = "0.0.60" @@ -320,6 +451,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"