From 6e9b26aac6e3abc88a2bb5e3ecb7005fb80c550e Mon Sep 17 00:00:00 2001 From: Jordan Evans Date: Wed, 1 Apr 2026 15:42:34 -0700 Subject: [PATCH 1/3] docs: add project onboarding guide and reindexing scripts LFXV2-1371 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jordan Evans --- docs/onboarding-new-project.md | 62 +++ scripts/reindexing/README.md | 25 ++ scripts/reindexing/audit_opensearch.py | 334 ++++++++++++++ scripts/reindexing/pyproject.toml | 10 + scripts/reindexing/reindex.py | 465 ++++++++++++++++++++ scripts/reindexing/reindex_committees.py | 268 +++++++++++ scripts/reindexing/reindex_groupsio.py | 262 +++++++++++ scripts/reindexing/reindex_past_meetings.py | 271 ++++++++++++ scripts/reindexing/reindex_votes.py | 218 +++++++++ scripts/reindexing/requirements.txt | 7 + scripts/reindexing/uv.lock | 8 + 11 files changed, 1930 insertions(+) create mode 100644 docs/onboarding-new-project.md create mode 100644 scripts/reindexing/README.md create mode 100644 scripts/reindexing/audit_opensearch.py create mode 100644 scripts/reindexing/pyproject.toml create mode 100644 scripts/reindexing/reindex.py create mode 100644 scripts/reindexing/reindex_committees.py create mode 100644 scripts/reindexing/reindex_groupsio.py create mode 100644 scripts/reindexing/reindex_past_meetings.py create mode 100644 scripts/reindexing/reindex_votes.py create mode 100644 scripts/reindexing/requirements.txt create mode 100644 scripts/reindexing/uv.lock diff --git a/docs/onboarding-new-project.md b/docs/onboarding-new-project.md new file mode 100644 index 0000000..d80d20c --- /dev/null +++ b/docs/onboarding-new-project.md @@ -0,0 +1,62 @@ +# Onboarding a New Project + +## 1. Add to Allow List + +Update `cmd/lfx-v1-sync-helper/config.go`: + +- **Single project only:** Add the project slug to `projectAllowlist`. +- **Project + all children:** Add the project slug to `projectFamilyAllowlist`. + +## 2. Replay the Project Entry + +The project entry has likely already been processed and skipped. To replay it, update the `v1-objects` NATS KV bucket with the same value it currently has: + +```sh +# Get the current value +nats kv get v1-objects "salesforce-project__c." > + +# Put it back (triggers reprocessing) +nats kv put v1-objects "salesforce-project__c." < +# Remove temporary file +rm +``` + +> **Note:** Deleting the WAL listener consumer also works but is not recommended. + +## 3. Verify Mappings + +Confirm both forward and reverse mappings exist in the `v1-mappings` bucket: + +```sh +nats kv get v1-mappings "project.uid." +nats kv get v1-mappings "project.sfid." +``` + +- `project.sfid.` should contain the project UID. +- `project.uid.` should contain the SFID. + +If either mapping is missing, create it: + +```sh +nats kv create v1-mappings "project.sfid." "" +nats kv create v1-mappings "project.uid." "" +``` + +## 4. Reindex Committees + +Committees must be indexed **before** other resources (meetings, mailing lists, etc.) since those depend on them. + +- **Order matters:** parent committees must exist before subcommittees. +- Run the reindex multiple times if needed until all committees (and their members) are added. + +## 5. Reindex Remaining Resources + +Once committees and committee members are indexed, proceed with: + +- Meetings +- Votes +- Surveys +- Mailing lists +- Any other resources + +See [`scripts/reindexing/`](../scripts/reindexing/) for reindexing scripts. diff --git a/scripts/reindexing/README.md b/scripts/reindexing/README.md new file mode 100644 index 0000000..bdc4949 --- /dev/null +++ b/scripts/reindexing/README.md @@ -0,0 +1,25 @@ +# Reindexing Scripts + +Scripts for reindexing v1 project resources into the v2 sync pipeline via DynamoDB and NATS KV. + +## Prerequisites + +- Python 3.12+ +- AWS credentials with DynamoDB read access +- NATS server with JetStream enabled and access to the `v1-objects` KV bucket + +## Setup + +```sh +uv sync +``` + +Or with pip: + +```sh +pip install -r requirements.txt +``` + +## Scripts + +Detailed documentation for each script is forthcoming. See the individual script files for usage and `--help` flags. diff --git a/scripts/reindexing/audit_opensearch.py b/scripts/reindexing/audit_opensearch.py new file mode 100644 index 0000000..8bde8a7 --- /dev/null +++ b/scripts/reindexing/audit_opensearch.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +""" +OpenSearch / NATS KV Audit + +For each object type, fetches all documents from OpenSearch and checks whether +a corresponding entry exists in the NATS KV bucket. Reports documents that are +in OpenSearch but missing from NATS KV. + +Object types checked: + - committee → KV bucket: committees (key: ) + - committee_member → KV bucket: committee-members (key: ) + +By default runs in dry-run mode. Pass --delete to remove stale OpenSearch +documents that have no corresponding NATS KV entry. + +Required env vars: + OPENSEARCH_URL — e.g. https://opensearch.example.com:9200 + Credentials via --os-user / --os-password + +Optional env vars: + OPENSEARCH_USER + OPENSEARCH_PASSWORD +""" + +import argparse +import asyncio +import os +import sys +from dataclasses import dataclass, field +from functools import partial +from typing import List, Optional, Tuple +from urllib.parse import urlparse + +from nats.aio.client import Client as NATS +from opensearchpy import NotFoundError, OpenSearch + + +SCROLL_SIZE = 500 +SCROLL_TTL = "2m" + +OBJECT_TYPES = [ + ("committee", "committees"), + ("committee_member", "committee-members"), +] + + +@dataclass +class Stats: + total: int = 0 + in_nats: int = 0 + missing_in_nats: int = 0 + deleted: int = 0 + errors: int = 0 + missing_ids: List[str] = field(default_factory=list) + + +def _parse_opensearch_url( + url: str, user: Optional[str], password: Optional[str] +) -> Tuple[str, Optional[str], Optional[str]]: + """Return (host_url, user, password) — creds from URL take precedence.""" + parsed = urlparse(url) + if parsed.username: + user = parsed.username + if parsed.password: + password = parsed.password + # Rebuild without embedded creds + host = f"{parsed.scheme}://{parsed.hostname}" + if parsed.port: + host = f"{host}:{parsed.port}" + if parsed.path and parsed.path != "/": + host = f"{host}{parsed.path}" + return host, user, password + + +class OpenSearchNATSAuditor: + def __init__( + self, + opensearch_url: str, + os_user: Optional[str], + os_password: Optional[str], + os_index: str, + nats_url: str, + dry_run: bool = True, + ): + host, user, password = _parse_opensearch_url( + opensearch_url, os_user, os_password + ) + + auth = (user, password) if user and password else None + self.os = OpenSearch( + hosts=[host], + http_auth=auth, + use_ssl=host.startswith("https"), + verify_certs=True, + ) + self.os_index = os_index + self.nats_url = nats_url + self.dry_run = dry_run + self.nc = None + self.js = None + + async def connect_nats(self): + self.nc = NATS() + await self.nc.connect(servers=[self.nats_url]) + self.js = self.nc.jetstream() + print(f"Connected to NATS at {self.nats_url}") + + async def _os(self, fn, *args, **kwargs): + """Run a sync OpenSearch call in a thread so it doesn't block the event loop.""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, partial(fn, *args, **kwargs)) + + async def disconnect(self): + self.os.close() + if self.nc: + await self.nc.close() + + async def _get_kv(self, bucket: str): + assert self.js is not None, "NATS JetStream not connected" + return await self.js.key_value(bucket=bucket) + + async def scroll_opensearch(self, object_type: str) -> List[dict]: + """Return all documents of the given object_type from OpenSearch.""" + query = {"query": {"term": {"object_type": object_type}}} + docs = [] + + resp = await self._os( + self.os.search, + index=self.os_index, + body=query, + scroll=SCROLL_TTL, + size=SCROLL_SIZE, + _source=["id", "object_type"], + ) + scroll_id = resp.get("_scroll_id") + + while True: + hits = resp["hits"]["hits"] + if not hits: + break + docs.extend(hits) + if len(hits) < SCROLL_SIZE: + break + resp = await self._os( + self.os.scroll, scroll_id=scroll_id, scroll=SCROLL_TTL + ) + scroll_id = resp.get("_scroll_id") + + if scroll_id: + try: + await self._os(self.os.clear_scroll, scroll_id=scroll_id) + except Exception: + pass + + return docs + + async def audit_type(self, object_type: str, kv_bucket: str) -> Stats: + s = Stats() + print(f"\n--- {object_type} → KV bucket: {kv_bucket} ---") + + print( + f" Fetching all '{object_type}' documents from " + f"OpenSearch index '{self.os_index}'..." + ) + docs = await self.scroll_opensearch(object_type) + s.total = len(docs) + print(f" Found {s.total} document(s) in OpenSearch") + + if s.total == 0: + return s + + kv = await self._get_kv(kv_bucket) + + for doc in docs: + doc_id = doc["_id"] # OpenSearch document _id + # Also check the `id` field in _source for the uuid + source_id = doc.get("_source", {}).get("object_id") or doc_id + + # Strip to bare UUID (last segment if namespaced) + uuid = source_id.split(":")[-1] if ":" in source_id else source_id + + try: + entry = await kv.get(uuid) + if entry is None: + raise NotFoundError(404, "key not found", {}) + s.in_nats += 1 + except Exception: + s.missing_in_nats += 1 + s.missing_ids.append(doc_id) + + if self.dry_run: + print(f" [DRY RUN] would delete: {doc_id} (uuid={uuid})") + else: + try: + await self._os(self.os.delete, index=self.os_index, id=doc_id) + s.deleted += 1 + print(f" deleted: {doc_id} (uuid={uuid})") + except Exception as e: + s.errors += 1 + print(f" ERROR deleting {doc_id}: {e}") + + return s + + async def run(self): + print(f"\n{'=' * 60}") + print("OpenSearch / NATS KV Audit") + print(f"Index : {self.os_index}") + print(f"Mode : {'DRY RUN' if self.dry_run else 'DELETE'}") + print(f"{'=' * 60}") + + await self.connect_nats() + + all_stats: List[Tuple[str, str, Stats]] = [] + try: + for object_type, kv_bucket in OBJECT_TYPES: + stats = await self.audit_type(object_type, kv_bucket) + all_stats.append((object_type, kv_bucket, stats)) + finally: + await self.disconnect() + + self._print_summary(all_stats) + + def _print_summary(self, all_stats: List[Tuple[str, str, Stats]]): + print(f"\n{'=' * 60}") + print("SUMMARY REPORT") + print(f"{'=' * 60}\n") + + grand_total = grand_in_nats = grand_missing = grand_deleted = grand_errors = 0 + + for object_type, kv_bucket, s in all_stats: + grand_total += s.total + grand_in_nats += s.in_nats + grand_missing += s.missing_in_nats + grand_deleted += s.deleted + grand_errors += s.errors + + status = "OK" if s.missing_in_nats == 0 and s.errors == 0 else "ISSUES" + print(f"{object_type} (bucket: {kv_bucket}):") + print(f" Total in OpenSearch : {s.total}") + print(f" Present in NATS KV : {s.in_nats}") + print(f" Missing in NATS KV : {s.missing_in_nats}") + if not self.dry_run: + print(f" Deleted from OpenSearch: {s.deleted}") + if s.errors: + print(f" Errors : {s.errors}") + print(f" Status : {status}") + if s.missing_ids: + shown = s.missing_ids[:10] + print(f" Missing IDs : {', '.join(shown)}") + if len(s.missing_ids) > 10: + print(f" ... and {len(s.missing_ids) - 10} more") + print() + + print(f"{'=' * 60}") + print("TOTALS:") + print(f" Total in OpenSearch : {grand_total}") + print(f" Present in NATS KV : {grand_in_nats}") + print(f" Missing in NATS KV : {grand_missing}") + if not self.dry_run: + print(f" Deleted from OpenSearch: {grand_deleted}") + if grand_errors: + print(f" Errors : {grand_errors}") + overall = ( + "ALL OK" + if grand_missing == 0 and grand_errors == 0 + else "STALE DOCUMENTS FOUND" + ) + print(f"\nOverall Status: {overall}") + print(f"{'=' * 60}\n") + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Audit OpenSearch against NATS KV — report (and optionally delete) " + "documents in OpenSearch with no NATS KV entry." + ) + ) + parser.add_argument( + "--opensearch-url", + default=os.environ.get("OPENSEARCH_URL", "http://localhost:9200"), + help="OpenSearch URL (default: $OPENSEARCH_URL or http://localhost:9200)", + ) + parser.add_argument( + "--os-user", + default=os.environ.get("OPENSEARCH_USER"), + help="OpenSearch username (default: $OPENSEARCH_USER)", + ) + parser.add_argument( + "--os-password", + default=os.environ.get("OPENSEARCH_PASSWORD"), + help="OpenSearch password (default: $OPENSEARCH_PASSWORD)", + ) + parser.add_argument( + "--os-index", + default=os.environ.get("OPENSEARCH_INDEX", "resources"), + help="OpenSearch index to query (default: $OPENSEARCH_INDEX or resources)", + ) + parser.add_argument( + "--nats-url", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete stale OpenSearch documents. " + "Without this flag, runs in dry-run mode.", + ) + args = parser.parse_args() + + auditor = OpenSearchNATSAuditor( + opensearch_url=args.opensearch_url, + os_user=args.os_user, + os_password=args.os_password, + os_index=args.os_index, + nats_url=args.nats_url, + dry_run=not args.delete, + ) + + try: + asyncio.run(auditor.run()) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/reindexing/pyproject.toml b/scripts/reindexing/pyproject.toml new file mode 100644 index 0000000..a60352c --- /dev/null +++ b/scripts/reindexing/pyproject.toml @@ -0,0 +1,10 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +[project] +name = "project-reindexer" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] diff --git a/scripts/reindexing/reindex.py b/scripts/reindexing/reindex.py new file mode 100644 index 0000000..47e23a2 --- /dev/null +++ b/scripts/reindexing/reindex.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +""" +DynamoDB to NATS KV Reindexer + +This script checks DynamoDB tables for a given project_id and verifies that +corresponding entries exist in the NATS KV bucket. It can operate in two modes: +- Dry-run: Report which entries are missing from NATS KV +- Reindex: Trigger reindexing by updating the NATS KV entry with its current value +""" + +import argparse +import asyncio +import sys +from typing import Dict, List, Set, Optional +from dataclasses import dataclass + +import boto3 +from boto3.dynamodb.conditions import Key +from nats.aio.client import Client as NATS + + +# Table configuration +@dataclass +class TableConfig: + name: str + primary_key: str + project_id_field: str + project_id_index: str + + +@dataclass +class SecondaryTableConfig: + name: str + primary_key: str + parent_table: str + parent_key_field: str + parent_key_index: Optional[str] = ( + None # None if parent_key_field is the primary key + ) + + +# Primary tables with project_id indices +PRIMARY_TABLES = [ + TableConfig( + name="itx-poll", + primary_key="poll_id", + project_id_field="project_id", + project_id_index="project_id_index", + ), + TableConfig( + name="itx-zoom-meetings-v2", + primary_key="meeting_id", + project_id_field="proj_id", + project_id_index="proj_id_index", + ), + TableConfig( + name="itx-zoom-past-meetings", + primary_key="meeting_and_occurrence_id", + project_id_field="proj_id", + project_id_index="proj_id_index", + ), +] + +# Secondary tables with their relationships +SECONDARY_TABLES = [ + SecondaryTableConfig( + name="itx-poll-vote", + primary_key="vote_id", + parent_table="itx-poll", + parent_key_field="poll_id", + parent_key_index="poll_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-meetings-mappings-v2", + primary_key="id", + parent_table="itx-zoom-meetings-v2", + parent_key_field="meeting_id", + parent_key_index="meeting_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-meetings-registrants-v2", + primary_key="registrant_id", + parent_table="itx-zoom-meetings-v2", + parent_key_field="meeting_id", + parent_key_index="meeting_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-meetings-invite-responses-v2", + primary_key="id", + parent_table="itx-zoom-meetings-v2", + parent_key_field="meeting_id", + parent_key_index="meeting_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-meetings-attachments-v2", + primary_key="id", + parent_table="itx-zoom-meetings-v2", + parent_key_field="meeting_id", + parent_key_index="meeting_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-past-meetings-mappings", + primary_key="id", + parent_table="itx-zoom-past-meetings", + parent_key_field="meeting_and_occurrence_id", + parent_key_index="meeting_and_occurrence_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-past-meetings-attendees", + primary_key="id", + parent_table="itx-zoom-past-meetings", + parent_key_field="meeting_and_occurrence_id", + parent_key_index="meeting_and_occurrence_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-past-meetings-invitees", + primary_key="invitee_id", + parent_table="itx-zoom-past-meetings", + parent_key_field="meeting_and_occurrence_id", + parent_key_index="meeting_and_occurrence_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-past-meetings-recordings", + primary_key="meeting_and_occurrence_id", + parent_table="itx-zoom-past-meetings", + parent_key_field="meeting_and_occurrence_id", + parent_key_index=None, # Primary key, not an index + ), + SecondaryTableConfig( + name="itx-zoom-past-meetings-summaries", + primary_key="id", + parent_table="itx-zoom-past-meetings", + parent_key_field="meeting_and_occurrence_id", + parent_key_index="meeting_and_occurrence_id_index", + ), + SecondaryTableConfig( + name="itx-zoom-past-meetings-attachments", + primary_key="id", + parent_table="itx-zoom-past-meetings", + parent_key_field="meeting_and_occurrence_id", + parent_key_index="meeting_and_occurrence_id_index", + ), +] + + +class ReindexStats: + def __init__(self): + self.tables: Dict[str, Dict] = {} + + def init_table(self, table_name: str): + if table_name not in self.tables: + self.tables[table_name] = { + "total": 0, + "missing": 0, + "reindexed": 0, + "errors": 0, + "missing_keys": [], + } + + def add_total(self, table_name: str, count: int = 1): + self.init_table(table_name) + self.tables[table_name]["total"] += count + + def add_missing(self, table_name: str, key: str): + self.init_table(table_name) + self.tables[table_name]["missing"] += 1 + self.tables[table_name]["missing_keys"].append(key) + + def add_reindexed(self, table_name: str): + self.init_table(table_name) + self.tables[table_name]["reindexed"] += 1 + + def add_error(self, table_name: str): + self.init_table(table_name) + self.tables[table_name]["errors"] += 1 + + +class DynamoDBNATSReindexer: + def __init__(self, nats_url: str, dry_run: bool = True): + self.nats_url = nats_url + self.dry_run = dry_run + self.dynamodb = boto3.resource("dynamodb") + self.nc = None + self.js = None + self.kv = None + self.stats = ReindexStats() + + async def connect_nats(self): + """Connect to NATS and get KV bucket""" + self.nc = NATS() + await self.nc.connect(servers=[self.nats_url]) + self.js = self.nc.jetstream() + self.kv = await self.js.key_value(bucket="v1-objects") + print(f"✓ Connected to NATS at {self.nats_url}") + + async def disconnect_nats(self): + """Disconnect from NATS""" + if self.nc: + await self.nc.close() + + def query_primary_table(self, config: TableConfig, project_id: str) -> List[Dict]: + """Query a primary table for all entries with the given project_id""" + table = self.dynamodb.Table(config.name) + items = [] + + print(f"Querying {config.name} for project_id={project_id}...") + + response = table.query( + IndexName=config.project_id_index, + KeyConditionExpression=Key(config.project_id_field).eq(project_id), + ) + items.extend(response.get("Items", [])) + + # Handle pagination + while "LastEvaluatedKey" in response: + response = table.query( + IndexName=config.project_id_index, + KeyConditionExpression=Key(config.project_id_field).eq(project_id), + ExclusiveStartKey=response["LastEvaluatedKey"], + ) + items.extend(response.get("Items", [])) + + print(f" Found {len(items)} items in {config.name}") + return items + + def query_secondary_table( + self, config: SecondaryTableConfig, parent_keys: Set[str] + ) -> List[Dict]: + """Query a secondary table for all entries matching parent keys""" + if not parent_keys: + return [] + + table = self.dynamodb.Table(config.name) + items = [] + + print(f"Querying {config.name} for {len(parent_keys)} parent keys...") + + # If parent_key_index is None, the parent_key_field is the primary key + # Use batch_get_item instead of query + if config.parent_key_index is None: + parent_keys_list = list(parent_keys) + # Batch get items in chunks of 100 (DynamoDB limit) + for i in range(0, len(parent_keys_list), 100): + batch = parent_keys_list[i:i + 100] + keys = [{config.primary_key: pk} for pk in batch] + + response = self.dynamodb.batch_get_item( + RequestItems={config.name: {"Keys": keys}} + ) + items.extend(response.get("Responses", {}).get(config.name, [])) + + # Handle unprocessed keys + while response.get("UnprocessedKeys"): + response = self.dynamodb.batch_get_item( + RequestItems=response["UnprocessedKeys"] + ) + items.extend(response.get("Responses", {}).get(config.name, [])) + else: + # Query for each parent key using the index + for parent_key in parent_keys: + response = table.query( + IndexName=config.parent_key_index, + KeyConditionExpression=Key(config.parent_key_field).eq(parent_key), + ) + items.extend(response.get("Items", [])) + + # Handle pagination + while "LastEvaluatedKey" in response: + response = table.query( + IndexName=config.parent_key_index, + KeyConditionExpression=Key(config.parent_key_field).eq( + parent_key + ), + ExclusiveStartKey=response["LastEvaluatedKey"], + ) + items.extend(response.get("Items", [])) + + print(f" Found {len(items)} items in {config.name}") + return items + + async def check_and_reindex_entry( + self, table_name: str, primary_key_value: str + ) -> bool: + """Check if entry exists in NATS KV and reindex if needed""" + kv_key = f"{table_name}.{primary_key_value}" + self.stats.add_total(table_name) + + try: + # Try to get the entry from KV bucket + assert self.kv is not None, "NATS KV not connected" + entry = await self.kv.get(kv_key) + + if entry is None: + print(f" ✗ Missing: {kv_key}") + self.stats.add_missing(table_name, primary_key_value) + return False + + # Entry exists - trigger reindex if not in dry-run mode + if not self.dry_run: + # Update with the same value to trigger reindex + await self.kv.put(kv_key, entry.value) + self.stats.add_reindexed(table_name) + print(f" ✓ Reindexed: {kv_key}") + + return True + + except Exception as e: + print(f" ✗ Error checking {kv_key}: {e}") + self.stats.add_error(table_name) + return False + + async def process_primary_table( + self, config: TableConfig, project_id: str + ) -> Set[str]: + """Process a primary table and return the set of primary keys""" + items = self.query_primary_table(config, project_id) + primary_keys = set() + + for item in items: + primary_key_value = item.get(config.primary_key) + if primary_key_value: + primary_keys.add(primary_key_value) + await self.check_and_reindex_entry(config.name, primary_key_value) + + return primary_keys + + async def process_secondary_table( + self, config: SecondaryTableConfig, parent_keys: Set[str] + ): + """Process a secondary table""" + items = self.query_secondary_table(config, parent_keys) + + for item in items: + primary_key_value = item.get(config.primary_key) + if primary_key_value: + await self.check_and_reindex_entry(config.name, primary_key_value) + + async def run(self, project_id: str): + """Main execution flow""" + print(f"\n{'=' * 60}") + print(f"Project ID: {project_id}") + print(f"Mode: {'DRY RUN' if self.dry_run else 'REINDEX'}") + print(f"{'=' * 60}\n") + + await self.connect_nats() + + try: + # Store parent keys for secondary table lookups + parent_keys_by_table = {} + + # Process primary tables + print("\n--- Processing Primary Tables ---\n") + for config in PRIMARY_TABLES: + primary_keys = await self.process_primary_table(config, project_id) + parent_keys_by_table[config.name] = primary_keys + + # Process secondary tables + print("\n--- Processing Secondary Tables ---\n") + for sec_config in SECONDARY_TABLES: + parent_keys = parent_keys_by_table.get(sec_config.parent_table, set()) + await self.process_secondary_table(sec_config, parent_keys) + + # Print summary + self.print_summary() + + finally: + await self.disconnect_nats() + + def print_summary(self): + """Print summary report""" + print(f"\n{'=' * 60}") + print("SUMMARY REPORT") + print(f"{'=' * 60}\n") + + all_good = True + total_items = 0 + total_missing = 0 + total_reindexed = 0 + total_errors = 0 + + for table_name in sorted(self.stats.tables.keys()): + stats = self.stats.tables[table_name] + total_items += stats["total"] + total_missing += stats["missing"] + total_reindexed += stats["reindexed"] + total_errors += stats["errors"] + + status = ( + "✓ OK" if stats["missing"] == 0 and stats["errors"] == 0 else "✗ ISSUES" + ) + if stats["missing"] > 0 or stats["errors"] > 0: + all_good = False + + print(f"{table_name}:") + print(f" Total entries: {stats['total']}") + print(f" Missing from NATS: {stats['missing']}") + if not self.dry_run: + print(f" Reindexed: {stats['reindexed']}") + if stats["errors"] > 0: + print(f" Errors: {stats['errors']}") + print(f" Status: {status}") + + # Show missing keys in dry-run mode + if self.dry_run and stats["missing"] > 0: + print(f" Missing keys: {', '.join(stats['missing_keys'][:10])}") + if len(stats["missing_keys"]) > 10: + print(f" ... and {len(stats['missing_keys']) - 10} more") + + print() + + print(f"{'=' * 60}") + print("TOTALS:") + print(f" Total entries checked: {total_items}") + print(f" Missing from NATS: {total_missing}") + if not self.dry_run: + print(f" Reindexed: {total_reindexed}") + if total_errors > 0: + print(f" Errors: {total_errors}") + status = ( + "✓ ALL ENTRIES HAVE NATS KV MATCHES" + if all_good + else "✗ SOME ENTRIES MISSING OR ERRORS" + ) + print(f"\nOverall Status: {status}") + print(f"{'=' * 60}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Check and reindex DynamoDB entries in NATS KV bucket" + ) + parser.add_argument("project_id", help="Project ID to search for") + parser.add_argument( + "--nats-url", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "--reindex", + action="store_true", + help="Reindex mode - trigger reindexing for entries " + "in NATS KV. Without this flag, runs in dry-run mode.", + ) + + args = parser.parse_args() + + # By default, run in dry-run mode unless --reindex is specified + dry_run = not args.reindex + + reindexer = DynamoDBNATSReindexer(nats_url=args.nats_url, dry_run=dry_run) + + try: + asyncio.run(reindexer.run(args.project_id)) + except KeyboardInterrupt: + print("\n\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\n\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/reindexing/reindex_committees.py b/scripts/reindexing/reindex_committees.py new file mode 100644 index 0000000..b875350 --- /dev/null +++ b/scripts/reindexing/reindex_committees.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +""" +Committee Service Reindexer + +Given a project_id (CLI arg) and TOKEN (env var), this script: +1. Fetches all committees for the project from the LF project-service API +2. Reindexes each committee in the v1-objects NATS KV bucket + (key: platform-collaboration__c.) +3. For each committee, fetches all members from the API +4. Reindexes each member in the v1-objects NATS KV bucket + (key: platform-community__c.) +5. Prints a summary report +""" + +import argparse +import asyncio +import os +import sys +from dataclasses import dataclass, field +from typing import Dict, List + +import httpx +from nats.aio.client import Client as NATS + + +# API_BASE = "https://api-gw.platform.linuxfoundation.org/project-service/v2" +API_BASE = "https://api-gw.dev.platform.linuxfoundation.org/project-service/v2" +KV_BUCKET = "v1-objects" +COMMITTEE_KV_PREFIX = "platform-collaboration__c" +MEMBER_KV_PREFIX = "platform-community__c" +PAGE_SIZE = 100 + + +@dataclass +class Stats: + total: int = 0 + reindexed: int = 0 + missing: int = 0 + errors: int = 0 + missing_keys: List[str] = field(default_factory=list) + + +class CommitteeReindexer: + def __init__(self, token: str, nats_url: str, dry_run: bool = True): + self.token = token + self.nats_url = nats_url + self.dry_run = dry_run + self.nc = None + self.kv = None + self.stats: Dict[str, Stats] = {} + + def _stats(self, key: str) -> Stats: + if key not in self.stats: + self.stats[key] = Stats() + return self.stats[key] + + async def connect(self): + self.nc = NATS() + await self.nc.connect(servers=[self.nats_url]) + js = self.nc.jetstream() + self.kv = await js.key_value(bucket=KV_BUCKET) + print(f"Connected to NATS at {self.nats_url}") + + async def disconnect(self): + if self.nc: + await self.nc.close() + + def _get_headers(self) -> dict: + return {"Authorization": f"Bearer {self.token}"} + + def _fetch_all_pages(self, client: httpx.Client, url: str) -> List[dict]: + """Fetch all pages from a paginated API endpoint.""" + items: List[dict] = [] + offset = 0 + + while True: + response = client.get( + url, + params={"pageSize": PAGE_SIZE, "offset": offset}, + headers=self._get_headers(), + ) + response.raise_for_status() + data = response.json() + + # The API returns a list directly or wrapped in a data field + page_items = ( + data + if isinstance(data, list) + else data.get("Data", data.get("data", [])) + ) + if not page_items: + break + + items.extend(page_items) + + # If we got fewer items than the page size, we're done + if len(page_items) < PAGE_SIZE: + break + + offset += PAGE_SIZE + + return items + + async def reindex_entry(self, kv_prefix: str, item_id: str): + """Fetch entry from NATS KV and write it back to trigger reindex.""" + kv_key = f"{kv_prefix}.{item_id}" + s = self._stats(kv_prefix) + s.total += 1 + + try: + assert self.kv is not None, "NATS KV not connected" + entry = await self.kv.get(kv_key) + if entry is None: + print(f" MISSING: {kv_key}") + s.missing += 1 + s.missing_keys.append(item_id) + return + + if not self.dry_run: + await self.kv.put(kv_key, entry.value) + s.reindexed += 1 + print(f" reindexed: {kv_key}") + else: + print(f" found: {kv_key}") + + except Exception as e: + print(f" ERROR {kv_key}: {e}") + s = self._stats(kv_prefix) + s.missing += 1 + s.errors += 1 + + async def run(self, project_id: str): + print(f"\n{'=' * 60}") + print("Committee Reindexer") + print(f"Project ID : {project_id}") + print(f"Mode : {'DRY RUN' if self.dry_run else 'REINDEX'}") + print(f"{'=' * 60}\n") + + await self.connect() + try: + with httpx.Client(timeout=30) as client: + # Fetch all committees for the project + committees_url = f"{API_BASE}/projects/{project_id}/committees" + print("--- Fetching committees from API ---") + committees = self._fetch_all_pages(client, committees_url) + print(f" Found {len(committees)} committee(s)") + + for committee in committees: + committee_id = committee.get("ID") or committee.get("id") + if not committee_id: + print(f" WARNING: committee has no ID: {committee}") + continue + + # Reindex the committee + await self.reindex_entry(COMMITTEE_KV_PREFIX, committee_id) + + # Fetch all members for this committee + members_url = ( + f"{API_BASE}/projects/{project_id}" + f"/committees/{committee_id}/members" + ) + members = self._fetch_all_pages(client, members_url) + print(f" Committee {committee_id}: {len(members)} member(s)") + + for member in members: + member_id = member.get("ID") or member.get("id") + if not member_id: + print(f" WARNING: member has no ID: {member}") + continue + await self.reindex_entry(MEMBER_KV_PREFIX, member_id) + + self.print_summary() + + finally: + await self.disconnect() + + def print_summary(self): + print(f"\n{'=' * 60}") + print("SUMMARY REPORT") + print(f"{'=' * 60}\n") + + grand_total = grand_missing = grand_reindexed = grand_errors = 0 + + for prefix in [COMMITTEE_KV_PREFIX, MEMBER_KV_PREFIX]: + if prefix not in self.stats: + continue + s = self.stats[prefix] + grand_total += s.total + grand_missing += s.missing + grand_reindexed += s.reindexed + grand_errors += s.errors + + status = "OK" if s.missing == 0 and s.errors == 0 else "ISSUES" + print(f"{prefix}:") + print(f" Total checked : {s.total}") + print(f" Missing : {s.missing}") + if not self.dry_run: + print(f" Reindexed : {s.reindexed}") + if s.errors: + print(f" Errors : {s.errors}") + print(f" Status : {status}") + if s.missing_keys: + shown = s.missing_keys[:10] + print(f" Missing keys : {', '.join(str(k) for k in shown)}") + if len(s.missing_keys) > 10: + print(f" ... and {len(s.missing_keys) - 10} more") + print() + + print(f"{'=' * 60}") + print("TOTALS:") + print(f" Total checked : {grand_total}") + print(f" Missing : {grand_missing}") + if not self.dry_run: + print(f" Reindexed : {grand_reindexed}") + if grand_errors: + print(f" Errors : {grand_errors}") + overall = ( + "ALL OK" + if grand_missing == 0 and grand_errors == 0 + else "SOME MISSING OR ERRORS" + ) + print(f"\nOverall Status: {overall}") + print(f"{'=' * 60}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Reindex project committees and their members in NATS KV" + ) + parser.add_argument("project_id", help="Project ID to reindex") + parser.add_argument( + "--nats-url", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "--reindex", + action="store_true", + help="Reindex mode — write entries back. " + "Without this flag, runs dry-run.", + ) + args = parser.parse_args() + + token = os.environ.get("TOKEN") + if not token: + print("ERROR: TOKEN environment variable is required", file=sys.stderr) + sys.exit(1) + + reindexer = CommitteeReindexer( + token=token, + nats_url=args.nats_url, + dry_run=not args.reindex, + ) + + try: + asyncio.run(reindexer.run(args.project_id)) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/reindexing/reindex_groupsio.py b/scripts/reindexing/reindex_groupsio.py new file mode 100644 index 0000000..f8d4294 --- /dev/null +++ b/scripts/reindexing/reindex_groupsio.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +""" +Groups.io Service Reindexer + +Given a project_id, this script: +1. Looks up the groupsio service entry in itx-v2-groupsio-service +2. Finds all subgroups/mailing lists via itx-v2-groupsio-subgroup (parent_id_index) +3. Reindexes each subgroup entry in the v1-objects NATS KV bucket +4. For each subgroup, finds all members via itx-v2-groupsio-member (group_id_index) +5. Reindexes each member entry in the v1-objects NATS KV bucket +6. Prints a summary report +""" + +import argparse +import asyncio +import sys +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +import boto3 +from boto3.dynamodb.conditions import Key +from nats.aio.client import Client as NATS + + +SERVICE_TABLE = "itx-groupsio-v2-service" +SUBGROUP_TABLE = "itx-groupsio-v2-subgroup" +MEMBER_TABLE = "itx-groupsio-v2-member" +KV_BUCKET = "v1-objects" + + +@dataclass +class TableStats: + total: int = 0 + reindexed: int = 0 + missing: int = 0 + errors: int = 0 + missing_keys: List[str] = field(default_factory=list) + + +class GroupsioReindexer: + def __init__(self, nats_url: str, dry_run: bool = True): + self.nats_url = nats_url + self.dry_run = dry_run + self.dynamodb = boto3.resource("dynamodb") + self.nc = None + self.kv = None + self.stats: Dict[str, TableStats] = {} + + def _stats(self, table: str) -> TableStats: + if table not in self.stats: + self.stats[table] = TableStats() + return self.stats[table] + + async def connect(self): + self.nc = NATS() + await self.nc.connect(servers=[self.nats_url]) + js = self.nc.jetstream() + self.kv = await js.key_value(bucket=KV_BUCKET) + print(f"Connected to NATS at {self.nats_url}") + + async def disconnect(self): + if self.nc: + await self.nc.close() + + def _query_all( + self, table_name: str, index: str, key_field: str, key_value: str + ) -> List[dict]: + """Query a DynamoDB index, handling pagination.""" + table = self.dynamodb.Table(table_name) + items = [] + kwargs = dict( + IndexName=index, + KeyConditionExpression=Key(key_field).eq(key_value), + ) + response = table.query(**kwargs) + items.extend(response.get("Items", [])) + while "LastEvaluatedKey" in response: + response = table.query( + **kwargs, ExclusiveStartKey=response["LastEvaluatedKey"] + ) + items.extend(response.get("Items", [])) + return items + + def _get_item(self, table_name: str, key: dict) -> Optional[dict]: + table = self.dynamodb.Table(table_name) + response = table.get_item(Key=key) + return response.get("Item") + + async def reindex_entry(self, table_name: str, item_id: str): + """Fetch entry from NATS KV and write it back to trigger reindex.""" + kv_key = f"{table_name}.{item_id}" + s = self._stats(table_name) + s.total += 1 + + try: + assert self.kv is not None, "NATS KV not connected" + entry = await self.kv.get(kv_key) + if entry is None: + print(f" MISSING: {kv_key}") + s.missing += 1 + s.missing_keys.append(item_id) + return + + if not self.dry_run: + await self.kv.put(kv_key, entry.value) + s.reindexed += 1 + print(f" reindexed: {kv_key}") + else: + print(f" found: {kv_key}") + + except Exception as e: + print(f" ERROR {kv_key}: {e}") + s.missing += 1 + s.errors += 1 + + async def run(self, project_id: str): + print(f"\n{'=' * 60}") + print("Groups.io Reindexer") + print(f"Project ID : {project_id}") + print(f"Mode : {'DRY RUN' if self.dry_run else 'REINDEX'}") + print(f"{'=' * 60}\n") + + await self.connect() + try: + # Step 1: find the service entry for this project + print(f"--- Looking up service in {SERVICE_TABLE} ---") + service_items = self._query_all( + SERVICE_TABLE, "project_id_index", "project_id", project_id + ) + if not service_items: + print(f"ERROR: No groupsio service found for project_id={project_id}") + return + + # There should be one service per project, but handle multiples gracefully + for service_item in service_items: + group_service_id = service_item.get("group_service_id") + if not group_service_id: + print( + f"WARNING: service item has no group_service_id: {service_item}" + ) + continue + + print(f" Found service: group_service_id={group_service_id}") + await self.reindex_entry(SERVICE_TABLE, group_service_id) + + # Step 2: find all subgroups for this service via parent_id + print(f"\n--- Finding subgroups in {SUBGROUP_TABLE} ---") + subgroup_items = self._query_all( + SUBGROUP_TABLE, "parent_id_index", "parent_id", group_service_id + ) + print(f" Found {len(subgroup_items)} subgroup(s)") + + for subgroup in subgroup_items: + group_id = subgroup.get("group_id") + if not group_id: + print(f" WARNING: subgroup item has no group_id: {subgroup}") + continue + + # Step 3: reindex the subgroup entry + await self.reindex_entry(SUBGROUP_TABLE, group_id) + + # Step 4: find all members for this subgroup + member_items = self._query_all( + MEMBER_TABLE, "group_id_index", "group_id", group_id + ) + + # Step 5: reindex each member + for member in member_items: + member_id = member.get("member_id") + if not member_id: + print(f" WARNING: member item has no member_id: {member}") + continue + await self.reindex_entry(MEMBER_TABLE, member_id) + + self.print_summary() + + finally: + await self.disconnect() + + def print_summary(self): + print(f"\n{'=' * 60}") + print("SUMMARY REPORT") + print(f"{'=' * 60}\n") + + grand_total = grand_missing = grand_reindexed = grand_errors = 0 + + for table_name in [SERVICE_TABLE, SUBGROUP_TABLE, MEMBER_TABLE]: + if table_name not in self.stats: + continue + s = self.stats[table_name] + grand_total += s.total + grand_missing += s.missing + grand_reindexed += s.reindexed + grand_errors += s.errors + + status = "OK" if s.missing == 0 and s.errors == 0 else "ISSUES" + print(f"{table_name}:") + print(f" Total checked : {s.total}") + print(f" Missing : {s.missing}") + if not self.dry_run: + print(f" Reindexed : {s.reindexed}") + if s.errors: + print(f" Errors : {s.errors}") + print(f" Status : {status}") + if s.missing_keys: + shown = s.missing_keys[:10] + print(f" Missing keys : {', '.join(str(k) for k in shown)}") + if len(s.missing_keys) > 10: + print(f" ... and {len(s.missing_keys) - 10} more") + print() + + print(f"{'=' * 60}") + print("TOTALS:") + print(f" Total checked : {grand_total}") + print(f" Missing : {grand_missing}") + if not self.dry_run: + print(f" Reindexed : {grand_reindexed}") + if grand_errors: + print(f" Errors : {grand_errors}") + overall = ( + "ALL OK" + if grand_missing == 0 and grand_errors == 0 + else "SOME MISSING OR ERRORS" + ) + print(f"\nOverall Status: {overall}") + print(f"{'=' * 60}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Reindex Groups.io service, subgroups, and members in NATS KV" + ) + parser.add_argument("project_id", help="Project ID to reindex") + parser.add_argument( + "--nats-url", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "--reindex", + action="store_true", + help="Reindex mode — write entries back. " + "Without this flag, runs dry-run.", + ) + args = parser.parse_args() + + reindexer = GroupsioReindexer(nats_url=args.nats_url, dry_run=not args.reindex) + + try: + asyncio.run(reindexer.run(args.project_id)) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/reindexing/reindex_past_meetings.py b/scripts/reindexing/reindex_past_meetings.py new file mode 100644 index 0000000..cc789ae --- /dev/null +++ b/scripts/reindexing/reindex_past_meetings.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +""" +Past Meeting Reindexer + +Fetches all v1_past_meeting objects from OpenSearch, then for each one +updates the corresponding NATS KV entry in v1-objects +(key: itx-zoom-past-meetings.). + +Required env vars: + OPENSEARCH_URL — e.g. https://opensearch.example.com:9200 + +Optional env vars: + OPENSEARCH_USER + OPENSEARCH_PASSWORD +""" + +import argparse +import asyncio +import os +import sys +from dataclasses import dataclass, field +from functools import partial +from typing import List, Optional, Tuple +from urllib.parse import urlparse + +from nats.aio.client import Client as NATS +from opensearchpy import OpenSearch + + +SCROLL_SIZE = 500 +SCROLL_TTL = "2m" +OBJECT_TYPE = "v1_past_meeting" +KV_BUCKET = "v1-objects" +KV_PREFIX = "itx-zoom-past-meetings" + + +@dataclass +class Stats: + total: int = 0 + reindexed: int = 0 + missing: int = 0 + errors: int = 0 + missing_keys: List[str] = field(default_factory=list) + + +def _parse_opensearch_url( + url: str, user: Optional[str], password: Optional[str] +) -> Tuple[str, Optional[str], Optional[str]]: + parsed = urlparse(url) + if parsed.username: + user = parsed.username + if parsed.password: + password = parsed.password + host = f"{parsed.scheme}://{parsed.hostname}" + if parsed.port: + host = f"{host}:{parsed.port}" + if parsed.path and parsed.path != "/": + host = f"{host}{parsed.path}" + return host, user, password + + +class PastMeetingReindexer: + def __init__( + self, + opensearch_url: str, + os_user: Optional[str], + os_password: Optional[str], + os_index: str, + nats_url: str, + dry_run: bool = True, + ): + host, user, password = _parse_opensearch_url( + opensearch_url, os_user, os_password + ) + auth = (user, password) if user and password else None + self.os = OpenSearch( + hosts=[host], + http_auth=auth, + use_ssl=host.startswith("https"), + verify_certs=True, + ) + self.os_index = os_index + self.nats_url = nats_url + self.dry_run = dry_run + self.nc = None + self.kv = None + self.stats = Stats() + + async def connect(self): + self.nc = NATS() + await self.nc.connect(servers=[self.nats_url]) + js = self.nc.jetstream() + self.kv = await js.key_value(bucket=KV_BUCKET) + print(f"Connected to NATS at {self.nats_url}") + + async def disconnect(self): + self.os.close() + if self.nc: + await self.nc.close() + + async def _os(self, fn, *args, **kwargs): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, partial(fn, *args, **kwargs)) + + async def scroll_opensearch(self) -> List[dict]: + """Return all v1_past_meeting documents from OpenSearch.""" + query = {"query": {"term": {"object_type": OBJECT_TYPE}}} + docs = [] + + resp = await self._os( + self.os.search, + index=self.os_index, + body=query, + scroll=SCROLL_TTL, + size=SCROLL_SIZE, + ) + scroll_id = resp.get("_scroll_id") + + while True: + hits = resp["hits"]["hits"] + if not hits: + break + docs.extend(hits) + if len(hits) < SCROLL_SIZE: + break + resp = await self._os( + self.os.scroll, scroll_id=scroll_id, scroll=SCROLL_TTL + ) + scroll_id = resp.get("_scroll_id") + + if scroll_id: + try: + await self._os(self.os.clear_scroll, scroll_id=scroll_id) + except Exception: + pass + + return docs + + async def reindex_entry(self, meeting_and_occurrence_id: str): + kv_key = f"{KV_PREFIX}.{meeting_and_occurrence_id}" + self.stats.total += 1 + + try: + assert self.kv is not None, "NATS KV not connected" + entry = await self.kv.get(kv_key) + if entry is None: + print(f" MISSING: {kv_key}") + self.stats.missing += 1 + self.stats.missing_keys.append(meeting_and_occurrence_id) + return + + if not self.dry_run: + await self.kv.put(kv_key, entry.value) + self.stats.reindexed += 1 + print(f" reindexed: {kv_key}") + else: + print(f" found: {kv_key}") + + except Exception as e: + print(f" ERROR {kv_key}: {e}") + self.stats.missing += 1 + self.stats.errors += 1 + + async def run(self): + print(f"\n{'=' * 60}") + print("Past Meeting Reindexer") + print(f"Index : {self.os_index}") + print(f"Mode : {'DRY RUN' if self.dry_run else 'REINDEX'}") + print(f"{'=' * 60}\n") + + await self.connect() + try: + print(f"--- Fetching '{OBJECT_TYPE}' documents from OpenSearch ---") + docs = await self.scroll_opensearch() + print(f" Found {len(docs)} document(s)\n") + + for doc in docs: + # _id is of the form "v1_past_meeting:" + doc_id = doc["_id"] + meeting_and_occurrence_id = ( + doc_id.split(":", 1)[1] if ":" in doc_id else doc_id + ) + await self.reindex_entry(meeting_and_occurrence_id) + + self.print_summary() + finally: + await self.disconnect() + + def print_summary(self): + s = self.stats + print(f"\n{'=' * 60}") + print("SUMMARY REPORT") + print(f"{'=' * 60}\n") + print(f"Total checked : {s.total}") + print(f"Missing : {s.missing}") + if not self.dry_run: + print(f"Reindexed : {s.reindexed}") + if s.errors: + print(f"Errors : {s.errors}") + if s.missing_keys: + shown = s.missing_keys[:10] + print(f"Missing keys : {', '.join(shown)}") + if len(s.missing_keys) > 10: + print(f" ... and {len(s.missing_keys) - 10} more") + overall = ( + "ALL OK" if s.missing == 0 and s.errors == 0 else "SOME MISSING OR ERRORS" + ) + print(f"\nOverall Status: {overall}") + print(f"{'=' * 60}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Reindex v1_past_meeting objects from OpenSearch into NATS KV" + ) + parser.add_argument( + "--opensearch-url", + default=os.environ.get("OPENSEARCH_URL", "http://localhost:9200"), + help="OpenSearch URL (default: $OPENSEARCH_URL or http://localhost:9200)", + ) + parser.add_argument( + "--os-user", + default=os.environ.get("OPENSEARCH_USER"), + help="OpenSearch username (default: $OPENSEARCH_USER)", + ) + parser.add_argument( + "--os-password", + default=os.environ.get("OPENSEARCH_PASSWORD"), + help="OpenSearch password (default: $OPENSEARCH_PASSWORD)", + ) + parser.add_argument( + "--os-index", + default=os.environ.get("OPENSEARCH_INDEX", "resources"), + help="OpenSearch index to query (default: $OPENSEARCH_INDEX or resources)", + ) + parser.add_argument( + "--nats-url", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "--reindex", + action="store_true", + help="Reindex mode — write entries back. " + "Without this flag, runs dry-run.", + ) + args = parser.parse_args() + + reindexer = PastMeetingReindexer( + opensearch_url=args.opensearch_url, + os_user=args.os_user, + os_password=args.os_password, + os_index=args.os_index, + nats_url=args.nats_url, + dry_run=not args.reindex, + ) + + try: + asyncio.run(reindexer.run()) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/reindexing/reindex_votes.py b/scripts/reindexing/reindex_votes.py new file mode 100644 index 0000000..13c1bb8 --- /dev/null +++ b/scripts/reindexing/reindex_votes.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT +""" +Votes Reindexer + +Queries OpenSearch for all objects of type "vote" and "vote_response" in the +resources index, then reindexes each in the v1-objects NATS KV bucket: + - vote -> itx-poll. + - vote_response -> itx-poll-vote. + +All itx-poll entries are reindexed before itx-poll-vote entries. +""" + +import argparse +import asyncio +import sys +from dataclasses import dataclass, field +from typing import List + +import httpx +from nats.aio.client import Client as NATS + + +OPENSEARCH_URL = "http://localhost:9200" +INDEX = "resources" +PAGE_SIZE = 500 +KV_BUCKET = "v1-objects" + + +@dataclass +class Stats: + total: int = 0 + reindexed: int = 0 + missing: int = 0 + errors: int = 0 + missing_keys: List[str] = field(default_factory=list) + + +def fetch_all_by_type(client: httpx.Client, object_type: str) -> List[str]: + """Fetch all object_ids of the given type from OpenSearch using pagination.""" + ids = [] + offset = 0 + + while True: + response = client.post( + f"{OPENSEARCH_URL}/{INDEX}/_search", + json={ + "query": {"term": {"object_type": object_type}}, + "size": PAGE_SIZE, + "from": offset, + "_source": ["object_id"], + }, + ) + response.raise_for_status() + data = response.json() + + hits = data["hits"]["hits"] + if not hits: + break + + for hit in hits: + oid = hit["_source"].get("object_id") + if oid: + ids.append(oid) + + total = data["hits"]["total"]["value"] + offset += len(hits) + if offset >= total: + break + + return ids + + +async def reindex_entry(kv, kv_prefix: str, item_id: str, dry_run: bool, stats: Stats): + """Fetch entry from NATS KV and write it back to trigger reindex.""" + kv_key = f"{kv_prefix}.{item_id}" + stats.total += 1 + + try: + entry = await kv.get(kv_key) + if entry is None: + print(f" MISSING: {kv_key}") + stats.missing += 1 + stats.missing_keys.append(item_id) + return + + if not dry_run: + await kv.put(kv_key, entry.value) + stats.reindexed += 1 + print(f" reindexed: {kv_key}") + else: + print(f" found: {kv_key}") + + except Exception as e: + print(f" ERROR {kv_key}: {e}") + stats.missing += 1 + stats.errors += 1 + + +async def run(nats_url: str, dry_run: bool): + print(f"\n{'=' * 60}") + print("Votes Reindexer") + print(f"Mode : {'DRY RUN' if dry_run else 'REINDEX'}") + print(f"{'=' * 60}\n") + + # Fetch all IDs from OpenSearch first + with httpx.Client(timeout=30) as client: + print("--- Fetching 'vote' objects from OpenSearch ---") + vote_ids = fetch_all_by_type(client, "vote") + print(f" Found {len(vote_ids)} vote(s)") + + print("--- Fetching 'vote_response' objects from OpenSearch ---") + vote_response_ids = fetch_all_by_type(client, "vote_response") + print(f" Found {len(vote_response_ids)} vote_response(s)") + + # Connect to NATS + nc = NATS() + await nc.connect(servers=[nats_url]) + js = nc.jetstream() + kv = await js.key_value(bucket=KV_BUCKET) + print(f"\nConnected to NATS at {nats_url}") + + poll_stats = Stats() + vote_stats = Stats() + + try: + # Reindex itx-poll entries first + print(f"\n--- Reindexing itx-poll ({len(vote_ids)} entries) ---") + for oid in vote_ids: + await reindex_entry(kv, "itx-poll", oid, dry_run, poll_stats) + + # Then reindex itx-poll-vote entries + print(f"\n--- Reindexing itx-poll-vote ({len(vote_response_ids)} entries) ---") + for oid in vote_response_ids: + await reindex_entry(kv, "itx-poll-vote", oid, dry_run, vote_stats) + + finally: + await nc.close() + + print_summary({"itx-poll": poll_stats, "itx-poll-vote": vote_stats}, dry_run) + + +def print_summary(stats_by_prefix: dict, dry_run: bool): + print(f"\n{'=' * 60}") + print("SUMMARY REPORT") + print(f"{'=' * 60}\n") + + grand_total = grand_missing = grand_reindexed = grand_errors = 0 + + for prefix, s in stats_by_prefix.items(): + grand_total += s.total + grand_missing += s.missing + grand_reindexed += s.reindexed + grand_errors += s.errors + + status = "OK" if s.missing == 0 and s.errors == 0 else "ISSUES" + print(f"{prefix}:") + print(f" Total checked : {s.total}") + print(f" Missing : {s.missing}") + if not dry_run: + print(f" Reindexed : {s.reindexed}") + if s.errors: + print(f" Errors : {s.errors}") + print(f" Status : {status}") + if s.missing_keys: + shown = s.missing_keys[:10] + print(f" Missing keys : {', '.join(str(k) for k in shown)}") + if len(s.missing_keys) > 10: + print(f" ... and {len(s.missing_keys) - 10} more") + print() + + print(f"{'=' * 60}") + print("TOTALS:") + print(f" Total checked : {grand_total}") + print(f" Missing : {grand_missing}") + if not dry_run: + print(f" Reindexed : {grand_reindexed}") + if grand_errors: + print(f" Errors : {grand_errors}") + overall = ( + "ALL OK" + if grand_missing == 0 and grand_errors == 0 + else "SOME MISSING OR ERRORS" + ) + print(f"\nOverall Status: {overall}") + print(f"{'=' * 60}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Reindex votes and vote responses from OpenSearch into NATS KV" + ) + parser.add_argument( + "--nats-url", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "--reindex", + action="store_true", + help="Reindex mode — write entries back. " + "Without this flag, runs dry-run.", + ) + args = parser.parse_args() + + try: + asyncio.run(run(nats_url=args.nats_url, dry_run=not args.reindex)) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/reindexing/requirements.txt b/scripts/reindexing/requirements.txt new file mode 100644 index 0000000..13b3ba3 --- /dev/null +++ b/scripts/reindexing/requirements.txt @@ -0,0 +1,7 @@ +# Copyright The Linux Foundation and each contributor to LFX. +# SPDX-License-Identifier: MIT + +boto3>=1.34.0 +httpx>=0.27.0 +nats-py>=2.7.0 +opensearch-py>=2.4.0 diff --git a/scripts/reindexing/uv.lock b/scripts/reindexing/uv.lock new file mode 100644 index 0000000..f177693 --- /dev/null +++ b/scripts/reindexing/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "project-reindexer" +version = "0.1.0" +source = { virtual = "." } From f80d656ac83db079304bd0107523d8286ab6be13 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Tue, 7 Apr 2026 10:58:06 -0700 Subject: [PATCH 2/3] fix(scripts): address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add runtime deps to pyproject.toml so uv sync works - Use meta.client.batch_get_item on dynamodb resource - Catch KeyNotFoundError explicitly; count other exceptions as errors, not missing entries - Replace offset pagination with scroll API in reindex_votes to avoid 10k result window limit - Fix _source field name object_id -> id in audit script - Make API_BASE configurable via env var, defaulting to production URL - Fix docstring table names in reindex_groupsio - Regenerate uv.lock with new dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Issue: LFXV2-1371 Co-Authored-By: Claude Opus 4.5 Signed-off-by: Trevor Bramwell --- scripts/reindexing/audit_opensearch.py | 14 +- scripts/reindexing/pyproject.toml | 7 +- scripts/reindexing/reindex.py | 14 +- scripts/reindexing/reindex_committees.py | 18 +- scripts/reindexing/reindex_groupsio.py | 17 +- scripts/reindexing/reindex_past_meetings.py | 11 +- scripts/reindexing/reindex_votes.py | 61 ++-- scripts/reindexing/uv.lock | 364 ++++++++++++++++++++ 8 files changed, 446 insertions(+), 60 deletions(-) diff --git a/scripts/reindexing/audit_opensearch.py b/scripts/reindexing/audit_opensearch.py index 8bde8a7..7497487 100644 --- a/scripts/reindexing/audit_opensearch.py +++ b/scripts/reindexing/audit_opensearch.py @@ -34,7 +34,8 @@ from urllib.parse import urlparse from nats.aio.client import Client as NATS -from opensearchpy import NotFoundError, OpenSearch +from nats.js.errors import KeyNotFoundError +from opensearchpy import OpenSearch SCROLL_SIZE = 500 @@ -176,17 +177,15 @@ async def audit_type(self, object_type: str, kv_bucket: str) -> Stats: for doc in docs: doc_id = doc["_id"] # OpenSearch document _id # Also check the `id` field in _source for the uuid - source_id = doc.get("_source", {}).get("object_id") or doc_id + source_id = doc.get("_source", {}).get("id") or doc_id # Strip to bare UUID (last segment if namespaced) uuid = source_id.split(":")[-1] if ":" in source_id else source_id try: - entry = await kv.get(uuid) - if entry is None: - raise NotFoundError(404, "key not found", {}) + await kv.get(uuid) s.in_nats += 1 - except Exception: + except KeyNotFoundError: s.missing_in_nats += 1 s.missing_ids.append(doc_id) @@ -200,6 +199,9 @@ async def audit_type(self, object_type: str, kv_bucket: str) -> Stats: except Exception as e: s.errors += 1 print(f" ERROR deleting {doc_id}: {e}") + except Exception as e: + s.errors += 1 + print(f" ERROR checking {doc_id}: {e}") return s diff --git a/scripts/reindexing/pyproject.toml b/scripts/reindexing/pyproject.toml index a60352c..d3b359c 100644 --- a/scripts/reindexing/pyproject.toml +++ b/scripts/reindexing/pyproject.toml @@ -7,4 +7,9 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "boto3", + "httpx", + "nats-py", + "opensearch-py", +] diff --git a/scripts/reindexing/reindex.py b/scripts/reindexing/reindex.py index 47e23a2..de097be 100644 --- a/scripts/reindexing/reindex.py +++ b/scripts/reindexing/reindex.py @@ -19,6 +19,7 @@ import boto3 from boto3.dynamodb.conditions import Key from nats.aio.client import Client as NATS +from nats.js.errors import KeyNotFoundError # Table configuration @@ -246,14 +247,14 @@ def query_secondary_table( batch = parent_keys_list[i:i + 100] keys = [{config.primary_key: pk} for pk in batch] - response = self.dynamodb.batch_get_item( + response = self.dynamodb.meta.client.batch_get_item( RequestItems={config.name: {"Keys": keys}} ) items.extend(response.get("Responses", {}).get(config.name, [])) # Handle unprocessed keys while response.get("UnprocessedKeys"): - response = self.dynamodb.batch_get_item( + response = self.dynamodb.meta.client.batch_get_item( RequestItems=response["UnprocessedKeys"] ) items.extend(response.get("Responses", {}).get(config.name, [])) @@ -292,11 +293,6 @@ async def check_and_reindex_entry( assert self.kv is not None, "NATS KV not connected" entry = await self.kv.get(kv_key) - if entry is None: - print(f" ✗ Missing: {kv_key}") - self.stats.add_missing(table_name, primary_key_value) - return False - # Entry exists - trigger reindex if not in dry-run mode if not self.dry_run: # Update with the same value to trigger reindex @@ -306,6 +302,10 @@ async def check_and_reindex_entry( return True + except KeyNotFoundError: + print(f" ✗ Missing: {kv_key}") + self.stats.add_missing(table_name, primary_key_value) + return False except Exception as e: print(f" ✗ Error checking {kv_key}: {e}") self.stats.add_error(table_name) diff --git a/scripts/reindexing/reindex_committees.py b/scripts/reindexing/reindex_committees.py index b875350..ad59ac7 100644 --- a/scripts/reindexing/reindex_committees.py +++ b/scripts/reindexing/reindex_committees.py @@ -23,10 +23,13 @@ import httpx from nats.aio.client import Client as NATS +from nats.js.errors import KeyNotFoundError -# API_BASE = "https://api-gw.platform.linuxfoundation.org/project-service/v2" -API_BASE = "https://api-gw.dev.platform.linuxfoundation.org/project-service/v2" +API_BASE = os.environ.get( + "API_BASE", + "https://api-gw.platform.linuxfoundation.org/project-service/v2", +) KV_BUCKET = "v1-objects" COMMITTEE_KV_PREFIX = "platform-collaboration__c" MEMBER_KV_PREFIX = "platform-community__c" @@ -112,11 +115,6 @@ async def reindex_entry(self, kv_prefix: str, item_id: str): try: assert self.kv is not None, "NATS KV not connected" entry = await self.kv.get(kv_key) - if entry is None: - print(f" MISSING: {kv_key}") - s.missing += 1 - s.missing_keys.append(item_id) - return if not self.dry_run: await self.kv.put(kv_key, entry.value) @@ -125,10 +123,12 @@ async def reindex_entry(self, kv_prefix: str, item_id: str): else: print(f" found: {kv_key}") + except KeyNotFoundError: + print(f" MISSING: {kv_key}") + s.missing += 1 + s.missing_keys.append(item_id) except Exception as e: print(f" ERROR {kv_key}: {e}") - s = self._stats(kv_prefix) - s.missing += 1 s.errors += 1 async def run(self, project_id: str): diff --git a/scripts/reindexing/reindex_groupsio.py b/scripts/reindexing/reindex_groupsio.py index f8d4294..96250d1 100644 --- a/scripts/reindexing/reindex_groupsio.py +++ b/scripts/reindexing/reindex_groupsio.py @@ -5,10 +5,10 @@ Groups.io Service Reindexer Given a project_id, this script: -1. Looks up the groupsio service entry in itx-v2-groupsio-service -2. Finds all subgroups/mailing lists via itx-v2-groupsio-subgroup (parent_id_index) +1. Looks up the groupsio service entry in itx-groupsio-v2-service +2. Finds all subgroups/mailing lists via itx-groupsio-v2-subgroup (parent_id_index) 3. Reindexes each subgroup entry in the v1-objects NATS KV bucket -4. For each subgroup, finds all members via itx-v2-groupsio-member (group_id_index) +4. For each subgroup, finds all members via itx-groupsio-v2-member (group_id_index) 5. Reindexes each member entry in the v1-objects NATS KV bucket 6. Prints a summary report """ @@ -22,6 +22,7 @@ import boto3 from boto3.dynamodb.conditions import Key from nats.aio.client import Client as NATS +from nats.js.errors import KeyNotFoundError SERVICE_TABLE = "itx-groupsio-v2-service" @@ -97,11 +98,6 @@ async def reindex_entry(self, table_name: str, item_id: str): try: assert self.kv is not None, "NATS KV not connected" entry = await self.kv.get(kv_key) - if entry is None: - print(f" MISSING: {kv_key}") - s.missing += 1 - s.missing_keys.append(item_id) - return if not self.dry_run: await self.kv.put(kv_key, entry.value) @@ -110,9 +106,12 @@ async def reindex_entry(self, table_name: str, item_id: str): else: print(f" found: {kv_key}") + except KeyNotFoundError: + print(f" MISSING: {kv_key}") + s.missing += 1 + s.missing_keys.append(item_id) except Exception as e: print(f" ERROR {kv_key}: {e}") - s.missing += 1 s.errors += 1 async def run(self, project_id: str): diff --git a/scripts/reindexing/reindex_past_meetings.py b/scripts/reindexing/reindex_past_meetings.py index cc789ae..0e503ea 100644 --- a/scripts/reindexing/reindex_past_meetings.py +++ b/scripts/reindexing/reindex_past_meetings.py @@ -26,6 +26,7 @@ from urllib.parse import urlparse from nats.aio.client import Client as NATS +from nats.js.errors import KeyNotFoundError from opensearchpy import OpenSearch @@ -145,11 +146,6 @@ async def reindex_entry(self, meeting_and_occurrence_id: str): try: assert self.kv is not None, "NATS KV not connected" entry = await self.kv.get(kv_key) - if entry is None: - print(f" MISSING: {kv_key}") - self.stats.missing += 1 - self.stats.missing_keys.append(meeting_and_occurrence_id) - return if not self.dry_run: await self.kv.put(kv_key, entry.value) @@ -158,9 +154,12 @@ async def reindex_entry(self, meeting_and_occurrence_id: str): else: print(f" found: {kv_key}") + except KeyNotFoundError: + print(f" MISSING: {kv_key}") + self.stats.missing += 1 + self.stats.missing_keys.append(meeting_and_occurrence_id) except Exception as e: print(f" ERROR {kv_key}: {e}") - self.stats.missing += 1 self.stats.errors += 1 async def run(self): diff --git a/scripts/reindexing/reindex_votes.py b/scripts/reindexing/reindex_votes.py index 13c1bb8..7165cee 100644 --- a/scripts/reindexing/reindex_votes.py +++ b/scripts/reindexing/reindex_votes.py @@ -20,6 +20,7 @@ import httpx from nats.aio.client import Client as NATS +from nats.js.errors import KeyNotFoundError OPENSEARCH_URL = "http://localhost:9200" @@ -38,36 +39,54 @@ class Stats: def fetch_all_by_type(client: httpx.Client, object_type: str) -> List[str]: - """Fetch all object_ids of the given type from OpenSearch using pagination.""" - ids = [] - offset = 0 + """Fetch all object_ids of the given type from OpenSearch using scroll pagination.""" + ids: List[str] = [] + scroll_id = None - while True: + try: response = client.post( f"{OPENSEARCH_URL}/{INDEX}/_search", + params={"scroll": "1m"}, json={ "query": {"term": {"object_type": object_type}}, "size": PAGE_SIZE, - "from": offset, "_source": ["object_id"], + "sort": ["_doc"], }, ) response.raise_for_status() data = response.json() - hits = data["hits"]["hits"] - if not hits: - break + scroll_id = data.get("_scroll_id") + hits = data.get("hits", {}).get("hits", []) + + while hits: + for hit in hits: + oid = (hit.get("_source") or {}).get("object_id") + if oid: + ids.append(oid) + + if not scroll_id: + break - for hit in hits: - oid = hit["_source"].get("object_id") - if oid: - ids.append(oid) + response = client.post( + f"{OPENSEARCH_URL}/_search/scroll", + json={"scroll": "1m", "scroll_id": scroll_id}, + ) + response.raise_for_status() + data = response.json() - total = data["hits"]["total"]["value"] - offset += len(hits) - if offset >= total: - break + scroll_id = data.get("_scroll_id") + hits = data.get("hits", {}).get("hits", []) + finally: + if scroll_id: + try: + client.delete( + f"{OPENSEARCH_URL}/_search/scroll", + json={"scroll_id": [scroll_id]}, + ) + except Exception: + pass return ids @@ -79,11 +98,6 @@ async def reindex_entry(kv, kv_prefix: str, item_id: str, dry_run: bool, stats: try: entry = await kv.get(kv_key) - if entry is None: - print(f" MISSING: {kv_key}") - stats.missing += 1 - stats.missing_keys.append(item_id) - return if not dry_run: await kv.put(kv_key, entry.value) @@ -92,9 +106,12 @@ async def reindex_entry(kv, kv_prefix: str, item_id: str, dry_run: bool, stats: else: print(f" found: {kv_key}") + except KeyNotFoundError: + print(f" MISSING: {kv_key}") + stats.missing += 1 + stats.missing_keys.append(item_id) except Exception as e: print(f" ERROR {kv_key}: {e}") - stats.missing += 1 stats.errors += 1 diff --git a/scripts/reindexing/uv.lock b/scripts/reindexing/uv.lock index f177693..00ebb2e 100644 --- a/scripts/reindexing/uv.lock +++ b/scripts/reindexing/uv.lock @@ -2,7 +2,371 @@ version = 1 revision = 3 requires-python = ">=3.12" +[options] +exclude-newer = "2026-03-31T17:57:01.318816628Z" +exclude-newer-span = "P7D" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.79" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/56/3909516140653c389214d01f15660fa76514a9a9e30da1cb9e894f01051e/boto3-1.42.79.tar.gz", hash = "sha256:286c4220785e4fbe46aaea04d005b0dcdd8aaf5b885d92b2609c934d794ec5d9", size = 112818, upload-time = "2026-03-30T19:44:40.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c0/d018824c2859976753cc4b78c48e1be641cf734b7d84e455b0f7f9b9d66d/boto3-1.42.79-py3-none-any.whl", hash = "sha256:2336d744dfe9017d6b96b8e40fdd445295ca6a67f02f1c8c488edeb92c4b7918", size = 140555, upload-time = "2026-03-30T19:44:37.326Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.79" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/4b/ec7b3a9acc47a27a3e279d6532f8fcaf16e44c840895718129ca339a0719/botocore-1.42.79.tar.gz", hash = "sha256:1ea98f505a1a65c4b6eed4a6b7452f27613c9aa24532aa71650ec3f3e05fa32c", size = 15064434, upload-time = "2026-03-30T19:44:28.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/d9/289f94219c2ee4b2a38642bb4a6b8c71cf136c0983e25c0c8fe6b29b1c0a/botocore-1.42.79-py3-none-any.whl", hash = "sha256:edea07bb255e812908e783a9da6ee63ba97baa0cc6612adabbad54466281e330", size = 14741875, upload-time = "2026-03-30T19:44:23.959Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "events" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/ed/e47dec0626edd468c84c04d97769e7ab4ea6457b7f54dcb3f72b17fcd876/Events-0.5-py3-none-any.whl", hash = "sha256:a7286af378ba3e46640ac9825156c93bdba7502174dd696090fdfcd4d80a1abd", size = 6758, upload-time = "2023-07-31T08:23:13.645Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "nats-py" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/f8/b956c4621ba88748ed707c52e69f95b7a50c8914e750edca59a5bef84a76/nats_py-2.14.0.tar.gz", hash = "sha256:4ed02cb8e3b55c68074a063aa2687087115d805d1513297da90cb2068fb07bed", size = 120751, upload-time = "2026-02-23T22:44:58.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/39/0e87753df1072254bac190b33ed34b264f28f6aa9bea0f01b7e818071756/nats_py-2.14.0-py3-none-any.whl", hash = "sha256:4116f5d2233ce16e63c3d5538fa40a5e207f75fcf42a741773929ddf1e29d19d", size = 82259, upload-time = "2026-02-23T22:45:00.152Z" }, +] + +[[package]] +name = "opensearch-protobufs" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e2/8a09dbdbfe51e30dfecb625a0f5c524a53bfa4b1fba168f73ac85621dba2/opensearch_protobufs-0.19.0-py3-none-any.whl", hash = "sha256:5137c9c2323cc7debb694754b820ca4cfb5fc8eb180c41ff125698c3ee11bfc2", size = 39778, upload-time = "2025-09-29T20:05:52.379Z" }, +] + +[[package]] +name = "opensearch-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "events" }, + { name = "opensearch-protobufs" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/9f/d4969f7e8fa221bfebf254cc3056e7c743ce36ac9874e06110474f7c947d/opensearch_py-3.1.0.tar.gz", hash = "sha256:883573af13175ff102b61c80b77934a9e937bdcc40cda2b92051ad53336bc055", size = 258616, upload-time = "2025-11-20T16:37:36.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/a1/293c8ad81768ad625283d960685bde07c6302abf20a685e693b48ab6eb91/opensearch_py-3.1.0-py3-none-any.whl", hash = "sha256:e5af83d0454323e6ea9ddee8c0dcc185c0181054592d23cb701da46271a3b65b", size = 385729, upload-time = "2025-11-20T16:37:34.941Z" }, +] + [[package]] name = "project-reindexer" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "boto3" }, + { name = "httpx" }, + { name = "nats-py" }, + { name = "opensearch-py" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3" }, + { name = "httpx" }, + { name = "nats-py" }, + { name = "opensearch-py" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] From c9ef5011900a000e59ae1a41022a23d3d7e5da12 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Tue, 7 Apr 2026 12:02:04 -0700 Subject: [PATCH 3/3] Fix docstring length in reindex_votes.py Issue: LFXV2-1371 Signed-off-by: Trevor Bramwell --- scripts/reindexing/reindex_votes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/reindexing/reindex_votes.py b/scripts/reindexing/reindex_votes.py index 7165cee..5e56bde 100644 --- a/scripts/reindexing/reindex_votes.py +++ b/scripts/reindexing/reindex_votes.py @@ -39,7 +39,9 @@ class Stats: def fetch_all_by_type(client: httpx.Client, object_type: str) -> List[str]: - """Fetch all object_ids of the given type from OpenSearch using scroll pagination.""" + """Fetch all object_ids of the given type from OpenSearch using + scroll pagination. + """ ids: List[str] = [] scroll_id = None