diff --git a/.github/workflows/test-parallel.yml b/.github/workflows/test-parallel.yml index 8ad7c5e0..0f6d3403 100644 --- a/.github/workflows/test-parallel.yml +++ b/.github/workflows/test-parallel.yml @@ -317,6 +317,13 @@ jobs: | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" + - name: Install PostgreSQL server binaries + run: | + set -Eeuo pipefail + if ! ls /usr/lib/postgresql/*/bin/initdb >/dev/null 2>&1 && ! command -v initdb >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y postgresql + fi + - name: Run test - ${{ matrix.test.name }} env: TEST_PATHS_JSON: ${{ toJson(matrix.test.paths) }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0fcb26c..dfd9cd8c 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -205,6 +205,20 @@ jobs: uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox version uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox status + - name: Install PostgreSQL server binaries + if: matrix.os_name == 'macOS' || matrix.python == '3.14' + run: | + set -Eeuo pipefail + if [ "$RUNNER_OS" = "Linux" ]; then + if ! ls /usr/lib/postgresql/*/bin/initdb >/dev/null 2>&1 && ! command -v initdb >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y postgresql + fi + else + if ! command -v initdb >/dev/null 2>&1 && ! ls /opt/homebrew/opt/postgresql*/bin/initdb >/dev/null 2>&1; then + brew install postgresql@17 + fi + fi + - name: Run consolidated core suite if: matrix.os_name == 'macOS' || matrix.python == '3.14' run: | diff --git a/archivebox/api/migrations/0001_initial.py b/archivebox/api/migrations/0001_initial.py index 1f3e6f3d..99cdeef9 100644 --- a/archivebox/api/migrations/0001_initial.py +++ b/archivebox/api/migrations/0001_initial.py @@ -12,19 +12,9 @@ import signal_webhooks.fields import signal_webhooks.utils -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.SeparateDatabaseAndState( - database_operations=[ - migrations.RunSQL( - sql=""" +# Raw sqlite DDL kept EXACTLY as-is; executed only on sqlite via Django's +# RunSQL (identical statement splitting → byte-for-byte identical behavior). +INITIAL_SQL = """ -- Create api_apitoken table CREATE TABLE IF NOT EXISTS api_apitoken ( id TEXT PRIMARY KEY NOT NULL, @@ -70,12 +60,48 @@ class Migration(migrations.Migration): CREATE INDEX IF NOT EXISTS api_outboundwebhook_created_at_idx ON api_outboundwebhook(created_at); CREATE INDEX IF NOT EXISTS api_outboundwebhook_name_idx ON api_outboundwebhook(name); CREATE INDEX IF NOT EXISTS api_outboundwebhook_ref_idx ON api_outboundwebhook(ref); - """, - reverse_sql=""" + """ + +INITIAL_REVERSE_SQL = """ DROP TABLE IF EXISTS api_outboundwebhook; DROP TABLE IF EXISTS api_apitoken; - """, - ), + """ + + +def _run_sqlite_only_sql(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL(sql=INITIAL_SQL, reverse_sql=INITIAL_REVERSE_SQL).database_forwards( + "api", schema_editor, None, None, + ) + + +def _run_sqlite_only_sql_reverse(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL(sql=INITIAL_SQL, reverse_sql=INITIAL_REVERSE_SQL).database_backwards( + "api", schema_editor, None, None, + ) + + +def _pg_sync_schema(apps, schema_editor): + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state(apps, schema_editor, "api", ["APIToken", "OutboundWebhook"]) + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython(_run_sqlite_only_sql, reverse_code=_run_sqlite_only_sql_reverse), ], state_operations=[ migrations.CreateModel( @@ -236,4 +262,8 @@ class Migration(migrations.Migration): ), ], ), + # On non-sqlite backends the raw DDL above is skipped, so the real + # schema diverges from Django state. Rebuild both tables (including the + # unique constraint) from the post-migration state (no-op on sqlite). + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/cli/archivebox_init.py b/archivebox/cli/archivebox_init.py index f20d3d16..73416ed8 100755 --- a/archivebox/cli/archivebox_init.py +++ b/archivebox/cli/archivebox_init.py @@ -28,13 +28,13 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non from archivebox.config import CONSTANTS, VERSION from archivebox.config.common import get_config from archivebox.config.collection import write_config_file - from archivebox.misc.db import apply_migrations + from archivebox.misc.db import apply_migrations, database_exists, ensure_database_ready from archivebox.misc.checks import check_migrations config = get_config() is_empty = not len(set(os.listdir(CONSTANTS.DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR) - existing_index = os.path.isfile(CONSTANTS.DATABASE_FILE) + existing_index = database_exists() if is_empty and not existing_index: print(f"[turquoise4][+] Initializing a new ArchiveBox v{VERSION} collection...[/turquoise4]") print("[green]----------------------------------------------------------------------[/green]") @@ -82,11 +82,15 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non # create the ArchiveBox.conf file write_config_file({"SECRET_KEY": config.SECRET_KEY}) - if os.access(CONSTANTS.DATABASE_FILE, os.F_OK): + if existing_index: print("\n[green][*] Verifying main SQL index and running any migrations needed...[/green]") else: print("\n[green][+] Building main SQL index and running initial migrations...[/green]") + # For postgres, make sure the server is reachable and create the database + # if it doesn't exist yet (sqlite creates its file automatically). + ensure_database_ready() + from archivebox.config.django import setup_django setup_django() @@ -103,9 +107,14 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non else: os.environ["ARCHIVEBOX_WANTS_INIT"] = previous_wants_init - assert os.path.isfile(CONSTANTS.DATABASE_FILE) and os.access(CONSTANTS.DATABASE_FILE, os.R_OK) + from archivebox.misc.db import database_display_location, is_postgres + + assert database_exists() print() - print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, CONSTANTS.DATA_DIR)}") + if is_postgres(): + print(f" √ {database_display_location()}") + else: + print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, CONSTANTS.DATA_DIR)}") print() print("[dodger_blue3][*] Checking links from indexes and archive folders (safe to Ctrl+C)...[/dodger_blue3]") diff --git a/archivebox/cli/archivebox_status.py b/archivebox/cli/archivebox_status.py index 96f8c035..e4927788 100644 --- a/archivebox/cli/archivebox_status.py +++ b/archivebox/cli/archivebox_status.py @@ -44,7 +44,10 @@ def status(out_dir: Path = CONSTANTS.DATA_DIR) -> None: legacy_snapshot_dirs = [ entry for entry in archive_dir.iterdir() if entry.is_dir() and not entry.is_symlink() and Snapshot.is_legacy_archive_dir(entry) ] - print(f" > SQL Main Index: {num_sql_links} links".ljust(36), f"(found in {CONSTANTS.SQL_INDEX_FILENAME})") + from archivebox.misc.db import database_display_location, is_postgres + + index_location = database_display_location() if is_postgres() else CONSTANTS.SQL_INDEX_FILENAME + print(f" > SQL Main Index: {num_sql_links} links".ljust(36), f"(found in {index_location})") print(f" > JSON Link Details: {len(legacy_snapshot_dirs)} links".ljust(36), f"(found in {archive_dir.name}/*/index.json)") print() print("[green]\\[*] Scanning archive data directories...[/green]") diff --git a/archivebox/config/common.py b/archivebox/config/common.py index fdb39bb0..01c4c9ec 100644 --- a/archivebox/config/common.py +++ b/archivebox/config/common.py @@ -350,7 +350,16 @@ class DatabaseConfig(BaseConfigSet): toml_section_header: str = "DATABASE_CONFIG" _scope: str = PrivateAttr(default=_SCOPE_SERVER) + DATABASE_ENGINE: str = Field( + default="sqlite", + alias="ARCHIVEBOX_DATABASE_ENGINE", + pattern=r"(?i)^(sqlite|postgres|postgresql)$", + ) DATABASE_NAME: str = Field(default=str(CONSTANTS.DATABASE_FILE), alias="ARCHIVEBOX_DATABASE_NAME") + DATABASE_HOST: str = Field(default="127.0.0.1", alias="ARCHIVEBOX_DATABASE_HOST") + DATABASE_PORT: int = Field(default=5432, alias="ARCHIVEBOX_DATABASE_PORT", ge=1, le=65535) + DATABASE_USER: str = Field(default="archivebox", alias="ARCHIVEBOX_DATABASE_USER") + DATABASE_PASSWORD: str = Field(default="", alias="ARCHIVEBOX_DATABASE_PASSWORD") SQLITE_JOURNAL_MODE: str = Field( default="WAL", alias="ARCHIVEBOX_SQLITE_JOURNAL_MODE", diff --git a/archivebox/config/django.py b/archivebox/config/django.py index 9f326522..a4626e5d 100644 --- a/archivebox/config/django.py +++ b/archivebox/config/django.py @@ -149,9 +149,10 @@ def setup_django(check_db=False, in_memory_db=False) -> None: for conn in connections.all(): conn.close_if_unusable_or_obsolete() - sql_index_path = CONSTANTS.DATABASE_FILE - assert os.access(sql_index_path, os.F_OK), ( - f"No database file {sql_index_path} found in: {CONSTANTS.DATA_DIR} (Are you in an ArchiveBox collection directory?)" + from archivebox.misc.db import database_display_location, database_exists + + assert database_exists(), ( + f"No database {database_display_location()} found for: {CONSTANTS.DATA_DIR} (Are you in an ArchiveBox collection directory?)" ) except KeyboardInterrupt: diff --git a/archivebox/config/paths.py b/archivebox/config/paths.py index 036cc550..3e5611d7 100644 --- a/archivebox/config/paths.py +++ b/archivebox/config/paths.py @@ -58,9 +58,11 @@ def _get_collection_id(DATA_DIR=DATA_DIR, force_create=False) -> str: collection_id = hashlib.sha256(f"{machine_id}:{collection_path}@{creation_date}".encode()).hexdigest()[:8] try: - # only persist collection_id file if we already have an index.sqlite3 file present + # only persist collection_id file if this dir already looks like a real collection + # (has an index.sqlite3, or an ArchiveBox.conf when the DB lives in postgres), # otherwise we might be running in a directory that is not a collection, no point creating cruft files - collection_is_active = os.path.isfile(DATABASE_FILE) and os.path.isdir(ARCHIVE_DIR) and os.access(DATA_DIR, os.W_OK) + collection_marker = os.path.isfile(DATABASE_FILE) or os.path.isfile(DATA_DIR / "ArchiveBox.conf") + collection_is_active = collection_marker and os.path.isdir(ARCHIVE_DIR) and os.access(DATA_DIR, os.W_OK) if collection_is_active or force_create: collection_id_file.write_text(collection_id) @@ -270,6 +272,24 @@ def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBox raise OSError(f"ArchiveBox is unable to find a writable ABXPKG_LIB_DIR, tried {CANDIDATES}!") +def _sql_index_location() -> dict: + from archivebox.misc.db import database_display_location, database_exists, is_postgres + + if is_postgres(): + return { + "path": database_display_location(), + "enabled": True, + "is_valid": database_exists(), + "is_mount": False, + } + return { + "path": DATABASE_FILE.resolve(), + "enabled": True, + "is_valid": os.path.isfile(DATABASE_FILE) and os.access(DATABASE_FILE, os.R_OK) and os.access(DATABASE_FILE, os.W_OK), + "is_mount": os.path.ismount(DATABASE_FILE.resolve()), + } + + def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs): from archivebox.config.constants import CONSTANTS from archivebox.config.common import get_config @@ -296,12 +316,7 @@ def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs and os.access(CONSTANTS.CONFIG_FILE, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.W_OK), }, - "SQL_INDEX": { - "path": DATABASE_FILE.resolve(), - "enabled": True, - "is_valid": os.path.isfile(DATABASE_FILE) and os.access(DATABASE_FILE, os.R_OK) and os.access(DATABASE_FILE, os.W_OK), - "is_mount": os.path.ismount(DATABASE_FILE.resolve()), - }, + "SQL_INDEX": _sql_index_location(), "ARCHIVE_DIR": { "path": CONSTANTS.ARCHIVE_DIR.resolve(), "enabled": True, diff --git a/archivebox/core/admin_site.py b/archivebox/core/admin_site.py index 9df8e688..36ef51ba 100644 --- a/archivebox/core/admin_site.py +++ b/archivebox/core/admin_site.py @@ -122,8 +122,6 @@ class ArchiveBoxAdmin(admin.AdminSite): def index(self, request: "HttpRequest", extra_context: dict[str, Any] | None = None) -> "TemplateResponse": response = super().index(request, extra_context) - if connection.vendor != "sqlite": - return response models_by_table: dict[str, list[dict[str, Any]]] = {} for app in response.context_data.get("app_list", []): @@ -136,23 +134,18 @@ class ArchiveBoxAdmin(admin.AdminSite): if not models_by_table: return response - try: - with connection.cursor() as cursor: - cursor.execute("SELECT tbl, stat FROM sqlite_stat1") - for table, stat in cursor.fetchall(): - try: - count = int(str(stat).split()[0]) - except (IndexError, TypeError, ValueError): - continue - self._set_model_object_count( - models_by_table, - table, - count, - title=f"Approximate count from SQLite stats: {count:,}", - ) - models_by_table.pop(table, None) - except DatabaseError: - pass + from archivebox.misc.db import approximate_row_counts + + for table, count in approximate_row_counts(connection).items(): + if table not in models_by_table: + continue + self._set_model_object_count( + models_by_table, + table, + count, + title=f"Approximate count from database stats: {count:,}", + ) + models_by_table.pop(table, None) for table in list(models_by_table): try: diff --git a/archivebox/core/apps.py b/archivebox/core/apps.py index 774d7433..c2674b6b 100644 --- a/archivebox/core/apps.py +++ b/archivebox/core/apps.py @@ -20,6 +20,14 @@ class CoreConfig(AppConfig): ModelWithOutputDir.register_delete_signal() + # SQLite ignores VARCHAR(n) limits but PostgreSQL enforces them; clamp + # CharField values on save so writes behave the same on both backends. + from django.db.models.signals import pre_save + + from archivebox.misc.db import truncate_overlong_charfields + + pre_save.connect(truncate_overlong_charfields, dispatch_uid="archivebox_truncate_overlong_charfields") + # Import models to register state machines with the registry # Skip during makemigrations to avoid premature state machine access if "makemigrations" not in sys.argv: diff --git a/archivebox/core/migrations/0023_upgrade_to_0_9_0.py b/archivebox/core/migrations/0023_upgrade_to_0_9_0.py index 1dfb13ed..a25d9569 100644 --- a/archivebox/core/migrations/0023_upgrade_to_0_9_0.py +++ b/archivebox/core/migrations/0023_upgrade_to_0_9_0.py @@ -47,6 +47,14 @@ def normalize_status(status): def upgrade_core_tables(apps, schema_editor): """Upgrade core tables from v0.7.2 or v0.8.6rc0 to v0.9.0.""" + # sqlite-only legacy repair/rebuild. On postgres this raw SQL (PRAGMA, + # sqlite_master, INSERT OR IGNORE, DATETIME, table-rebuild dance) is both + # invalid and unnecessary: a postgres database can never contain legacy + # data at this point. The final _pg_sync_schema op resyncs the real + # postgres schema to migration state instead. + if schema_editor.connection.vendor != "sqlite": + return + from archivebox.uuid_compat import uuid7 cursor = connection.cursor() @@ -491,6 +499,15 @@ def upgrade_core_tables(apps, schema_editor): print(" ✓ Core table rebuild complete") +def _pg_sync_schema(apps, schema_editor): + # On postgres, upgrade_core_tables is a no-op above, so the raw sqlite + # rebuilds of core_tag/core_snapshot/core_archiveresult never ran. Resync + # the real schema to this migration's end-state (tables are empty on pg). + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state(apps, schema_editor, "core", ["Tag", "Snapshot", "SnapshotTag", "ArchiveResult"]) + + class Migration(migrations.Migration): dependencies = [ ("core", "0022_auto_20231023_2008"), @@ -571,4 +588,5 @@ class Migration(migrations.Migration): ), ], ), + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/core/migrations/0024_assign_default_crawl.py b/archivebox/core/migrations/0024_assign_default_crawl.py index e32c2552..75548553 100644 --- a/archivebox/core/migrations/0024_assign_default_crawl.py +++ b/archivebox/core/migrations/0024_assign_default_crawl.py @@ -4,11 +4,102 @@ from django.db import migrations, models +# Raw sqlite table-rebuild that makes core_snapshot.crawl_id NOT NULL. Kept +# byte-for-byte and replayed through Django's own RunSQL only on sqlite. +_MAKE_CRAWL_ID_NOT_NULL_SQL = """ + -- Rebuild snapshot table with NOT NULL crawl_id + CREATE TABLE core_snapshot_final ( + id TEXT PRIMARY KEY NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + url TEXT NOT NULL, + timestamp VARCHAR(32) NOT NULL UNIQUE, + bookmarked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + crawl_id TEXT NOT NULL, + parent_snapshot_id TEXT, + + title VARCHAR(512), + downloaded_at DATETIME, + depth INTEGER NOT NULL DEFAULT 0, + fs_version VARCHAR(10) NOT NULL DEFAULT '0.9.0', + + config TEXT NOT NULL DEFAULT '{}', + notes TEXT NOT NULL DEFAULT '', + num_uses_succeeded INTEGER NOT NULL DEFAULT 0, + num_uses_failed INTEGER NOT NULL DEFAULT 0, + + status VARCHAR(15) NOT NULL DEFAULT 'queued', + retry_at DATETIME, + current_step INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY (crawl_id) REFERENCES crawls_crawl(id) ON DELETE CASCADE, + FOREIGN KEY (parent_snapshot_id) REFERENCES core_snapshot(id) ON DELETE SET NULL + ); + + INSERT INTO core_snapshot_final ( + id, url, timestamp, title, + bookmarked_at, created_at, modified_at, + crawl_id, parent_snapshot_id, + downloaded_at, depth, fs_version, + config, notes, + num_uses_succeeded, num_uses_failed, + status, retry_at, current_step + ) + SELECT + id, url, timestamp, title, + bookmarked_at, created_at, modified_at, + REPLACE(crawl_id, '-', ''), REPLACE(parent_snapshot_id, '-', ''), + downloaded_at, depth, fs_version, + COALESCE(config, '{}'), COALESCE(notes, ''), + num_uses_succeeded, num_uses_failed, + status, retry_at, current_step + FROM core_snapshot; + + DROP TABLE core_snapshot; + ALTER TABLE core_snapshot_final RENAME TO core_snapshot; + + CREATE INDEX core_snapshot_url_idx ON core_snapshot(url); + CREATE INDEX core_snapshot_timestamp_idx ON core_snapshot(timestamp); + CREATE INDEX core_snapshot_bookmarked_at_idx ON core_snapshot(bookmarked_at); + CREATE INDEX core_snapshot_crawl_id_idx ON core_snapshot(crawl_id); + CREATE INDEX core_snapshot_status_idx ON core_snapshot(status); + CREATE INDEX core_snapshot_retry_at_idx ON core_snapshot(retry_at); + CREATE INDEX core_snapshot_created_at_idx ON core_snapshot(created_at); + CREATE UNIQUE INDEX core_snapshot_url_crawl_unique ON core_snapshot(url, crawl_id); + """ + + +def _make_crawl_id_not_null(apps, schema_editor): + # sqlite-only table rebuild. Reuse Django's own RunSQL statement splitting so + # the sqlite behavior is byte-for-byte identical to the original RunSQL op. + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL(sql=_MAKE_CRAWL_ID_NOT_NULL_SQL).database_forwards("core", schema_editor, None, None) + + +def _pg_sync_schema(apps, schema_editor): + # On postgres the raw sqlite rebuilds above are skipped and crawl was only + # added to migration state; resync the real schema to this migration's + # end-state (empty tables). Snapshot is referenced by SnapshotTag and + # ArchiveResult, so those are rebuilt too to restore FK constraints. + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state(apps, schema_editor, "core", ["Snapshot", "SnapshotTag", "ArchiveResult"]) + + def create_default_crawl_and_assign_snapshots(apps, schema_editor): """ Create a default crawl for migrated snapshots and assign all snapshots without a crawl to it. Uses raw SQL because the app registry isn't fully populated during migrations. """ + # Legacy-data-only, sqlite-specific (PRAGMA + '?' placeholders). On a fresh + # postgres install core_snapshot has no crawl_id column yet and there are no + # unassigned snapshots, so gate before touching any SQL. + if schema_editor.connection.vendor != "sqlite": + return + from django.db import connection import uuid as uuid_lib from datetime import datetime @@ -89,72 +180,10 @@ class Migration(migrations.Migration): ), migrations.SeparateDatabaseAndState( database_operations=[ - # Now make crawl_id NOT NULL - migrations.RunSQL( - sql=""" - -- Rebuild snapshot table with NOT NULL crawl_id - CREATE TABLE core_snapshot_final ( - id TEXT PRIMARY KEY NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - url TEXT NOT NULL, - timestamp VARCHAR(32) NOT NULL UNIQUE, - bookmarked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - crawl_id TEXT NOT NULL, - parent_snapshot_id TEXT, - - title VARCHAR(512), - downloaded_at DATETIME, - depth INTEGER NOT NULL DEFAULT 0, - fs_version VARCHAR(10) NOT NULL DEFAULT '0.9.0', - - config TEXT NOT NULL DEFAULT '{}', - notes TEXT NOT NULL DEFAULT '', - num_uses_succeeded INTEGER NOT NULL DEFAULT 0, - num_uses_failed INTEGER NOT NULL DEFAULT 0, - - status VARCHAR(15) NOT NULL DEFAULT 'queued', - retry_at DATETIME, - current_step INTEGER NOT NULL DEFAULT 0, - - FOREIGN KEY (crawl_id) REFERENCES crawls_crawl(id) ON DELETE CASCADE, - FOREIGN KEY (parent_snapshot_id) REFERENCES core_snapshot(id) ON DELETE SET NULL - ); - - INSERT INTO core_snapshot_final ( - id, url, timestamp, title, - bookmarked_at, created_at, modified_at, - crawl_id, parent_snapshot_id, - downloaded_at, depth, fs_version, - config, notes, - num_uses_succeeded, num_uses_failed, - status, retry_at, current_step - ) - SELECT - id, url, timestamp, title, - bookmarked_at, created_at, modified_at, - REPLACE(crawl_id, '-', ''), REPLACE(parent_snapshot_id, '-', ''), - downloaded_at, depth, fs_version, - COALESCE(config, '{}'), COALESCE(notes, ''), - num_uses_succeeded, num_uses_failed, - status, retry_at, current_step - FROM core_snapshot; - - DROP TABLE core_snapshot; - ALTER TABLE core_snapshot_final RENAME TO core_snapshot; - - CREATE INDEX core_snapshot_url_idx ON core_snapshot(url); - CREATE INDEX core_snapshot_timestamp_idx ON core_snapshot(timestamp); - CREATE INDEX core_snapshot_bookmarked_at_idx ON core_snapshot(bookmarked_at); - CREATE INDEX core_snapshot_crawl_id_idx ON core_snapshot(crawl_id); - CREATE INDEX core_snapshot_status_idx ON core_snapshot(status); - CREATE INDEX core_snapshot_retry_at_idx ON core_snapshot(retry_at); - CREATE INDEX core_snapshot_created_at_idx ON core_snapshot(created_at); - CREATE UNIQUE INDEX core_snapshot_url_crawl_unique ON core_snapshot(url, crawl_id); - """, - reverse_sql=migrations.RunSQL.noop, + # Now make crawl_id NOT NULL (sqlite-only table rebuild) + migrations.RunPython( + _make_crawl_id_not_null, + reverse_code=migrations.RunPython.noop, ), ], state_operations=[ @@ -169,4 +198,5 @@ class Migration(migrations.Migration): ), ], ), + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/core/migrations/0025_alter_archiveresult_options_alter_snapshot_options_and_more.py b/archivebox/core/migrations/0025_alter_archiveresult_options_alter_snapshot_options_and_more.py index c4eb2ee8..46d637ad 100644 --- a/archivebox/core/migrations/0025_alter_archiveresult_options_alter_snapshot_options_and_more.py +++ b/archivebox/core/migrations/0025_alter_archiveresult_options_alter_snapshot_options_and_more.py @@ -11,6 +11,12 @@ from archivebox.uuid_compat import uuid7 def copy_old_fields_to_new(apps, schema_editor): """Copy data from old field names to new field names after AddField operations.""" + # sqlite-only legacy data copy (PRAGMA introspection). On postgres there is + # no legacy data and the state-only AddFields below are resynced by + # _pg_sync_schema at the end of this migration. + if schema_editor.connection.vendor != "sqlite": + return + cursor = connection.cursor() # Check if old fields still exist @@ -43,6 +49,19 @@ def copy_old_fields_to_new(apps, schema_editor): # NOTE: UUIDs are already populated by migration 0023 for all migration paths. +def _pg_sync_schema(apps, schema_editor): + # This migration mixes real ORM AddFields with state-only AddFields (the + # snapshot config/current_step/depth/notes/num_uses_*/parent_snapshot/ + # retry_at/status columns and archiveresult created_at/modified_at were + # created on sqlite by 0023's raw rebuild, so here they are state-only). + # On postgres those columns would never land, desyncing the real schema. + # Resync to this migration's final state (empty tables on pg). Snapshot is + # referenced by SnapshotTag and ArchiveResult, so rebuild those too. + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state(apps, schema_editor, "core", ["Snapshot", "SnapshotTag", "ArchiveResult"]) + + class Migration(migrations.Migration): dependencies = [ ("core", "0024_assign_default_crawl"), @@ -335,4 +354,5 @@ class Migration(migrations.Migration): model_name="snapshot", constraint=models.UniqueConstraint(fields=("timestamp",), name="unique_timestamp"), ), + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/core/migrations/0027_copy_archiveresult_to_process.py b/archivebox/core/migrations/0027_copy_archiveresult_to_process.py index 0b983425..a564b252 100644 --- a/archivebox/core/migrations/0027_copy_archiveresult_to_process.py +++ b/archivebox/core/migrations/0027_copy_archiveresult_to_process.py @@ -275,6 +275,14 @@ def copy_archiveresult_data_to_process(apps, schema_editor): - failed → exited (exit_code=1) - skipped → exited (exit_code=None) """ + # sqlite-only legacy data copy (PRAGMA introspection, '?' placeholders). + # A postgres install can never contain legacy ArchiveResult cmd/pwd data at + # this point, so there is nothing to copy into machine_process. The + # RemoveField ops below are real (non-state-only) and drop the now-empty + # cmd/pwd/cmd_version columns on both vendors, so no resync is needed. + if schema_editor.connection.vendor != "sqlite": + return + cursor = connection.cursor() # Check if old fields still exist (skip if fresh install or already migrated) diff --git a/archivebox/core/migrations/0029_migrate_archiveresult_to_uuid_pk.py b/archivebox/core/migrations/0029_migrate_archiveresult_to_uuid_pk.py index 64045aad..ddf16ea5 100644 --- a/archivebox/core/migrations/0029_migrate_archiveresult_to_uuid_pk.py +++ b/archivebox/core/migrations/0029_migrate_archiveresult_to_uuid_pk.py @@ -26,6 +26,12 @@ def migrate_archiveresult_id_to_uuid(apps, schema_editor): Result: Clean schema with ONLY id as UUIDField (no old_id, no uuid) """ + # sqlite-only table rebuild (sqlite_master, PRAGMA, table copy/rename). On + # postgres this is unnecessary and invalid: the table is empty and the + # state flip of id -> UUIDField is applied by _pg_sync_schema below. + if schema_editor.connection.vendor != "sqlite": + return + cursor = connection.cursor() # Check if table exists and has data @@ -190,6 +196,16 @@ def migrate_archiveresult_id_to_uuid(apps, schema_editor): print(f" ✓ ArchiveResult UUID primary key migration complete ({row_count} records)") +def _pg_sync_schema(apps, schema_editor): + # On postgres the sqlite table rebuild above is skipped; the id -> UUIDField + # flip and uuid-field removal only reach migration state. Resync the real + # (empty) core_archiveresult table to this migration's end-state. Nothing in + # core references ArchiveResult, so no other models need rebuilding. + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state(apps, schema_editor, "core", ["ArchiveResult"]) + + class Migration(migrations.Migration): dependencies = [ ("core", "0028_alter_snapshot_fs_version"), @@ -217,4 +233,5 @@ class Migration(migrations.Migration): ), ], ), + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/core/migrations/0046_repair_snapshot_permissions.py b/archivebox/core/migrations/0046_repair_snapshot_permissions.py index 53703c90..0317453a 100644 --- a/archivebox/core/migrations/0046_repair_snapshot_permissions.py +++ b/archivebox/core/migrations/0046_repair_snapshot_permissions.py @@ -13,6 +13,13 @@ def _repair_snapshot_permissions(apps, schema_editor): no underlying column. Fresh installs added the column via 0041 and this is a no-op. """ + # sqlite-only legacy repair (PRAGMA table_xinfo + sqlite VIRTUAL generated + # column syntax). On postgres the permissions column was created by 0041's + # portable GeneratedField AddField (STORED generated column), so there is + # nothing to repair. + if schema_editor.connection.vendor != "sqlite": + return + cursor = schema_editor.connection.cursor() # ``table_xinfo`` lists STORED/VIRTUAL generated columns; ``table_info`` # silently drops them, so a prior 0041 that landed the STORED column diff --git a/archivebox/core/migrations/0051_postgres_url_pattern_ops_index.py b/archivebox/core/migrations/0051_postgres_url_pattern_ops_index.py new file mode 100644 index 00000000..ad31ff3b --- /dev/null +++ b/archivebox/core/migrations/0051_postgres_url_pattern_ops_index.py @@ -0,0 +1,30 @@ +# Postgres-only: btree pattern-ops index on core_snapshot.url so LIKE 'prefix%' +# queries (url__startswith and the URL prefix search) stay index scans under any +# database collation. SQLite needs nothing here: its plain url index already +# serves the bytewise range comparisons used on that backend. + +from django.db import migrations + + +def add_pg_url_pattern_index(apps, schema_editor): + if schema_editor.connection.vendor != "postgresql": + return + schema_editor.execute( + "CREATE INDEX IF NOT EXISTS core_snapshot_url_pattern_ops_idx ON core_snapshot (url text_pattern_ops)", + ) + + +def remove_pg_url_pattern_index(apps, schema_editor): + if schema_editor.connection.vendor != "postgresql": + return + schema_editor.execute("DROP INDEX IF EXISTS core_snapshot_url_pattern_ops_idx") + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0050_snapshot_permissions_not_null"), + ] + + operations = [ + migrations.RunPython(add_pg_url_pattern_index, remove_pg_url_pattern_index), + ] diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py index f3e0370f..b278ee2a 100644 --- a/archivebox/core/settings.py +++ b/archivebox/core/settings.py @@ -212,44 +212,18 @@ TEMPLATES = [ ### External Service Settings ################################################################################ +# All sqlite-vs-postgres connection logic lives in archivebox.misc.db; +# DATABASE_ENGINE config selects the backend (sqlite by default). +from archivebox.misc.db import get_database_settings, get_sqlite_connection_options + DATABASE_NAME = CONFIG.DATABASE_NAME SQLITE_JOURNAL_MODE = CONFIG.SQLITE_JOURNAL_MODE SQLITE_MMAP_SIZE = CONFIG.SQLITE_MMAP_SIZE -SQLITE_CONNECTION_OPTIONS = { - "ENGINE": "archivebox.core.sqlite_backend", - "TIME_ZONE": CONSTANTS.TIMEZONE, - "OPTIONS": { - # https://gcollazo.com/optimal-sqlite-settings-for-django/ - # https://litestream.io/tips/#busy-timeout - # https://docs.djangoproject.com/en/5.1/ref/databases/#setting-pragma-options - "timeout": CONFIG.SQLITE_BUSY_TIMEOUT / 1000, - "check_same_thread": False, - # Keep SQLite on Django's default deferred transaction mode. BEGIN - # IMMEDIATE grabs the write lock as soon as atomic() opens, which is - # exactly what hurts ArchiveBox on large collections where Python code - # may do filesystem work before the actual row write. Deferred BEGIN - # keeps writes statement-scoped unless a caller explicitly opens a - # transaction around multiple writes. - "transaction_mode": None, - "init_command": ( - "PRAGMA foreign_keys=ON;" - f"PRAGMA busy_timeout = {CONFIG.SQLITE_BUSY_TIMEOUT};" - f"PRAGMA journal_mode = {SQLITE_JOURNAL_MODE};" - "PRAGMA synchronous = NORMAL;" - "PRAGMA temp_store = MEMORY;" - f"PRAGMA mmap_size = {SQLITE_MMAP_SIZE};" - "PRAGMA journal_size_limit = 67108864;" - "PRAGMA cache_size = 2000;" - ), - }, -} +SQLITE_CONNECTION_OPTIONS = get_sqlite_connection_options() DATABASES = { - "default": { - "NAME": DATABASE_NAME, - **SQLITE_CONNECTION_OPTIONS, - }, + "default": get_database_settings(), } MIGRATION_MODULES = {"signal_webhooks": None} diff --git a/archivebox/core/views.py b/archivebox/core/views.py index d9f4bfe9..c046354c 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -16,6 +16,7 @@ from django.utils.safestring import mark_safe from django.views import View from django.views.generic.list import ListView from django.views.generic import FormView +from django.db import connection from django.db.models import Case, IntegerField, Q, Value, When from django.core.paginator import InvalidPage from django.contrib import messages @@ -276,12 +277,18 @@ class SnapshotView(View): """ def _fragmentless_url_query(url: str) -> Q: - # Use a range comparison (url >= 'canonical#' AND url < 'canonical#\U0010ffff') - # instead of LIKE/__startswith — SQLite's case-insensitive LIKE bypasses the - # url index and forces a full-table scan over ~1M rows (~250ms). The range - # form lets SQLite use a MULTI-INDEX OR and stays under 1ms. canonical = without_fragment(url) - return Q(url=canonical) | (Q(url__gte=f"{canonical}#") & Q(url__lt=f"{canonical}#\U0010ffff")) + if connection.vendor == "sqlite": + # Use a range comparison (url >= 'canonical#' AND url < 'canonical#\U0010ffff') + # instead of LIKE/__startswith — SQLite's case-insensitive LIKE bypasses the + # url index and forces a full-table scan over ~1M rows (~250ms). The range + # form lets SQLite use a MULTI-INDEX OR and stays under 1ms. + return Q(url=canonical) | (Q(url__gte=f"{canonical}#") & Q(url__lt=f"{canonical}#\U0010ffff")) + # On postgres the range trick is unsafe: linguistic (ICU/libc) collations + # don't compare '#'-suffixed strings bytewise, so the range can miss rows. + # startswith compiles to LIKE 'prefix%' with wildcards escaped, which is + # correct under any collation and uses the url pattern-ops index. + return Q(url=canonical) | Q(url__startswith=f"{canonical}#") normalized = without_fragment(path) if path.startswith(("http://", "https://")): diff --git a/archivebox/crawls/migrations/0001_initial.py b/archivebox/crawls/migrations/0001_initial.py index c90b52ad..6b611a3b 100644 --- a/archivebox/crawls/migrations/0001_initial.py +++ b/archivebox/crawls/migrations/0001_initial.py @@ -10,19 +10,9 @@ from archivebox.uuid_compat import uuid7 from archivebox.base_models.models import get_or_create_system_user_pk -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.SeparateDatabaseAndState( - database_operations=[ - migrations.RunSQL( - sql=""" +# Raw sqlite DDL kept EXACTLY as-is; executed only on sqlite via Django's +# RunSQL (identical statement splitting → byte-for-byte identical behavior). +INITIAL_SQL = """ -- Create crawls_crawlschedule table first (circular FK will be added later) CREATE TABLE IF NOT EXISTS crawls_crawlschedule ( id TEXT PRIMARY KEY NOT NULL, @@ -75,12 +65,48 @@ class Migration(migrations.Migration): CREATE INDEX IF NOT EXISTS crawls_crawl_created_at_idx ON crawls_crawl(created_at); CREATE INDEX IF NOT EXISTS crawls_crawl_created_by_id_idx ON crawls_crawl(created_by_id); CREATE INDEX IF NOT EXISTS crawls_crawl_schedule_id_idx ON crawls_crawl(schedule_id); - """, - reverse_sql=""" + """ + +INITIAL_REVERSE_SQL = """ DROP TABLE IF EXISTS crawls_crawl; DROP TABLE IF EXISTS crawls_crawlschedule; - """, - ), + """ + + +def _run_sqlite_only_sql(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL(sql=INITIAL_SQL, reverse_sql=INITIAL_REVERSE_SQL).database_forwards( + "crawls", schema_editor, None, None, + ) + + +def _run_sqlite_only_sql_reverse(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL(sql=INITIAL_SQL, reverse_sql=INITIAL_REVERSE_SQL).database_backwards( + "crawls", schema_editor, None, None, + ) + + +def _pg_sync_schema(apps, schema_editor): + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state(apps, schema_editor, "crawls", ["CrawlSchedule", "Crawl"]) + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython(_run_sqlite_only_sql, reverse_code=_run_sqlite_only_sql_reverse), ], state_operations=[ migrations.CreateModel( @@ -174,4 +200,9 @@ class Migration(migrations.Migration): ), ], ), + # On non-sqlite backends the raw DDL above is skipped, so the real + # schema diverges from Django state. Rebuild both tables from the + # post-migration state (no-op on sqlite). Runs as a top-level op so + # ``apps`` reflects the full state including the circular template FK. + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/crawls/migrations/0002_upgrade_from_0_8_6.py b/archivebox/crawls/migrations/0002_upgrade_from_0_8_6.py index eee69b1a..86e86763 100644 --- a/archivebox/crawls/migrations/0002_upgrade_from_0_8_6.py +++ b/archivebox/crawls/migrations/0002_upgrade_from_0_8_6.py @@ -6,6 +6,10 @@ from django.db import migrations, connection def upgrade_crawl_table_from_v086(apps, schema_editor): """Upgrade crawls_crawl table from v0.8.6rc0 schema to v0.9.0 schema.""" + # sqlite-only legacy repair (sqlite_master/PRAGMA/GLOB). Postgres support + # postdates all legacy data, so this never has work to do there. + if schema_editor.connection.vendor != "sqlite": + return cursor = connection.cursor() # Check if crawls_crawl table exists diff --git a/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py b/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py index 1377faac..87a7403f 100644 --- a/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py +++ b/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py @@ -97,6 +97,12 @@ def _ensure_permissions_column(cursor): def hydrate_crawl_permissions(apps, schema_editor): + # sqlite-only legacy hydration + generated-column repair (PRAGMA + # table_xinfo / VIRTUAL generated column). On postgres the ``permissions`` + # column is created portably by 0013 (AddField GeneratedField) and there is + # never legacy data to hydrate, so this is a no-op there. + if schema_editor.connection.vendor != "sqlite": + return Crawl = apps.get_model("crawls", "Crawl") base_config = raw_base_config(apps) default_permissions = resolve_permissions(base_config, "public") diff --git a/archivebox/machine/migrations/0001_initial.py b/archivebox/machine/migrations/0001_initial.py index 2a0f018c..9425f761 100644 --- a/archivebox/machine/migrations/0001_initial.py +++ b/archivebox/machine/migrations/0001_initial.py @@ -7,16 +7,36 @@ import django.utils.timezone from archivebox.uuid_compat import uuid7 -class Migration(migrations.Migration): - initial = True +def _run_sqlite_initial_ddl(apps, schema_editor): + # sqlite-only: raw DDL creates the real tables byte-for-byte as historically + # shipped. On other vendors the tables are built from migration state by + # _pg_sync_schema below (a fresh non-sqlite DB never holds legacy data here). + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL( + sql=_SQLITE_INITIAL_SQL, + reverse_sql=_SQLITE_INITIAL_REVERSE_SQL, + ).database_forwards("machine", schema_editor, None, None) - dependencies = [] - operations = [ - migrations.SeparateDatabaseAndState( - database_operations=[ - migrations.RunSQL( - sql=""" +def _reverse_sqlite_initial_ddl(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + migrations.RunSQL( + sql=_SQLITE_INITIAL_SQL, + reverse_sql=_SQLITE_INITIAL_REVERSE_SQL, + ).database_backwards("machine", schema_editor, None, None) + + +def _pg_sync_schema(apps, schema_editor): + from archivebox.misc.db import rebuild_models_from_migration_state + + rebuild_models_from_migration_state( + apps, schema_editor, "machine", ["Machine", "NetworkInterface", "Binary"] + ) + + +_SQLITE_INITIAL_SQL = """ -- Create machine_machine table CREATE TABLE IF NOT EXISTS machine_machine ( id TEXT PRIMARY KEY NOT NULL, @@ -98,13 +118,25 @@ class Migration(migrations.Migration): CREATE INDEX IF NOT EXISTS machine_binary_status_idx ON machine_binary(status); CREATE INDEX IF NOT EXISTS machine_binary_retry_at_idx ON machine_binary(retry_at); - """, - reverse_sql=""" + """ + + +_SQLITE_INITIAL_REVERSE_SQL = """ DROP TABLE IF EXISTS machine_binary; DROP TABLE IF EXISTS machine_networkinterface; DROP TABLE IF EXISTS machine_machine; - """, - ), + """ + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython(_run_sqlite_initial_ddl, _reverse_sqlite_initial_ddl), ], state_operations=[ migrations.CreateModel( @@ -244,4 +276,5 @@ class Migration(migrations.Migration): ), ], ), + migrations.RunPython(_pg_sync_schema, reverse_code=migrations.RunPython.noop), ] diff --git a/archivebox/machine/migrations/0005_converge_binary_model.py b/archivebox/machine/migrations/0005_converge_binary_model.py index c1e89175..cbbac9b2 100644 --- a/archivebox/machine/migrations/0005_converge_binary_model.py +++ b/archivebox/machine/migrations/0005_converge_binary_model.py @@ -10,6 +10,13 @@ def converge_binary_table(apps, schema_editor): Drop machine_installedbinary if it exists (0.8.6rc0 path). Create machine_binary if it doesn't exist (needed by Process model). """ + # sqlite-only legacy convergence: on a fresh install machine_binary already + # exists with the final column set (created by 0001), so every branch below + # is a no-op schema-wise. A non-sqlite DB never carries the 0.8.x legacy + # shapes this repairs, and its Binary table already matches migration state. + if schema_editor.connection.vendor != "sqlite": + return + cursor = connection.cursor() # Check what tables exist diff --git a/archivebox/machine/migrations/0011_remove_binary_output_dir.py b/archivebox/machine/migrations/0011_remove_binary_output_dir.py index 0a24dff1..92f0e935 100644 --- a/archivebox/machine/migrations/0011_remove_binary_output_dir.py +++ b/archivebox/machine/migrations/0011_remove_binary_output_dir.py @@ -2,9 +2,17 @@ from django.db import migrations def remove_output_dir_if_exists(apps, schema_editor): - cursor = schema_editor.connection.cursor() - cursor.execute("PRAGMA table_info(machine_binary)") - columns = {row[1] for row in cursor.fetchall()} + if schema_editor.connection.vendor == "sqlite": + cursor = schema_editor.connection.cursor() + cursor.execute("PRAGMA table_info(machine_binary)") + columns = {row[1] for row in cursor.fetchall()} + else: + # Portable introspection: on non-sqlite the RemoveField below is + # state-only, so the real ALTER TABLE DROP COLUMN must run here too. + # A fresh non-sqlite DB has machine_binary with output_dir from 0001. + from archivebox.misc.db import migration_table_columns + + columns = migration_table_columns(schema_editor.connection, "machine_binary") if "output_dir" not in columns: return diff --git a/archivebox/machine/migrations/0012_add_machine_config_if_missing.py b/archivebox/machine/migrations/0012_add_machine_config_if_missing.py index d462b8ce..e268b69e 100644 --- a/archivebox/machine/migrations/0012_add_machine_config_if_missing.py +++ b/archivebox/machine/migrations/0012_add_machine_config_if_missing.py @@ -2,6 +2,12 @@ from django.db import migrations def add_machine_config_if_missing(apps, schema_editor): + # sqlite-only: uses PRAGMA introspection. On a fresh non-sqlite install the + # config column already exists (created from 0001 migration state), so there + # is nothing to add. + if schema_editor.connection.vendor != "sqlite": + return + cursor = schema_editor.connection.cursor() cursor.execute("PRAGMA table_info(machine_machine)") columns = {row[1] for row in cursor.fetchall()} diff --git a/archivebox/misc/db.py b/archivebox/misc/db.py index a154d16b..746dcd57 100644 --- a/archivebox/misc/db.py +++ b/archivebox/misc/db.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import TextIO from typing import Any import fcntl +import os import time from collections.abc import Callable from contextlib import contextmanager @@ -21,6 +22,267 @@ from archivebox.config import CONSTANTS from archivebox.misc.util import enforce_types +# ============================================================================ +# Database backend adapter (sqlite / postgresql) +# ============================================================================ +# All sqlite-vs-postgres branching in ArchiveBox is centralized in this +# section. Code elsewhere should call these helpers instead of checking +# ``connection.vendor``, building ``DATABASES`` entries, or touching +# ``CONSTANTS.DATABASE_FILE`` directly. + +SQLITE_VENDOR = "sqlite" +POSTGRES_VENDOR = "postgresql" + + +def database_backend() -> str: + """Configured backend vendor name: 'sqlite' (default) or 'postgresql'.""" + from archivebox.config.common import get_config + + engine = (get_config().DATABASE_ENGINE or "sqlite").strip().lower() + return POSTGRES_VENDOR if engine.startswith("postgres") else SQLITE_VENDOR + + +def is_postgres() -> bool: + return database_backend() == POSTGRES_VENDOR + + +def postgres_db_params() -> dict[str, str]: + """Postgres connection params from config (NAME/USER/PASSWORD/HOST/PORT).""" + from archivebox.config.common import get_config + + config = get_config() + name = config.DATABASE_NAME + # DATABASE_NAME defaults to the sqlite file path; that default makes no + # sense as a postgres database name, so fall back to 'archivebox'. + if name == str(CONSTANTS.DATABASE_FILE) or name.endswith(".sqlite3"): + name = "archivebox" + return { + "NAME": name, + "USER": config.DATABASE_USER, + "PASSWORD": config.DATABASE_PASSWORD, + "HOST": config.DATABASE_HOST, + "PORT": str(config.DATABASE_PORT), + } + + +def get_sqlite_connection_options() -> dict[str, Any]: + """ENGINE + OPTIONS for the sqlite backend (without NAME).""" + from archivebox.config.common import get_config + + config = get_config() + return { + "ENGINE": "archivebox.core.sqlite_backend", + "TIME_ZONE": CONSTANTS.TIMEZONE, + "OPTIONS": { + # https://gcollazo.com/optimal-sqlite-settings-for-django/ + # https://litestream.io/tips/#busy-timeout + # https://docs.djangoproject.com/en/5.1/ref/databases/#setting-pragma-options + "timeout": config.SQLITE_BUSY_TIMEOUT / 1000, + "check_same_thread": False, + # Keep SQLite on Django's default deferred transaction mode. BEGIN + # IMMEDIATE grabs the write lock as soon as atomic() opens, which is + # exactly what hurts ArchiveBox on large collections where Python code + # may do filesystem work before the actual row write. Deferred BEGIN + # keeps writes statement-scoped unless a caller explicitly opens a + # transaction around multiple writes. + "transaction_mode": None, + "init_command": ( + "PRAGMA foreign_keys=ON;" + f"PRAGMA busy_timeout = {config.SQLITE_BUSY_TIMEOUT};" + f"PRAGMA journal_mode = {config.SQLITE_JOURNAL_MODE};" + "PRAGMA synchronous = NORMAL;" + "PRAGMA temp_store = MEMORY;" + f"PRAGMA mmap_size = {config.SQLITE_MMAP_SIZE};" + "PRAGMA journal_size_limit = 67108864;" + "PRAGMA cache_size = 2000;" + ), + }, + } + + +def get_database_settings() -> dict[str, Any]: + """The DATABASES['default'] dict for Django settings, per configured backend.""" + from archivebox.config.common import get_config + + if is_postgres(): + return { + "ENGINE": "django.db.backends.postgresql", + **postgres_db_params(), + "OPTIONS": {"connect_timeout": 10}, + } + return { + "NAME": get_config().DATABASE_NAME, + **get_sqlite_connection_options(), + } + + +def _psycopg_connect(dbname: str | None = None, connect_timeout: int = 5): + import psycopg + + params = postgres_db_params() + return psycopg.connect( + dbname=dbname or params["NAME"], + user=params["USER"], + password=params["PASSWORD"] or None, + host=params["HOST"], + port=params["PORT"], + connect_timeout=connect_timeout, + ) + + +def database_exists() -> bool: + """True if this collection's database has been initialized. + + sqlite: the index.sqlite3 file exists on disk. + postgres: the configured database is reachable and contains the + django_migrations table. Safe to call before Django is set up. + """ + if not is_postgres(): + return os.path.isfile(CONSTANTS.DATABASE_FILE) + try: + with _psycopg_connect() as conn: + row = conn.execute("SELECT to_regclass('django_migrations')").fetchone() + return bool(row and row[0]) + except Exception: + return False + + +def database_display_location() -> str: + """Human-readable location of the database (file path or postgres DSN).""" + if not is_postgres(): + return str(CONSTANTS.DATABASE_FILE) + params = postgres_db_params() + return f"postgresql://{params['USER']}@{params['HOST']}:{params['PORT']}/{params['NAME']}" + + +def ensure_database_ready() -> None: + """Make sure a database server is reachable before running migrations. + + sqlite: no-op (the file is created on first connection). + postgres: verify the server accepts connections and create the configured + database if it does not exist yet. Raises SystemExit with a helpful + message if the server is unreachable. + """ + if not is_postgres(): + return + + import psycopg + from rich import print as rich_print + + params = postgres_db_params() + try: + with _psycopg_connect(): + return + except psycopg.OperationalError as err: + # 3D000 invalid_catalog_name: server is up but the database is missing + if getattr(err, "sqlstate", None) != "3D000" and "does not exist" not in str(err): + rich_print(f"[red][X] Error: Unable to connect to PostgreSQL at {database_display_location()}[/red]") + rich_print(f" {err}") + rich_print(" [violet]Hint:[/violet] Check ARCHIVEBOX_DATABASE_HOST/PORT/USER/PASSWORD and that the server is running.") + raise SystemExit(4) from err + + with _psycopg_connect(dbname="postgres") as conn: + conn.autocommit = True + safe_name = params["NAME"].replace('"', '""') + conn.execute(f'CREATE DATABASE "{safe_name}"') + rich_print(f" + Created PostgreSQL database {params['NAME']}") + + +def is_missing_table_error(err: BaseException) -> bool: + """True if err means a queried table does not exist (any backend).""" + msg = str(err).lower() + return "no such table" in msg or ("relation" in msg and "does not exist" in msg) + + +def approximate_row_counts(connection) -> dict[str, int]: + """Cheap per-table approximate row counts from the backend's optimizer stats. + + sqlite: reads sqlite_stat1 (populated by ANALYZE). + postgres: reads pg_class.reltuples (maintained by autovacuum/ANALYZE). + Returns {} on any failure; tables never analyzed may be absent. + """ + counts: dict[str, int] = {} + try: + with connection.cursor() as cursor: + if connection.vendor == SQLITE_VENDOR: + cursor.execute("SELECT tbl, stat FROM sqlite_stat1") + for table, stat in cursor.fetchall(): + try: + counts[str(table)] = int(str(stat).split()[0]) + except (IndexError, TypeError, ValueError): + continue + elif connection.vendor == POSTGRES_VENDOR: + cursor.execute( + """ + SELECT c.relname, c.reltuples::bigint + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname = current_schema() + AND c.reltuples >= 0 + """, + ) + counts = {str(table): int(estimate) for table, estimate in cursor.fetchall()} + except Exception: + return {} + return counts + + +def truncate_overlong_charfields(sender, instance, **kwargs) -> None: + """pre_save receiver: clamp CharField values to their declared max_length. + + SQLite never enforces VARCHAR(n) limits, so ArchiveBox has always stored + overlong values (e.g. long crawl labels or page titles) untruncated. + PostgreSQL enforces them and would raise DataError on save instead. + Truncating at save time keeps writes succeeding identically on both + backends. Registered once for all models in CoreConfig.ready(). + """ + from django.db import models as dj_models + + for field in instance._meta.local_concrete_fields: + if isinstance(field, dj_models.CharField) and field.max_length: + value = getattr(instance, field.attname, None) + if isinstance(value, str) and len(value) > field.max_length: + setattr(instance, field.attname, value[: field.max_length]) + + +# --- migration helpers ------------------------------------------------------ + + +def migration_table_exists(connection, table_name: str) -> bool: + """Portable existence check usable from inside migrations.""" + return table_name in connection.introspection.table_names() + + +def migration_table_columns(connection, table_name: str) -> set[str]: + """Portable column-name introspection usable from inside migrations.""" + with connection.cursor() as cursor: + return {col.name for col in connection.introspection.get_table_description(cursor, table_name)} + + +def rebuild_models_from_migration_state(apps, schema_editor, app_label: str, model_names: list[str]) -> None: + """(non-sqlite only) Drop and recreate the given models' tables from the + current migration state. + + ArchiveBox's historical sqlite migrations rebuild tables with raw SQL that + intentionally diverges from Django migration state (state-only AddFields + reconciled by later sqlite rebuilds). Postgres support postdates all of + them, so a non-sqlite database can never contain legacy data at these + points in history: every affected table is empty, and dropping + recreating + it from state is always equivalent, keeping the real schema in lockstep + with migration state at each divergence point. No-op on sqlite. + """ + if schema_editor.connection.vendor == SQLITE_VENDOR: + return + existing_tables = set(schema_editor.connection.introspection.table_names()) + models = [apps.get_model(app_label, model_name) for model_name in model_names] + for model in models: + if model._meta.db_table in existing_tables: + schema_editor.delete_model(model) + for model in models: + schema_editor.create_model(model) + + def run_db_analyze_batch( remaining: list[str] | None, *, @@ -357,7 +619,7 @@ def migration_state(out_dir: Path = CONSTANTS.DATA_DIR) -> tuple[list[str], list try: cursor.execute("SELECT app, name FROM django_migrations") except Exception as err: - if "no such table" in str(err).lower(): + if is_missing_table_error(err): return set() raise return {(str(app), str(name)) for app, name in cursor.fetchall()} diff --git a/archivebox/misc/logging_util.py b/archivebox/misc/logging_util.py index a14ac3b5..06170d9e 100644 --- a/archivebox/misc/logging_util.py +++ b/archivebox/misc/logging_util.py @@ -410,7 +410,8 @@ def printable_folder_status(name: str, folder: dict) -> str: else: color, symbol, note, num_files = "grey53", "-", "unused", "-" - if folder["path"]: + if folder["path"] and "://" not in str(folder["path"]): + # file-count probing only makes sense for filesystem paths, not DSNs if os.access(folder["path"], os.R_OK): try: num_files = ( diff --git a/archivebox/personas/migrations/0004_hydrate_persona_permissions.py b/archivebox/personas/migrations/0004_hydrate_persona_permissions.py index 7b7bd22e..ec4a0cd8 100644 --- a/archivebox/personas/migrations/0004_hydrate_persona_permissions.py +++ b/archivebox/personas/migrations/0004_hydrate_persona_permissions.py @@ -92,6 +92,12 @@ def _ensure_permissions_column(cursor): def hydrate_persona_permissions(apps, schema_editor): + # sqlite-only legacy hydration + generated-column repair (PRAGMA + # table_xinfo / VIRTUAL generated column). On postgres the ``permissions`` + # column is created portably by 0003 (AddField GeneratedField) and there is + # never legacy data to hydrate, so this is a no-op there. + if schema_editor.connection.vendor != "sqlite": + return Persona = apps.get_model("personas", "Persona") base_config = raw_base_config(apps) default_permissions = resolve_permissions(base_config, "public") diff --git a/archivebox/search/query.py b/archivebox/search/query.py index 2e01537d..2142faa4 100644 --- a/archivebox/search/query.py +++ b/archivebox/search/query.py @@ -16,31 +16,38 @@ MAX_SEARCH_RANK_IDS = 500 def escape_like_query(query: str) -> str: - """Escape a string for SQLite LIKE matching.""" + """Escape a string for SQL LIKE matching (used with ESCAPE '\\').""" return query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") def crawl_config_values_search_wave(query: str) -> Q | None: """Build a Snapshot Q predicate matching values inside Crawl.config.""" - if connection.vendor != "sqlite": - return None - from archivebox.crawls.models import Crawl pattern = f"%{escape_like_query(query).lower()}%" - matching_crawls = Crawl.objects.extra( - where=[ - """ - EXISTS ( - SELECT 1 - FROM json_tree(config) - WHERE json_tree.atom IS NOT NULL - AND LOWER(CAST(json_tree.atom AS TEXT)) LIKE %s ESCAPE '\\' - ) - """, - ], - params=[pattern], - ) + if connection.vendor == "sqlite": + matching_crawls = Crawl.objects.extra( + where=[ + """ + EXISTS ( + SELECT 1 + FROM json_tree(config) + WHERE json_tree.atom IS NOT NULL + AND LOWER(CAST(json_tree.atom AS TEXT)) LIKE %s ESCAPE '\\' + ) + """, + ], + params=[pattern], + ) + elif connection.vendor == "postgresql": + # jsonb has no json_tree() equivalent; matching the serialized jsonb text + # covers every nested value (keys can also match, which only widens recall). + matching_crawls = Crawl.objects.extra( + where=["LOWER(config::text) LIKE %s ESCAPE '\\'"], + params=[pattern], + ) + else: + return None return Q(crawl_id__in=matching_crawls.values("pk")) diff --git a/archivebox/search/views.py b/archivebox/search/views.py index 8d1f2799..4bbd9220 100644 --- a/archivebox/search/views.py +++ b/archivebox/search/views.py @@ -124,19 +124,32 @@ def iter_url_prefix_search_ids(prefix: str, queryset): table = connection.ops.quote_name(model._meta.db_table) pk_column = connection.ops.quote_name(model._meta.pk.column) url_column = connection.ops.quote_name(model._meta.get_field("url").column) - upper_bound = url_prefix_upper_bound(prefix) raw_ids = [] + if connection.vendor == "sqlite": + # Bytewise range comparison uses the plain url btree index directly. + where_clause = f"{url_column} >= %s AND {url_column} < %s" + where_params = [prefix, url_prefix_upper_bound(prefix)] + else: + # Range comparisons are collation-dependent on postgres (linguistic + # collations don't compare bytewise), so use LIKE with escaped + # wildcards instead — correct under any collation and able to use the + # url pattern-ops index. + from archivebox.search.query import escape_like_query + + where_clause = f"{url_column} LIKE %s ESCAPE '\\'" + where_params = [f"{escape_like_query(prefix)}%"] + with connection.cursor() as cursor: cursor.execute( f""" SELECT {pk_column} FROM {table} - WHERE {url_column} >= %s AND {url_column} < %s + WHERE {where_clause} ORDER BY {url_column} LIMIT %s """, - [prefix, upper_bound, URL_PREFIX_SEARCH_LIMIT], + [*where_params, URL_PREFIX_SEARCH_LIMIT], ) raw_ids = [str(row[0]).replace("-", "") for row in cursor.fetchall()] diff --git a/archivebox/tests/test_postgres_backend.py b/archivebox/tests/test_postgres_backend.py new file mode 100644 index 00000000..29adccd9 --- /dev/null +++ b/archivebox/tests/test_postgres_backend.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +End-to-end tests for the PostgreSQL database backend (DATABASE_ENGINE=postgres). + +Spins up a real throwaway PostgreSQL cluster (initdb + pg_ctl) for the module, +then exercises real archivebox CLI flows against it: init, status, re-init, +add --index-only, list, remove, and a full schema-vs-models parity check. + +Requires PostgreSQL server binaries (initdb/pg_ctl) to be installed. +""" + +import os +import shlex +import shutil +import socket +import subprocess +import tempfile +import uuid +from pathlib import Path + +import pytest + +from .conftest import cli_env, run_archivebox_cmd, run_queued_crawls + + +def _find_pg_bindir() -> Path: + """Locate PostgreSQL server binaries (initdb) on this machine.""" + initdb_on_path = shutil.which("initdb") + if initdb_on_path: + return Path(initdb_on_path).resolve().parent + candidates = [] + for base in (Path("/usr/lib/postgresql"), Path("/opt/homebrew/opt"), Path("/usr/local/opt"), Path("/opt/homebrew/Cellar/postgresql")): + if base.is_dir(): + for sub in sorted(base.iterdir(), reverse=True): + initdb = sub / "bin" / "initdb" + if initdb.is_file(): + candidates.append(initdb.parent) + assert candidates, "PostgreSQL server binaries (initdb) are required for test_postgres_backend tests" + return candidates[0] + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +PG_TEST_USER = "abxtest" + + +@pytest.fixture(scope="module") +def pg_server(): + """A real throwaway PostgreSQL server for the whole test module.""" + bindir = _find_pg_bindir() + workdir = Path(tempfile.mkdtemp(prefix="abx-pg-test-")) + datadir = workdir / "data" + logfile = workdir / "server.log" + port = _free_port() + + # postgres refuses to run as root; when the suite runs as root (e.g. in a + # dev container), run the server as the system postgres user instead. + run_as_postgres_user = os.geteuid() == 0 + if run_as_postgres_user: + workdir.chmod(0o755) + shutil.chown(workdir, user="postgres") + + def pg_cmd(args: list[str]) -> subprocess.CompletedProcess: + if run_as_postgres_user: + full = ["su", "postgres", "-s", "/bin/sh", "-c", shlex.join(args)] + else: + full = args + return subprocess.run(full, capture_output=True, text=True, check=False, timeout=120) + + result = pg_cmd([str(bindir / "initdb"), "-D", str(datadir), "-E", "UTF8", "-A", "trust", "-U", PG_TEST_USER]) + assert result.returncode == 0, f"initdb failed: {result.stderr}" + + server_opts = f"-p {port} -k {workdir} -c listen_addresses=127.0.0.1 -c fsync=off -c synchronous_commit=off" + result = pg_cmd([str(bindir / "pg_ctl"), "-D", str(datadir), "-l", str(logfile), "-o", server_opts, "-w", "start"]) + assert result.returncode == 0, f"pg_ctl start failed: {result.stderr}\n{logfile.read_text() if logfile.exists() else ''}" + + try: + yield {"host": "127.0.0.1", "port": port, "user": PG_TEST_USER} + finally: + pg_cmd([str(bindir / "pg_ctl"), "-D", str(datadir), "-m", "immediate", "stop"]) + shutil.rmtree(workdir, ignore_errors=True) + + +def pg_cli_env(pg_server: dict, dbname: str, **extra) -> dict: + env = cli_env(**extra) + env.update( + { + "ARCHIVEBOX_DATABASE_ENGINE": "postgres", + "ARCHIVEBOX_DATABASE_HOST": pg_server["host"], + "ARCHIVEBOX_DATABASE_PORT": str(pg_server["port"]), + "ARCHIVEBOX_DATABASE_USER": pg_server["user"], + "ARCHIVEBOX_DATABASE_NAME": dbname, + }, + ) + return env + + +def unique_dbname() -> str: + return f"abx_test_{uuid.uuid4().hex[:12]}" + + +def pg_query(pg_server: dict, dbname: str, query: str) -> list[tuple]: + import psycopg + + with psycopg.connect(host=pg_server["host"], port=pg_server["port"], user=pg_server["user"], dbname=dbname) as conn: + return conn.execute(query).fetchall() + + +def test_init_creates_postgres_schema(pg_server, tmp_path): + """Fresh init against postgres should create the database + full schema, and no sqlite file.""" + dbname = unique_dbname() + env = pg_cli_env(pg_server, dbname) + + result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, env=env, timeout=300) + assert result.returncode == 0, f"Init failed: {result.stderr}\n{result.stdout}" + + assert not (tmp_path / "index.sqlite3").exists(), "sqlite file should not be created when using postgres" + assert (tmp_path / "archive").is_dir(), "Archive dir not created" + + (applied_migrations,) = pg_query(pg_server, dbname, "SELECT COUNT(*) FROM django_migrations")[0] + assert applied_migrations > 50, f"Expected all migrations applied, got {applied_migrations}" + + for table in ("core_snapshot", "core_archiveresult", "core_tag", "crawls_crawl", "machine_machine", "api_apitoken", "personas_persona"): + (regclass,) = pg_query(pg_server, dbname, f"SELECT to_regclass('{table}')")[0] + assert regclass == table, f"Table {table} missing from postgres schema" + + +def test_postgres_schema_matches_models(pg_server, tmp_path): + """Every model column must exist in postgres and vice versa (no state/schema drift).""" + dbname = unique_dbname() + env = pg_cli_env(pg_server, dbname) + + result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, env=env, timeout=300) + assert result.returncode == 0, f"Init failed: {result.stderr}" + + parity_script = ( + "from django.apps import apps\n" + "from django.db import connection\n" + "problems = []\n" + "with connection.cursor() as cursor:\n" + " tables = set(connection.introspection.table_names(cursor))\n" + " for model in apps.get_models(include_auto_created=True):\n" + " meta = model._meta\n" + " if not meta.managed or meta.proxy:\n" + " continue\n" + " if meta.db_table not in tables:\n" + " problems.append(f'missing table {meta.db_table}')\n" + " continue\n" + " db_cols = {col.name for col in connection.introspection.get_table_description(cursor, meta.db_table)}\n" + " model_cols = {field.column for field in meta.local_concrete_fields}\n" + " for col in sorted(model_cols - db_cols):\n" + " problems.append(f'{meta.db_table}: missing column {col}')\n" + " for col in sorted(db_cols - model_cols):\n" + " problems.append(f'{meta.db_table}: extra column {col}')\n" + "print('SCHEMA_PROBLEMS=' + repr(sorted(problems)))\n" + ) + result = run_archivebox_cmd(["manage", "shell", "-c", parity_script], cwd=tmp_path, env=env, timeout=120) + assert result.returncode == 0, f"manage shell failed: {result.stderr}" + assert "SCHEMA_PROBLEMS=[]" in result.stdout, f"Postgres schema diverges from models:\n{result.stdout}\n{result.stderr}" + + result = run_archivebox_cmd(["manage", "makemigrations", "--check", "--dry-run"], cwd=tmp_path, env=env, timeout=120) + assert result.returncode == 0, f"Model state does not match migrations: {result.stdout}\n{result.stderr}" + + +def test_status_and_reinit_on_postgres(pg_server, tmp_path): + """status works against postgres, and a second init takes the 'verify existing' path.""" + dbname = unique_dbname() + env = pg_cli_env(pg_server, dbname) + + result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, env=env, timeout=300) + assert result.returncode == 0, f"Init failed: {result.stderr}" + assert "Initializing a new ArchiveBox" in result.stdout + + result = run_archivebox_cmd(["status"], cwd=tmp_path, env=env, timeout=120) + assert result.returncode == 0, f"Status failed: {result.stderr}" + assert f"postgresql://{PG_TEST_USER}@" in result.stdout, f"status should show the postgres DSN:\n{result.stdout}" + + result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, env=env, timeout=300) + assert result.returncode == 0, f"Re-init failed: {result.stderr}" + assert "Verifying and updating existing ArchiveBox collection" in result.stdout + + +def test_add_list_remove_on_postgres(pg_server, tmp_path): + """Real add/list/remove CLI flows store and retrieve rows from postgres.""" + dbname = unique_dbname() + env = pg_cli_env(pg_server, dbname, disable_extractors=True) + test_url = "https://example.com/abx-postgres-test" + + result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, env=env, timeout=300) + assert result.returncode == 0, f"Init failed: {result.stderr}" + + result = run_archivebox_cmd(["add", "--index-only", test_url], cwd=tmp_path, env=env, timeout=300) + assert result.returncode == 0, f"Add failed: {result.stderr}\n{result.stdout}" + run_queued_crawls(tmp_path, env) + + (snapshot_count,) = pg_query(pg_server, dbname, "SELECT COUNT(*) FROM core_snapshot")[0] + assert snapshot_count >= 1, "Snapshot row not written to postgres" + (crawl_count,) = pg_query(pg_server, dbname, "SELECT COUNT(*) FROM crawls_crawl")[0] + assert crawl_count >= 1, "Crawl row not written to postgres" + + result = run_archivebox_cmd(["list"], cwd=tmp_path, env=env, timeout=120) + assert result.returncode == 0, f"List failed: {result.stderr}" + assert "example.com/abx-postgres-test" in result.stdout, f"Added URL missing from list output:\n{result.stdout}" + + result = run_archivebox_cmd(["search", "abx-postgres-test"], cwd=tmp_path, env=env, timeout=120) + assert result.returncode == 0, f"Search failed: {result.stderr}" + assert "example.com/abx-postgres-test" in result.stdout, f"Added URL missing from search output:\n{result.stdout}" + + result = run_archivebox_cmd( + ["remove", "--yes", "--delete", "--filter-type=exact", test_url], + cwd=tmp_path, + env=env, + timeout=120, + ) + assert result.returncode == 0, f"Remove failed: {result.stderr}\n{result.stdout}" + (snapshot_count,) = pg_query(pg_server, dbname, f"SELECT COUNT(*) FROM core_snapshot WHERE url = '{test_url}'")[0] + assert snapshot_count == 0, "Snapshot row should be deleted from postgres" + + +def test_sqlite_remains_the_default(tmp_path): + """Without DATABASE_ENGINE config, init keeps using the sqlite file backend.""" + result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, env=cli_env(), timeout=300) + assert result.returncode == 0, f"Init failed: {result.stderr}" + assert (tmp_path / "index.sqlite3").exists(), "sqlite file should be created by default" diff --git a/bin/benchmark_db_backends.py b/bin/benchmark_db_backends.py new file mode 100644 index 00000000..5a344f21 --- /dev/null +++ b/bin/benchmark_db_backends.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Benchmark ArchiveBox hot-path index queries at large row counts. + +Run from inside an initialized (empty) collection directory, with the same +ARCHIVEBOX_DATABASE_* env vars the collection was initialized with: + + cd /path/to/collection + uv run --project /path/to/ArchiveBox python /path/to/ArchiveBox/bin/benchmark_db_backends.py --rows 1000000 + +Seeds N snapshots (+1 archiveresult each) via bulk_create, runs ANALYZE, then +times the hot queries used by the admin UI, snapshot detail views, URL prefix +search, and the worker queue/claim paths. Works on both sqlite and postgres — +use it to compare backends or to catch performance regressions. +""" + +import argparse +import json +import os +import statistics +import sys +import time +from datetime import datetime, timedelta, timezone + + +def seed(rows: int, batch_size: int = 20_000) -> None: + from django.db import transaction + + from archivebox.base_models.models import get_or_create_system_user_pk + from archivebox.core.models import ArchiveResult, Snapshot + from archivebox.crawls.models import Crawl + from archivebox.uuid_compat import uuid7 + + user_pk = get_or_create_system_user_pk() + crawl = Crawl.objects.create(urls="https://example.com/", created_by_id=user_pk, status="sealed", label="benchmark seed") + + existing = Snapshot.objects.count() + if existing >= rows: + print(f"already seeded ({existing} snapshots)", flush=True) + return + + base_time = datetime(2020, 1, 1, tzinfo=timezone.utc) + started = time.monotonic() + for start in range(existing, rows, batch_size): + end = min(start + batch_size, rows) + snapshots = [] + for i in range(start, end): + created = base_time + timedelta(seconds=i) + snapshots.append( + Snapshot( + id=uuid7(), + url=f"https://site{i % 1000}.example.org/path/{i}" + ("#section" if i % 7 == 0 else ""), + timestamp=f"{1577836800 + i}.{i}", + title=f"Benchmark page {i}", + crawl=crawl, + bookmarked_at=created, + created_at=created, + modified_at=created, + downloaded_at=created if i % 10 else None, + status="sealed" if i % 20 else "queued", + retry_at=None if i % 20 else created, + fs_version="0.9.0", + config={}, + depth=0, + ), + ) + with transaction.atomic(): + Snapshot.objects.bulk_create(snapshots, batch_size=batch_size) + ArchiveResult.objects.bulk_create( + [ + ArchiveResult( + id=uuid7(), + snapshot=snapshot, + plugin="wget", + hook_name="on_Snapshot__06_wget.py", + status="succeeded", + created_at=snapshot.created_at, + modified_at=snapshot.created_at, + start_ts=snapshot.created_at, + end_ts=snapshot.created_at, + output_str="benchmark", + output_files={}, + ) + for snapshot in snapshots + ], + batch_size=batch_size, + ) + if (end // batch_size) % 5 == 0 or end == rows: + rate = (end - existing) / max(time.monotonic() - started, 0.001) + print(f" seeded {end}/{rows} snapshots ({rate:,.0f} rows/s)", flush=True) + + +def analyze_tables() -> None: + from django.db import connection + + with connection.cursor() as cursor: + for table in ("core_snapshot", "core_archiveresult"): + cursor.execute(f"ANALYZE {table}") + + +def timed(func, repeat: int = 5) -> tuple[float, object]: + times = [] + result = None + for _ in range(repeat): + started = time.perf_counter() + result = func() + times.append((time.perf_counter() - started) * 1000) + return statistics.median(times), result + + +def run_benchmarks(rows: int) -> dict[str, float]: + from django.db import connection + from django.db.models import Count, Q + from django.utils import timezone as dj_timezone + + from archivebox.core.models import ArchiveResult, Snapshot + from archivebox.misc.db import approximate_row_counts + from archivebox.search.views import iter_url_prefix_search_ids + + now = dj_timezone.now() + results: dict[str, float] = {} + target_i = rows // 2 + target_url = f"https://site{target_i % 1000}.example.org/path/{target_i}" + + def fragmentless_q(url: str) -> Q: + if connection.vendor == "sqlite": + return Q(url=url) | (Q(url__gte=f"{url}#") & Q(url__lt=f"{url}#\U0010ffff")) + return Q(url=url) | Q(url__startswith=f"{url}#") + + benchmarks = { + "exact_count": lambda: Snapshot.objects.count(), + "approximate_row_counts": lambda: approximate_row_counts(connection), + "admin_list_page": lambda: list(Snapshot.objects.order_by("-bookmarked_at").values("id", "url", "title", "status", "bookmarked_at")[:40]), + "admin_list_page_offset_10k": lambda: list(Snapshot.objects.order_by("-bookmarked_at").values("id", "url", "title")[10_000:10_040]), + "snapshot_detail_by_url": lambda: list(Snapshot.objects.filter(fragmentless_q(target_url))[:10]), + "snapshot_detail_archiveresults": lambda: list( + ArchiveResult.objects.filter(snapshot__url=target_url).order_by("start_ts").values("id", "plugin", "status")[:100], + ), + "url_prefix_search": lambda: list(iter_url_prefix_search_ids("https://site500.example.org/", Snapshot.objects.all())), + "worker_queue_scan": lambda: list( + Snapshot.objects.filter(status="queued", retry_at__lte=now).order_by("retry_at", "created_at").values_list("id", flat=True)[:100], + ), + "status_facet_counts": lambda: dict(Snapshot.objects.values_list("status").annotate(n=Count("id")).values_list("status", "n")), + "tag_join_filter": lambda: list(Snapshot.objects.filter(title__icontains="page 4242").values("id")[:20]), + } + + for name, func in benchmarks.items(): + median_ms, _ = timed(func) + results[name] = round(median_ms, 2) + print(f" {name:35s} {median_ms:10.2f} ms", flush=True) + + def claim_one() -> int: + snapshot = Snapshot.objects.filter(status="queued").order_by("retry_at").first() + if snapshot is None: + return 0 + return Snapshot.objects.filter(pk=snapshot.pk, retry_at=snapshot.retry_at).update(retry_at=now + timedelta(seconds=60), modified_at=now) + + median_ms, _ = timed(claim_one) + results["worker_cas_claim"] = round(median_ms, 2) + print(f" {'worker_cas_claim':35s} {median_ms:10.2f} ms", flush=True) + return results + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=1_000_000) + parser.add_argument("--seed-only", action="store_true") + parser.add_argument("--json", dest="json_path", default=None, help="also write results to this JSON file") + args = parser.parse_args() + + from archivebox.config.django import setup_django + from archivebox.misc.db import database_exists + + setup_django() + assert database_exists(), "run archivebox init in this directory first" + from django.db import connection + + print(f"backend: {connection.vendor}, target rows: {args.rows}", flush=True) + print("seeding...", flush=True) + seed(args.rows) + if args.seed_only: + return + print("running ANALYZE...", flush=True) + analyze_tables() + print("benchmarks (median of 5):", flush=True) + results = run_benchmarks(args.rows) + results["_backend"] = connection.vendor + results["_rows"] = args.rows + if args.json_path: + with open(args.json_path, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + + +if __name__ == "__main__": + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + main() diff --git a/docs/Configuration.md b/docs/Configuration.md index 5ed4dca6..5a996bba 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -666,9 +666,9 @@ Skip the startup check that verifies [`TMP_DIR`](#tmp_dir) can host unix-domain ## Database Settings -*Options for tuning the SQLite index database that backs ArchiveBox's snapshot, tag, and crawl metadata.* +*Options for choosing and tuning the index database that backs ArchiveBox's snapshot, tag, and crawl metadata.* -ArchiveBox stores all of its index metadata in a single SQLite database file (`index.sqlite3` inside your data directory). The defaults are tuned for nearly all users — the knobs below mostly govern **lock-contention behavior**, which matters when multiple workers touch the database concurrently (e.g. supervised orchestrators, parallel `archivebox add` runs, container restarts that race against an in-flight write, or long-running web/admin processes alongside CLI commands). +ArchiveBox stores all of its index metadata in a single SQLite database file (`index.sqlite3` inside your data directory) by default, or optionally in a PostgreSQL database (see [`DATABASE_ENGINE`](#database_engine)). The defaults are tuned for nearly all users — the knobs below mostly govern **lock-contention behavior**, which matters when multiple workers touch the database concurrently (e.g. supervised orchestrators, parallel `archivebox add` runs, container restarts that race against an in-flight write, or long-running web/admin processes alongside CLI commands). > [!NOTE] > These are advanced operator tuning options. If you are not actively diagnosing `database is locked` errors or planning a non-default storage layout, you can safely leave everything in this section at its default. @@ -678,6 +678,49 @@ ArchiveBox stores all of its index metadata in a single SQLite database file (`i - https://www.sqlite.org/wal.html - https://www.sqlite.org/pragma.html +--- + + +#### `DATABASE_ENGINE` +**Possible Values:** [`sqlite`]/`postgres` +Which database backend to use for the main index. Settable as `ARCHIVEBOX_DATABASE_ENGINE` or under `[DATABASE_CONFIG]` in `ArchiveBox.conf`. + +The default `sqlite` keeps everything in a single `index.sqlite3` file inside the data directory and requires no external services. Set this to `postgres` to store the index in a PostgreSQL database instead — useful for large collections with many concurrent writers, or when the data directory lives on a filesystem where SQLite performs poorly (e.g. network mounts). + +With `postgres`, connection details come from the options below. `archivebox init` will create the configured database automatically if the server is reachable and the database does not exist yet. Only the *index* moves to PostgreSQL — snapshot output files stay in `./archive/` in the data directory, and plugin-owned sidecar databases (e.g. the `search.sqlite3` full-text index) are unaffected. + +```ini +# example ArchiveBox.conf +[DATABASE_CONFIG] +DATABASE_ENGINE = postgres +DATABASE_NAME = archivebox +DATABASE_HOST = 127.0.0.1 +DATABASE_PORT = 5432 +DATABASE_USER = archivebox +DATABASE_PASSWORD = s3cret +``` + +> [!WARNING] +> Pick a backend when you first run `archivebox init` and stick with it. There is no built-in tool (yet) to move an existing collection's index between SQLite and PostgreSQL. + +*Related options:* +[`DATABASE_NAME`](#database_name), [`DATABASE_HOST`](#database_host) + +--- + + + + + + + + +#### `DATABASE_HOST` / `DATABASE_PORT` / `DATABASE_USER` / `DATABASE_PASSWORD` +**Possible Values:** [`127.0.0.1`] / [`5432`] / [`archivebox`] / [empty] +PostgreSQL connection settings, used only when [`DATABASE_ENGINE`](#database_engine)`=postgres`. Settable as `ARCHIVEBOX_DATABASE_HOST`, `ARCHIVEBOX_DATABASE_PORT`, `ARCHIVEBOX_DATABASE_USER`, and `ARCHIVEBOX_DATABASE_PASSWORD`. + +`DATABASE_HOST` may also be a path to a directory containing a PostgreSQL unix socket (e.g. `/var/run/postgresql`). When `DATABASE_ENGINE=postgres`, [`DATABASE_NAME`](#database_name) is the name of the PostgreSQL database (default: `archivebox`) instead of a file path. + --- diff --git a/pyproject.toml b/pyproject.toml index bcca18a7..3dd706da 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ ### Django libraries "setuptools>=74.1.0", # for: django 5 on python >=3.12, distutils is no longer in stdlib but django 5.1 expects distutils (TODO: check if this can be removed eventually) "django>=6.0", + "psycopg[binary]>=3.2", # for: PostgreSQL database backend (ARCHIVEBOX_DATABASE_ENGINE=postgres) "daphne>=4.2.1", # ASGI server for Django (no channels needed - websockets not used) "django-ninja>=1.5.1", "django-extensions>=3.2.3", diff --git a/uv.lock b/uv.lock index 59a779b9..cf8849a3 100644 --- a/uv.lock +++ b/uv.lock @@ -13,11 +13,11 @@ supported-markers = [ ] [options] -exclude-newer = "2026-07-18T13:51:28.491653Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P5D" [options.exclude-newer-package] -abxbus = { timestamp = "2026-07-23T13:51:27.491669Z", span = "PT1S" } +abxbus = { timestamp = "0001-01-01T00:00:00Z", span = "PT1S" } abx-plugins = "2100-01-01T00:00:00Z" abx-dl = "2100-01-01T00:00:00Z" abxpkg = "2100-01-01T00:00:00Z" @@ -27,17 +27,17 @@ name = "abx-dl" version = "1.11.268" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "abxpkg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jambo", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "abx-plugins" }, + { name = "abxbus" }, + { name = "abxpkg" }, + { name = "jambo" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "requests" }, + { name = "rich" }, + { name = "rich-click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ea/b3/ba83c367c76c1fa82e8e94f468f093d092f52f13a31c468a56436f388442/abx_dl-1.11.268.tar.gz", hash = "sha256:e8cb9ce008f2d0dfdc36adbd54c692caf505b78f5ab8510fe77eda7c4a44699b", size = 86729, upload-time = "2026-07-23T12:12:52.806Z" } wheels = [ @@ -49,13 +49,13 @@ name = "abx-plugins" version = "1.11.311" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "abxpkg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "imagesize", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jambo", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "abxbus" }, + { name = "abxpkg" }, + { name = "httpx" }, + { name = "imagesize" }, + { name = "jambo" }, + { name = "rich-click" }, + { name = "uv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/f8/ad69bed9751ccbdc892ac3fd6a63b9eace0bd7b0bbb254b855fd8351bc58/abx_plugins-1.11.311.tar.gz", hash = "sha256:acf3ad909f63f610b8981cf9796ff758d713585a380dd9404f42c8419f1ef020", size = 266481, upload-time = "2026-07-23T11:32:48.583Z" } wheels = [ @@ -67,9 +67,9 @@ name = "abxbus" version = "2.5.40" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "uuid7", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "uuid7" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/c4/bf7415d0e158cc76860264b3e66a409872541544059f0833e057ae4dcfaf/abxbus-2.5.40.tar.gz", hash = "sha256:f9660128149f9079d18cba1ddbd0daa1941246ac0d19bbfff05491ba417e6447", size = 133584, upload-time = "2026-07-22T03:15:20.732Z" } wheels = [ @@ -81,11 +81,11 @@ name = "abxpkg" version = "1.11.288" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pip", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pip" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "rich-click" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8d/e0/f98aab6246c6d328ee289ced80e95befd46d8046abbbfc317ccf696582b1/abxpkg-1.11.288.tar.gz", hash = "sha256:d802c34ee655cc17930572b07a4ed9f62c18022fe98f380b38fb043dc7ba26b1", size = 212909, upload-time = "2026-07-23T03:30:24.528Z" } wheels = [ @@ -115,7 +115,7 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -127,98 +127,99 @@ name = "archivebox" version = "0.9.35rc138" source = { editable = "." } dependencies = [ - { name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "abxpkg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "atomicwrites", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "base32-crockford", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "bleach", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "croniter", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "daphne", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "dateparser", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-admin-data-views", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-ninja", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-object-actions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-signal-webhooks", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-stubs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ipython", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "py-machineid", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-statemachine", extra = ["diagrams"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sonic-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "supervisor", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "toml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "tzdata", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "uuid7", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux')" }, - { name = "w3lib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "abx-dl" }, + { name = "abx-plugins" }, + { name = "abxbus" }, + { name = "abxpkg" }, + { name = "atomicwrites" }, + { name = "base32-crockford" }, + { name = "bleach" }, + { name = "click" }, + { name = "croniter" }, + { name = "daphne" }, + { name = "dateparser" }, + { name = "django" }, + { name = "django-admin-data-views" }, + { name = "django-extensions" }, + { name = "django-ninja" }, + { name = "django-object-actions" }, + { name = "django-signal-webhooks" }, + { name = "django-stubs" }, + { name = "ipython" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, + { name = "py-machineid" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-statemachine", extra = ["diagrams"] }, + { name = "requests" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "setuptools" }, + { name = "sonic-client" }, + { name = "supervisor" }, + { name = "toml" }, + { name = "tzdata" }, + { name = "uuid7", marker = "python_full_version < '3.14'" }, + { name = "w3lib" }, ] [package.optional-dependencies] all = [ - { name = "django-auth-ldap", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-autotyping", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-debug-toolbar", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "djdt-flamegraph", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ipdb", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-ldap", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests-tracker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django-auth-ldap" }, + { name = "django-autotyping" }, + { name = "django-debug-toolbar" }, + { name = "djdt-flamegraph" }, + { name = "ipdb" }, + { name = "python-ldap" }, + { name = "requests-tracker" }, ] debug = [ - { name = "django-autotyping", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-debug-toolbar", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "djdt-flamegraph", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ipdb", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests-tracker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django-autotyping" }, + { name = "django-debug-toolbar" }, + { name = "djdt-flamegraph" }, + { name = "ipdb" }, + { name = "requests-tracker" }, ] ldap = [ - { name = "django-auth-ldap", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-ldap", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django-auth-ldap" }, + { name = "python-ldap" }, ] [package.dev-dependencies] dev = [ - { name = "bottle", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "bumpver", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "coverage", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-debug-toolbar", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "djdt-flamegraph", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ipdb", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "linkify-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "logfire", extra = ["django"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "myst-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation-sqlite3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-codeblocks", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-github-actions-annotate-failures", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-httpserver", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-sugar", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "recommonmark", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests-tracker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx-autodoc2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx-rtd-theme", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-mermaid", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ty", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "viztracer", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "bottle" }, + { name = "bumpver" }, + { name = "coverage" }, + { name = "django-debug-toolbar" }, + { name = "djdt-flamegraph" }, + { name = "ipdb" }, + { name = "linkify-it-py" }, + { name = "logfire", extra = ["django"] }, + { name = "myst-parser" }, + { name = "opentelemetry-instrumentation-django" }, + { name = "opentelemetry-instrumentation-sqlite3" }, + { name = "prek" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-codeblocks" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, + { name = "pytest-github-actions-annotate-failures" }, + { name = "pytest-httpserver" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout" }, + { name = "recommonmark" }, + { name = "requests-tracker" }, + { name = "ruff" }, + { name = "sphinx" }, + { name = "sphinx-autodoc2" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-mermaid" }, + { name = "ty" }, + { name = "uv" }, + { name = "viztracer" }, ] [package.metadata] @@ -250,6 +251,7 @@ requires-dist = [ { name = "ipython", specifier = ">=8.27.0" }, { name = "platformdirs", specifier = ">=4.3.6" }, { name = "psutil", specifier = ">=6.0.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "py-machineid", specifier = ">=0.6.0" }, { name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, @@ -351,14 +353,14 @@ name = "autobahn" version = "26.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cbor2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "hyperlink", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "msgpack", marker = "(platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_python_implementation == 'CPython' and sys_platform == 'linux')" }, - { name = "txaio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "u-msgpack-python", marker = "(platform_python_implementation != 'CPython' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux')" }, - { name = "ujson", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cbor2" }, + { name = "cffi" }, + { name = "cryptography" }, + { name = "hyperlink" }, + { name = "msgpack", marker = "platform_python_implementation == 'CPython'" }, + { name = "txaio" }, + { name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" }, + { name = "ujson" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/73/f109f563c27e048e45d135d81af19e6ca391e24905550b06bd1c9d674c57/autobahn-26.7.1.tar.gz", hash = "sha256:c6949a2c6eb95fb1c218837dbda0a59abbbebafb8b11098551c01a7061dfd245", size = 14056542, upload-time = "2026-07-15T19:14:01.246Z" } wheels = [ @@ -406,7 +408,7 @@ name = "bleach" version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "webencodings", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "webencodings" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ @@ -427,10 +429,10 @@ name = "bumpver" version = "2026.1132" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "lexid", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "toml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "click" }, + { name = "colorama" }, + { name = "lexid" }, + { name = "toml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/22/c0c9bf9e7ad6877e4c7c3dedd464e390d9fadb8927c61d6d77547eb17539/bumpver-2026.1132.tar.gz", hash = "sha256:80b223c23fca9bc9dd569b7a44680949d34bee23a738860d9a9b36f1abe3b0e0", size = 116784, upload-time = "2026-05-22T18:40:32.333Z" } wheels = [ @@ -474,7 +476,7 @@ name = "cffi" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux')" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ @@ -652,7 +654,7 @@ name = "croniter" version = "6.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } wheels = [ @@ -664,7 +666,7 @@ name = "cryptography" version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -711,9 +713,9 @@ name = "daphne" version = "4.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "autobahn", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "twisted", extra = ["tls"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "asgiref" }, + { name = "autobahn" }, + { name = "twisted", extra = ["tls"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/d3/65ff32c01cc64d44441b038dbb7cfb0c6a5507a1c937b3d41bd99af7bdc4/daphne-4.2.2.tar.gz", hash = "sha256:6c3527d4ce32630ae054dfb0ef5578e9a35d2f39f0ebcd02ef4f9129a121ce8d", size = 47601, upload-time = "2026-06-03T10:53:13.31Z" } wheels = [ @@ -725,10 +727,10 @@ name = "dateparser" version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d3/f4/561c49bca97af561d34eed27e3e831135eb5cb88e754c1150be41820f5c6/dateparser-1.4.1.tar.gz", hash = "sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c", size = 314734, upload-time = "2026-06-15T08:45:47.659Z" } wheels = [ @@ -749,8 +751,8 @@ name = "django" version = "6.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sqlparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "asgiref" }, + { name = "sqlparse" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -762,8 +764,8 @@ name = "django-admin-data-views" version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-settings-holder", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "django-settings-holder" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/3f/2377a4088c0bf7ce677bb0a526cbf898a11e5528941d7cda303efef3bd73/django_admin_data_views-0.4.3.tar.gz", hash = "sha256:bd287a5d874febd8b544f83b47d0846fbf7b3e00a7f6633912630053c7ae4298", size = 12519, upload-time = "2024-11-24T14:18:00.406Z" } wheels = [ @@ -775,8 +777,8 @@ name = "django-auth-ldap" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-ldap", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "python-ldap" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/6d/d3ceb4b49e7153811a4b2d92bbe198a5ef2e2820469add3d6dc129ef2fab/django_auth_ldap-5.3.0.tar.gz", hash = "sha256:743d8107b146240b46f7e97207dc06cb11facc0cd70dce490b7ca09dd5643d19", size = 55272, upload-time = "2025-12-26T15:00:14.272Z" } wheels = [ @@ -788,8 +790,8 @@ name = "django-autotyping" version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "libcst", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "libcst" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/d4/65d2b1c54f35116bd2d31d1064c523ded729353633389ecfc283a93b4c47/django_autotyping-0.5.1.tar.gz", hash = "sha256:b48c57d3d358a608109dd47698e64466e596983e8729bff130669dd744588c25", size = 78974, upload-time = "2024-05-29T14:48:28.561Z" } wheels = [ @@ -801,8 +803,8 @@ name = "django-debug-toolbar" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sqlparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "sqlparse" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1e/ac/3ac674f99c64bfbcd9ae3d7f5f3577f23ea52884e5ac36842e1f024dce03/django_debug_toolbar-7.0.0.tar.gz", hash = "sha256:ef7494c5b459c149e87cc2da88d86e944064945e86bd7b2d879093586b5fefbf", size = 359560, upload-time = "2026-06-19T00:21:23.346Z" } wheels = [ @@ -814,7 +816,7 @@ name = "django-extensions" version = "4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" } wheels = [ @@ -826,8 +828,8 @@ name = "django-ninja" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" } wheels = [ @@ -848,7 +850,7 @@ name = "django-settings-holder" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/810c15a408987262bf70fc1892d4d5c3b2c1ae62e8715b73592c8f46ed37/django_settings_holder-0.3.0.tar.gz", hash = "sha256:d41eb6d6023d61c08e395f2406fd6f047d1edff2f0346d04323f3681f12372ef", size = 8580, upload-time = "2025-04-27T13:15:42.069Z" } wheels = [ @@ -860,11 +862,11 @@ name = "django-signal-webhooks" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-settings-holder", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "asgiref" }, + { name = "cryptography" }, + { name = "django" }, + { name = "django-settings-holder" }, + { name = "httpx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/15/865e72e1da78bc6c6865ff16b0dffb11db62999fc91bed8c3c1668eac4c1/django_signal_webhooks-0.3.1.tar.gz", hash = "sha256:23dc439be2fdea24b746726495eb1a7a59440809056482eebceb153d050a3f5b", size = 17806, upload-time = "2024-10-31T23:34:40.37Z" } wheels = [ @@ -876,10 +878,10 @@ name = "django-stubs" version = "6.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "django-stubs-ext", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/90/087c6e424e705e05182e543ef6b366a59eb5c92ab008b0dbbba55f357a40/django_stubs-6.0.7.tar.gz", hash = "sha256:bc55431c0af745a64e39cf33a8d36c87dccbedeae2fe26fab47dd355270e8538", size = 282293, upload-time = "2026-07-14T10:08:27.122Z" } wheels = [ @@ -891,8 +893,8 @@ name = "django-stubs-ext" version = "6.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/50/917f7224ea470e89cdcdc93d3dfe75b8391adf976cf12f2ecdb5f5d122be/django_stubs_ext-6.0.7.tar.gz", hash = "sha256:c3172c5126614fd2a44d0196b313b44c21f717cb09477ba52b447d41f4ce613e", size = 6665, upload-time = "2026-07-14T10:07:56.933Z" } wheels = [ @@ -931,8 +933,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "dnspython" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -953,7 +955,7 @@ name = "googleapis-common-protos" version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ @@ -974,8 +976,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -987,10 +989,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "httpcore", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -1002,7 +1004,7 @@ name = "hyperlink" version = "21.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } wheels = [ @@ -1032,7 +1034,7 @@ name = "incremental" version = "24.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } wheels = [ @@ -1053,8 +1055,8 @@ name = "ipdb" version = "0.13.13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "decorator", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ipython", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "decorator" }, + { name = "ipython" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042, upload-time = "2023-03-09T15:40:57.487Z" } wheels = [ @@ -1066,16 +1068,16 @@ name = "ipython" version = "9.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "decorator", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "ipython-pygments-lexers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jedi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "matplotlib-inline", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pexpect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "prompt-toolkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "stack-data", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "traitlets", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect" }, + { name = "prompt-toolkit" }, + { name = "psutil" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ @@ -1087,7 +1089,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -1099,9 +1101,9 @@ name = "jambo" version = "0.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "email-validator", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "email-validator" }, + { name = "jsonschema" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/f5/74de157c7aece6a070f99f18201a0e2f46cdfd0f9e337efd411745ed9b22/jambo-0.1.7.tar.gz", hash = "sha256:df89ab8209ebdf7a6e92252ec925979cd3d32811bf4a8182a97dc35b7df58f74", size = 137822, upload-time = "2026-01-14T19:17:30.302Z" } @@ -1110,7 +1112,7 @@ name = "jedi" version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "parso", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "parso" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ @@ -1122,7 +1124,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -1134,10 +1136,10 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jsonschema-specifications", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1149,7 +1151,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -1170,8 +1172,8 @@ name = "libcst" version = "1.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml", marker = "(python_full_version >= '3.14' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform == 'linux')" }, - { name = "pyyaml-ft", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux')" }, + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml-ft", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } wheels = [ @@ -1206,7 +1208,7 @@ name = "linkify-it-py" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "uc-micro-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "uc-micro-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } wheels = [ @@ -1218,13 +1220,13 @@ name = "logfire" version = "4.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "executing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/f2/34b8ebbd6bbd82c71055d6b881b24d8ada79a0e6692d3dd8cca5e86fadb3/logfire-4.37.0.tar.gz", hash = "sha256:7ee0cb64b59c356a41a1701fb84597037f8db1fa15df7a3715ef363e5a1de06a", size = 1212176, upload-time = "2026-06-12T20:47:06.904Z" } wheels = [ @@ -1233,8 +1235,8 @@ wheels = [ [package.optional-dependencies] django = [ - { name = "opentelemetry-instrumentation-asgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-instrumentation-django" }, ] [[package]] @@ -1242,7 +1244,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -1294,7 +1296,7 @@ name = "matplotlib-inline" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "traitlets", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "traitlets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ @@ -1306,7 +1308,7 @@ name = "mdit-py-plugins" version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "markdown-it-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } wheels = [ @@ -1359,12 +1361,12 @@ name = "myst-parser" version = "5.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "mdit-py-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } wheels = [ @@ -1394,7 +1396,7 @@ name = "opentelemetry-api" version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ @@ -1406,7 +1408,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-proto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } wheels = [ @@ -1418,13 +1420,13 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } wheels = [ @@ -1436,10 +1438,10 @@ name = "opentelemetry-instrumentation" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } wheels = [ @@ -1451,11 +1453,11 @@ name = "opentelemetry-instrumentation-asgi" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a0/b5/7ea3a9fd1b80e89786c14250bfaecf32a753c3fd08232690f4da8dc16e29/opentelemetry_instrumentation_asgi-0.63b1.tar.gz", hash = "sha256:267b422416d768f3c7f4054883b41d9c3a7c943d86d20032b738c99a3dbb5862", size = 26151, upload-time = "2026-05-21T16:36:18.368Z" } wheels = [ @@ -1467,10 +1469,10 @@ name = "opentelemetry-instrumentation-dbapi" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/bf/2bb8048a3ba5686bb70e5d3d7dd2aa2b3d838ccd324e88557780f86b7635/opentelemetry_instrumentation_dbapi-0.63b1.tar.gz", hash = "sha256:406978ed56bcfc5fd246fd918e6b36d0f5de26fa396c78cf63326a7b530597c8", size = 19323, upload-time = "2026-05-21T16:36:27.036Z" } wheels = [ @@ -1482,11 +1484,11 @@ name = "opentelemetry-instrumentation-django" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/37/90fd5f0dc4a2042dfdb609a9dbe42fde26b3690f9c33ece529b024251015/opentelemetry_instrumentation_django-0.63b1.tar.gz", hash = "sha256:f2071d2f92e4779c5a14dd452b0dfe426343599e6efa9d888304fb639a9f3101", size = 25565, upload-time = "2026-05-21T16:36:27.727Z" } wheels = [ @@ -1498,9 +1500,9 @@ name = "opentelemetry-instrumentation-sqlite3" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation-dbapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-dbapi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/9c/b741eb1d9c6c74680913d0a4fd233c518e8f3f69a200a176121db795ccca/opentelemetry_instrumentation_sqlite3-0.63b1.tar.gz", hash = "sha256:3d61afda8358dc32135fabd27e5934bd25ea0eed68d64c34508f24ba8d723efc", size = 8420, upload-time = "2026-05-21T16:36:47.889Z" } wheels = [ @@ -1512,10 +1514,10 @@ name = "opentelemetry-instrumentation-wsgi" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/55/832f287fb153adc25c05bc2594d00ac4d1dbeca8b19388b2666d5154c912/opentelemetry_instrumentation_wsgi-0.63b1.tar.gz", hash = "sha256:03d61c4678ce82402e7f37b6a3dbd84cb97b85b3cb416a78c2e74c7c6d9451fa", size = 19667, upload-time = "2026-05-21T16:36:53.873Z" } wheels = [ @@ -1527,7 +1529,7 @@ name = "opentelemetry-proto" version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } wheels = [ @@ -1539,9 +1541,9 @@ name = "opentelemetry-sdk" version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } wheels = [ @@ -1553,8 +1555,8 @@ name = "opentelemetry-semantic-conventions" version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } wheels = [ @@ -1593,7 +1595,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1653,7 +1655,7 @@ name = "prompt-toolkit" version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ @@ -1695,6 +1697,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1736,7 +1779,7 @@ name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyasn1" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ @@ -1757,10 +1800,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pydantic-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -1772,7 +1815,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -1819,9 +1862,9 @@ name = "pydantic-settings" version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ @@ -1833,7 +1876,7 @@ name = "pydot" version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyparsing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } wheels = [ @@ -1854,7 +1897,7 @@ name = "pyopenssl" version = "26.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cryptography" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/b7/da07bae88f5a9506b4def6f2f4903cf4c3b8831e560dba8fa18ca08f758f/pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341", size = 182024, upload-time = "2026-06-12T20:28:07.458Z" } wheels = [ @@ -1875,8 +1918,8 @@ name = "pyright" version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "nodeenv" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ @@ -1888,10 +1931,10 @@ name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -1903,7 +1946,7 @@ name = "pytest-codeblocks" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/3a/cb/5f40df7db75c0ac2555009287bb97b322f8d79f9f2bfdfb0bdb560834c28/pytest_codeblocks-0.18.0-py3-none-any.whl", hash = "sha256:3fe944dc505107421204c83e9232e0155eea1279b9425a10bee327079b272efb", size = 8009, upload-time = "2026-06-15T20:48:02.608Z" }, @@ -1914,9 +1957,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -1928,7 +1971,7 @@ name = "pytest-django" version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } wheels = [ @@ -1940,7 +1983,7 @@ name = "pytest-github-actions-annotate-failures" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/a0/bdb91581b03c41016c78e16b8ec36c34d8508206fcb30f1951c9cdff2e97/pytest_github_actions_annotate_failures-0.4.2.tar.gz", hash = "sha256:5dd18304512361788bc7b5c5c805db853f03f4950c6be09b088de6bab8e2e6c9", size = 12158, upload-time = "2026-06-19T15:59:17.445Z" } wheels = [ @@ -1952,7 +1995,7 @@ name = "pytest-httpserver" version = "1.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/17/ad187f46998814014f7cda309de700b87c0eb4b2e111e18bc8c819be7116/pytest_httpserver-1.1.5.tar.gz", hash = "sha256:dc3d82e1fe00e491829d8939c549bf4bd9b39a260f87113c619b9d517c2f8ff1", size = 70974, upload-time = "2026-02-14T13:27:23.412Z" } wheels = [ @@ -1964,8 +2007,8 @@ name = "pytest-sugar" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest" }, + { name = "termcolor" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } wheels = [ @@ -1977,7 +2020,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ @@ -1989,7 +2032,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -2010,8 +2053,8 @@ name = "python-ldap" version = "3.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/f4/60edeb794bbc9ed0ff2149bbaeec605f3ed331766459d195832ecbd0ba2d/python_ldap-3.4.7.tar.gz", hash = "sha256:bacd9fb680d20263d8570ade1cf234d90d281149a8beb4f079dd8f33f7613dc8", size = 387477, upload-time = "2026-05-20T13:41:04.358Z" } @@ -2026,7 +2069,7 @@ wheels = [ [package.optional-dependencies] diagrams = [ - { name = "pydot", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydot" }, ] [[package]] @@ -2094,9 +2137,9 @@ name = "recommonmark" version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "commonmark", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "commonmark" }, + { name = "docutils" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/00/3dd2bdc4184b0ce754b5b446325abf45c2e0a347e022292ddc44670f628c/recommonmark-0.7.1.tar.gz", hash = "sha256:bdb4db649f2222dcd8d2d844f0006b958d627f732415d399791ee436a3686d67", size = 34444, upload-time = "2020-12-17T19:24:56.523Z" } wheels = [ @@ -2108,8 +2151,8 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "attrs" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -2181,10 +2224,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "charset-normalizer", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -2196,8 +2239,8 @@ name = "requests-tracker" version = "0.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sqlparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "django" }, + { name = "sqlparse" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/10/35d214c4eaa479251ebb6f774842e476cd4162ca939e72bb1d943131fb2c/requests_tracker-0.3.3.tar.gz", hash = "sha256:eb288d69ebcae49149b41d603960d101d7eb892627e3455a456fa1f9441d2a49", size = 49168, upload-time = "2023-11-04T01:24:11.992Z" } wheels = [ @@ -2209,8 +2252,8 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -2222,8 +2265,8 @@ name = "rich-click" version = "1.9.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "click" }, + { name = "rich" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" } wheels = [ @@ -2334,8 +2377,8 @@ name = "service-identity" version = "26.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "attrs" }, + { name = "cryptography" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/87/ad52e2c582c0f0e7f0a1b86950494c38d67422dc0f5ed9044a5fb9569a49/service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b", size = 42898, upload-time = "2026-05-30T12:04:55.184Z" } wheels = [ @@ -2383,22 +2426,22 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "alabaster", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "imagesize", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "roman-numerals", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "snowballstemmer", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-applehelp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-devhelp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-htmlhelp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-jsmath", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-qthelp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-serializinghtml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ @@ -2410,8 +2453,8 @@ name = "sphinx-autodoc2" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "astroid", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "astroid" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/17/5f/5350046d1aa1a56b063ae08b9ad871025335c9d55fe2372896ea48711da9/sphinx_autodoc2-0.5.0.tar.gz", hash = "sha256:7d76044aa81d6af74447080182b6868c7eb066874edc835e8ddf810735b6565a", size = 115077, upload-time = "2023-11-27T07:27:51.407Z" } wheels = [ @@ -2423,9 +2466,9 @@ name = "sphinx-rtd-theme" version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinxcontrib-jquery", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } wheels = [ @@ -2464,7 +2507,7 @@ name = "sphinxcontrib-jquery" version = "4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } wheels = [ @@ -2485,9 +2528,9 @@ name = "sphinxcontrib-mermaid" version = "2.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "sphinx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/29/54cf1f7e03630ca4859caba25bc192698954d7c630377c62d1e264715c37/sphinxcontrib_mermaid-2.0.3.tar.gz", hash = "sha256:a6865ef6b65b225c5403a3170de63a04a07227cada11a4a71a6b87b4f9ed185a", size = 20764, upload-time = "2026-07-08T00:30:44.216Z" } wheels = [ @@ -2526,9 +2569,9 @@ name = "stack-data" version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asttokens", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "executing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pure-eval", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ @@ -2576,13 +2619,13 @@ name = "twisted" version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "automat", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "constantly", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "hyperlink", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "incremental", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "zope-interface", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "attrs" }, + { name = "automat" }, + { name = "constantly" }, + { name = "hyperlink" }, + { name = "incremental" }, + { name = "typing-extensions" }, + { name = "zope-interface" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/97/6e9beb1e78247ae6dc34114f27d538cf2cb183c4afcd3609dfdf2b0439c8/twisted-26.4.0.tar.gz", hash = "sha256:dbfd0fe1ee409d0243fdd7a6a6ff14f4948cec1fd78e0376291f805e1501fae9", size = 3575095, upload-time = "2026-05-11T11:24:51.861Z" } wheels = [ @@ -2591,9 +2634,9 @@ wheels = [ [package.optional-dependencies] tls = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "pyopenssl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "service-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "idna" }, + { name = "pyopenssl" }, + { name = "service-identity" }, ] [[package]] @@ -2650,7 +2693,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -2777,7 +2820,7 @@ name = "viztracer" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "objprint", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "objprint" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9c/ab/94ae463cd4e386f143e3520a274856c4f2b4858d7ae30aa223ae25e9a2e5/viztracer-1.1.1.tar.gz", hash = "sha256:dcd4b5ddcc3a40ee79a584406d984cb4d40bc3301a6c9015d8949d4445fe9346", size = 15667892, upload-time = "2025-11-11T00:03:17.751Z" } wheels = [ @@ -2831,7 +2874,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [