diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 69514f9..1752765 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -30,18 +30,20 @@ jobs: - name: Generate distribution packages run: npm run package + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "0.7.*" + enable-cache: true - name: Install deployment environment id: install_deploy_env run: | # install deployment environment with eoapi-cdk from build - python -m venv .deployment_venv - source .deployment_venv/bin/activate - pip install dist/python/*.gz + uv sync --group deploy + uv pip install dist/python/*.gz cd integration_tests/cdk - pip install -r requirements.txt npm install - deactivate cd - # use short commit SHA to name stacks @@ -55,15 +57,12 @@ jobs: env: PROJECT_ID: ${{ steps.short-sha.outputs.sha }} run: | - source .deployment_venv/bin/activate - # synthesize the stack cd integration_tests/cdk - npx cdk synth --debug --all --require-approval never + uv run npx cdk synth --debug --all --require-approval never # deploy the stack - npx cdk deploy --ci --all --require-approval never - deactivate + uv run npx cdk deploy --ci --all --require-approval never cd - - name: Tear down any infrastructure @@ -75,10 +74,9 @@ jobs: # run this only if we find a 'cdk.out' directory, which means there might be things to tear down if [ -d "cdk.out" ]; then cd - - source .deployment_venv/bin/activate cd integration_tests/cdk # see https://github.com/aws/aws-cdk/issues/24946 # this didn't work : rm -f cdk.out/synth.lock # so we just duplicate the cdk output to cdk-destroy.out - npx cdk destroy --output cdk-destroy.out --ci --all --force + uv run npx cdk destroy --output cdk-destroy.out --ci --all --force fi diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml new file mode 100644 index 0000000..6baf89f --- /dev/null +++ b/.github/workflows/unit-tests.yaml @@ -0,0 +1,36 @@ +name: Unit tests + +on: + workflow_dispatch: + push: + branches: + - main + tags: + - 'v*' + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "0.7.*" + enable-cache: true + + - name: Install PostgreSQL and PostGIS + run: | + sudo apt-get update + sudo apt-get install -y postgresql postgresql-contrib postgresql-16-postgis-3 + sudo service postgresql start + pg_isready + + - name: Install dependencies + run: | + uv sync + + - name: Run tests + run: uv run pytest diff --git a/.npmignore b/.npmignore index b21d17f..29ae2ad 100644 --- a/.npmignore +++ b/.npmignore @@ -1,18 +1,122 @@ -# Exclude typescript source and config +# ==================== +# Source Files (exclude TypeScript sources, keep compiled JS and declarations) +# ==================== *.ts -tsconfig.json - -# Include javascript files and typescript declarations -!*.js !*.d.ts +!*.js +tsconfig.json +tsconfig.*.json +tsconfig.tsbuildinfo -# Exclude jsii outdir -dist - -# Include .jsii and .jsii.gz +# ==================== +# jsii-specific files (include these in package) +# ==================== !.jsii !.jsii.gz -# Exclude any dev environments -.venv +# ==================== +# Build and Distribution +# ==================== +dist/ +lib/**/*.ts +!lib/**/*.js +!lib/**/*.d.ts + +# ==================== +# Development and Testing +# ==================== +.git/ +.github/ +.gitignore +.pre-commit-config.yaml +.husky/ +.devcontainer/ +integration_tests/ +test/ +tests/ +__tests__/ +lib/**/tests/ +lib/**/test/ +lib/**/__tests__/ +*.test.js +*.test.ts +*.test.py +*.spec.js +*.spec.ts +*.spec.py +conftest.py +lib/**/conftest.py +.coverage/ +coverage/ +.nyc_output/ + +# ==================== +# Node.js and npm +# ==================== +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.node_repl_history +package-lock.json +yarn.lock + +# ==================== +# Python (for this hybrid project) +# ==================== +.venv/ +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.tox/ +toxenv/ +.coverage +.coverage.* +htmlcov/ +tox.ini +ruff.toml +.ruff_cache/ + +# ==================== +# Documentation and Demos +# ==================== +docs/ +diagrams/ +*.md +!README.md + +# ==================== +# IDE and Editor +# ==================== +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ==================== +# Semantic Release and CI/CD +# ==================== +.semantic-release/ +.travis.yml +.circleci/ +.github/ +CHANGELOG.md + +# ==================== +# Other Development Files +# ==================== +.nvmrc +.editorconfig +.eslintrc* +.prettierrc* +nodemon.json + + +# Exclude jsii outdir +dist diff --git a/integration_tests/cdk/app.py b/integration_tests/cdk/app.py index 2b8d896..d919679 100644 --- a/integration_tests/cdk/app.py +++ b/integration_tests/cdk/app.py @@ -1,10 +1,21 @@ -from aws_cdk import App, RemovalPolicy, Stack, aws_ec2, aws_iam, aws_rds +from aws_cdk import ( + App, + RemovalPolicy, + Stack, + aws_ec2, + aws_iam, + aws_rds, + aws_s3, + aws_s3_notifications, +) from config import AppConfig, build_app_config from constructs import Construct from eoapi_cdk import ( PgStacApiLambda, PgStacDatabase, StacIngestor, + StacItemLoader, + StactoolsItemGenerator, TiPgApiLambda, TitilerPgstacApiLambda, ) @@ -168,6 +179,37 @@ def __init__( }, ) + self.stac_item_loader = StacItemLoader( + self, + "stac-item-loader", + pgstac_db=pgstac_db, + batch_size=500, + lambda_timeout_seconds=300, + ) + + self.stac_item_generator = StactoolsItemGenerator( + self, + "stactools-item-generator", + item_load_topic_arn=self.stac_item_loader.topic.topic_arn, + ) + + self.stac_item_loader.topic.grant_publish( + self.stac_item_generator.lambda_function + ) + + stac_bucket = aws_s3.Bucket( + self, + "stac-item-bucket", + ) + + stac_bucket.add_event_notification( + aws_s3.EventType.OBJECT_CREATED, + aws_s3_notifications.SnsDestination(self.stac_item_loader.topic), + aws_s3.NotificationKeyFilter(suffix=".json"), + ) + + stac_bucket.grant_read(self.stac_item_loader.lambda_function) + app = App() diff --git a/integration_tests/cdk/package-lock.json b/integration_tests/cdk/package-lock.json index e400afb..ae56103 100644 --- a/integration_tests/cdk/package-lock.json +++ b/integration_tests/cdk/package-lock.json @@ -1,42 +1,43 @@ { - "name": "eoapi-template", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "eoapi-template", - "version": "0.1.0", - "dependencies": { - "aws-cdk": "2.130.0" - } - }, - "node_modules/aws-cdk": { - "version": "2.130.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.130.0.tgz", - "integrity": "sha512-MsjGzQ2kZv0FEfXvpW7FTJRnefew0GrYt9M2SMN2Yn45+yjugGl2X8to416kABeFz1OFqW56hq8Y5BiLuFDVLQ==", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 14.15.0" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - } + "name": "eoapi-template", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eoapi-template", + "version": "0.1.0", + "dependencies": { + "aws-cdk": "2.1016.1" + } + }, + "node_modules/aws-cdk": { + "version": "2.1016.1", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1016.1.tgz", + "integrity": "sha512-248TBiluT8jHUjkpzvWJOHv2fS+An9fiII3eji8H7jwfTu5yMBk7on4B/AVNr9A1GXJk9I32qf9Q0A3rLWRYPQ==", + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 14.15.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } } + } } diff --git a/integration_tests/cdk/package.json b/integration_tests/cdk/package.json index e5a5a6b..a76c6d2 100644 --- a/integration_tests/cdk/package.json +++ b/integration_tests/cdk/package.json @@ -1,7 +1,7 @@ { - "name": "eoapi-template", - "version": "0.1.0", - "dependencies": { - "aws-cdk": "2.130.0" - } + "name": "eoapi-template", + "version": "0.1.0", + "dependencies": { + "aws-cdk": "2.1016.1" } +} diff --git a/integration_tests/cdk/requirements.txt b/integration_tests/cdk/requirements.txt deleted file mode 100644 index 62d711f..0000000 --- a/integration_tests/cdk/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -aws-cdk-lib==2.130.0 -constructs==10.3.0 -pydantic==2.0.2 -pydantic-settings==2.0.1 -python-dotenv==1.0.0 -pyyaml==6.0.2 -types-PyYAML==6.0.12.10 diff --git a/lib/database/index.ts b/lib/database/index.ts index 2fbd8d4..34ab3d2 100644 --- a/lib/database/index.ts +++ b/lib/database/index.ts @@ -36,6 +36,7 @@ export class PgStacDatabase extends Construct { pgstacSecret: secretsmanager.ISecret; private _pgBouncerServer?: PgBouncer; + public readonly pgstacVersion: string; public readonly connectionTarget: rds.IDatabaseInstance | ec2.Instance; public readonly securityGroup?: ec2.SecurityGroup; public readonly secretBootstrapper?: CustomResource; @@ -68,7 +69,9 @@ export class PgStacDatabase extends Construct { parameterGroup, ...props, }); - const pgstac_version = props.pgstacVersion || DEFAULT_PGSTAC_VERSION; + + this.pgstacVersion = props.pgstacVersion || DEFAULT_PGSTAC_VERSION; + const handler = new aws_lambda.Function(this, "lambda", { // defaults runtime: aws_lambda.Runtime.PYTHON_3_11, @@ -80,7 +83,7 @@ export class PgStacDatabase extends Construct { file: "bootstrapper_runtime/Dockerfile", buildArgs: { PYTHON_VERSION: "3.11", - PGSTAC_VERSION: pgstac_version, + PGSTAC_VERSION: this.pgstacVersion, }, }), vpc: hasVpc(this.db) ? this.db.vpc : props.vpc, @@ -131,7 +134,7 @@ export class PgStacDatabase extends Construct { // if props.lambdaFunctionOptions doesn't have 'code' defined, update pgstac_version (needed for default runtime) if (!props.bootstrapperLambdaFunctionOptions?.code) { - customResourceProperties["pgstac_version"] = pgstac_version; + customResourceProperties["pgstac_version"] = this.pgstacVersion; } // add timestamp to properties to ensure the Lambda gets re-executed on each deploy diff --git a/lib/index.ts b/lib/index.ts index a1c5a45..e00a4e1 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,6 +1,8 @@ export * from "./bastion-host"; export * from "./database"; export * from "./ingestor-api"; +export * from "./stactools-item-generator"; +export * from "./stac-item-loader"; export * from "./stac-api"; export * from "./titiler-pgstac-api"; export * from "./stac-browser"; diff --git a/lib/stac-item-loader/index.ts b/lib/stac-item-loader/index.ts new file mode 100644 index 0000000..f37f21a --- /dev/null +++ b/lib/stac-item-loader/index.ts @@ -0,0 +1,436 @@ +import { + aws_lambda as lambda, + aws_sqs as sqs, + aws_sns as sns, + aws_sns_subscriptions as snsSubscriptions, + aws_lambda_event_sources as lambdaEventSources, + aws_logs as logs, + Duration, + CfnOutput, +} from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { PgStacDatabase } from "../database"; +import * as path from "path"; + +/** + * Configuration properties for the StacItemLoader construct. + * + * The StacItemLoader is part of a two-phase serverless STAC ingestion pipeline + * that loads STAC items into a pgstac database. This construct creates + * the infrastructure for receiving STAC items from multiple sources: + * 1. SNS messages containing STAC metadata (direct ingestion) + * 2. S3 event notifications for STAC items uploaded to S3 buckets + * + * Items from both sources are batched and inserted into PostgreSQL with the pgstac extension. + * + * @example + * const loader = new StacItemLoader(this, 'ItemLoader', { + * pgstacDb: database, + * batchSize: 1000, + * maxBatchingWindowMinutes: 1, + * lambdaTimeoutSeconds: 300 + * }); + */ +export interface StacItemLoaderProps { + /** + * The PgSTAC database instance to load items into. + * + * This database must have the pgstac extension installed and be properly + * configured with collections before items can be loaded. The loader will + * use AWS Secrets Manager to securely access database credentials. + */ + readonly pgstacDb: PgStacDatabase; + + /** + * The lambda runtime to use for the item loading function. + * + * The function is implemented in Python and uses pypgstac for database + * operations. Ensure the runtime version is compatible with the pgstac + * version specified in the database configuration. + * + * @default lambda.Runtime.PYTHON_3_11 + */ + readonly lambdaRuntime?: lambda.Runtime; + + /** + * The timeout for the item load lambda in seconds. + * + * This should accommodate the time needed to process up to `batchSize` + * items and perform database insertions. The SQS visibility timeout + * will be set to this value plus 10 seconds. + * + * @default 300 + */ + readonly lambdaTimeoutSeconds?: number; + + /** + * Memory size for the lambda function in MB. + * + * Higher memory allocation may improve performance when processing + * large batches of STAC items, especially for memory-intensive + * database operations. + * + * @default 1024 + */ + readonly memorySize?: number; + + /** + * SQS batch size for lambda event source. + * + * This determines the maximum number of STAC items that will be + * processed together in a single lambda invocation. Larger batch + * sizes improve database insertion efficiency but require more + * memory and longer processing time. + * + * **Batching Behavior**: SQS will wait to accumulate up to this many + * messages before triggering the Lambda, OR until the maxBatchingWindow + * timeout is reached, whichever comes first. This creates an efficient + * balance between throughput and latency. + * + * @default 500 + */ + readonly batchSize?: number; + + /** + * Maximum batching window in minutes. + * + * Even if the batch size isn't reached, the lambda will be triggered + * after this time period to ensure timely processing of items. + * This prevents items from waiting indefinitely in low-volume scenarios. + * + * **Important**: This timeout works in conjunction with batchSize - SQS + * will trigger the Lambda when EITHER the batch size is reached OR this + * time window expires, ensuring items are processed in a timely manner + * regardless of volume. + * + * @default 1 + */ + readonly maxBatchingWindowMinutes?: number; + + /** + * Maximum concurrent executions for the StacItemLoader Lambda function + * + * This limit will be applied to the Lambda function and will control how + * many concurrent batches will be released from the SQS queue. + * + * @default 2 + */ + readonly maxConcurrency?: number; + + /** + * Additional environment variables for the lambda function. + * + * These will be merged with the default environment variables including + * PGSTAC_SECRET_ARN. Use this for custom configuration or debugging flags. + */ + readonly environment?: { [key: string]: string }; +} + +/** + * AWS CDK Construct for STAC Item Loading Infrastructure + * + * The StacItemLoader creates a serverless, event-driven system for loading + * STAC (SpatioTemporal Asset Catalog) items into a PostgreSQL database with + * the pgstac extension. This construct supports multiple ingestion pathways + * for flexible STAC item loading. + * + * ## Architecture Overview + * + * This construct creates the following AWS resources: + * - **SNS Topic**: Entry point for STAC items and S3 event notifications + * - **SQS Queue**: Buffers and batches messages before processing (60-second visibility timeout) + * - **Dead Letter Queue**: Captures failed loading attempts after 5 retries + * - **Lambda Function**: Python function that processes batches and inserts items into pgstac + * + * ## Data Flow + * + * The loader supports two primary data ingestion patterns: + * + * ### Direct STAC Item Publishing + * 1. STAC items (JSON) are published directly to the SNS topic in message bodies + * 2. The SQS queue collects messages and batches them (up to {batchSize} items or 1 minute window) + * 3. The Lambda function receives batches, validates items, and inserts into pgstac + * + * ### S3 Event-Driven Loading + * 1. An S3 bucket is configured to send notifications to the SNS topic when json files are created + * 2. STAC items are uploaded to S3 buckets as JSON/GeoJSON files + * 3. S3 event notifications are sent to the SNS topic when items are uploaded + * 4. The Lambda function receives S3 events in the SQS message batch, fetches items from S3, and loads into pgstac + * + * ## Batching Behavior + * + * The SQS-to-Lambda integration uses intelligent batching to optimize performance: + * + * - **Batch Size**: Lambda waits to receive up to `batchSize` messages (default: 500) + * - **Batching Window**: If fewer than `batchSize` messages are available, Lambda + * triggers after `maxBatchingWindow` minutes (default: 1 minute) + * - **Trigger Condition**: Lambda executes when EITHER condition is met first + * - **Concurrency**: Limited to `maxConcurrency` concurrent executions to prevent database overload + * - **Partial Failures**: Uses `reportBatchItemFailures` to retry only failed items + * + * This approach balances throughput (larger batches = fewer database connections) + * with latency (time-based triggers prevent indefinite waiting). + * + * ## Error Handling and Dead Letter Queue + * + * Failed messages are sent to the dead letter queue after 5 processing attempts. + * **Important**: This construct provides NO automated handling of dead letter queue + * messages - monitoring, inspection, and reprocessing of failed items is the + * responsibility of the implementing application. + * + * Consider implementing: + * - CloudWatch alarms on dead letter queue depth + * - Manual or automated reprocessing workflows + * - Logging and alerting for failed items + * - Regular cleanup of old dead letter messages (14-day retention) + * + * ## Operational Characteristics + * + * - **Scalability**: Lambda scales automatically based on queue depth + * - **Reliability**: Dead letter queue captures failures for debugging + * - **Efficiency**: Batching optimizes database operations for high throughput + * - **Security**: Database credentials accessed via AWS Secrets Manager + * - **Observability**: CloudWatch logs retained for one week + * + * ## Prerequisites + * + * Before using this construct, ensure: + * - The pgstac database has collections loaded (items require existing collection IDs) + * - Database credentials are stored in AWS Secrets Manager + * - The pgstac extension is properly installed and configured + * + * ## Usage Example + * + * ```typescript + * // Create database first + * const database = new PgStacDatabase(this, 'Database', { + * pgstacVersion: '0.9.5' + * }); + * + * // Create item loader + * const loader = new StacItemLoader(this, 'ItemLoader', { + * pgstacDb: database, + * batchSize: 1000, // Process up to 1000 items per batch + * maxBatchingWindowMinutes: 1, // Wait max 1 minute to fill batch + * lambdaTimeoutSeconds: 300 // Allow up to 300 seconds for database operations + * }); + * + * // The topic ARN can be used by other services to publish items + * new CfnOutput(this, 'LoaderTopicArn', { + * value: loader.topic.topicArn + * }); + * ``` + * + * ## Direct Item Publishing + * + * External services can publish STAC items directly to the topic: + * + * ```bash + * aws sns publish --topic-arn $ITEM_LOAD_TOPIC --message '{ + * "type": "Feature", + * "stac_version": "1.0.0", + * "id": "example-item", + * "properties": {"datetime": "2021-01-01T00:00:00Z"}, + * "geometry": {"type": "Polygon", "coordinates": [...]}, + * "collection": "example-collection" + * }' + * ``` + * + * ## S3 Event Configuration + * + * To enable S3 event-driven loading, configure S3 bucket notifications to send + * events to the SNS topic when STAC items (.json or .geojson files) are uploaded: + * + * ```typescript + * // Configure S3 bucket to send notifications to the loader topic + * bucket.addEventNotification( + * s3.EventType.OBJECT_CREATED, + * new s3n.SnsDestination(loader.topic), + * { suffix: '.json' } + * ); + * + * bucket.addEventNotification( + * s3.EventType.OBJECT_CREATED, + * new s3n.SnsDestination(loader.topic), + * { suffix: '.geojson' } + * ); + * ``` + * + * When STAC items are uploaded to the configured S3 bucket, the loader will: + * 1. Receive S3 event notifications via SNS + * 2. Fetch the STAC item JSON from S3 + * 3. Validate and load the item into the pgstac database + * + * ## Monitoring and Troubleshooting + * + * - Monitor Lambda logs: `/aws/lambda/{FunctionName}` + * - **Dead Letter Queue**: Check for failed items - **no automated handling provided** + * - Use batch item failure reporting for partial batch processing + * - CloudWatch metrics available for queue depth and Lambda performance + * + * ### Dead Letter Queue Management + * + * Applications must implement their own dead letter queue monitoring: + * + * ```typescript + * // Example: CloudWatch alarm for dead letter queue depth + * new cloudwatch.Alarm(this, 'DeadLetterAlarm', { + * metric: loader.deadLetterQueue.metricApproximateNumberOfVisibleMessages(), + * threshold: 1, + * evaluationPeriods: 1 + * }); + * + * // Example: Lambda to reprocess dead letter messages + * const reprocessFunction = new lambda.Function(this, 'Reprocess', { + * // Implementation to fetch and republish failed messages + * }); + * ``` + * + */ +export class StacItemLoader extends Construct { + /** + * The SNS topic that receives STAC items and S3 event notifications for loading. + * + * This topic serves as the entry point for two types of events: + * 1. Direct STAC item JSON documents published by external services + * 2. S3 event notifications when STAC items are uploaded to configured buckets + * + * The topic fans out to the SQS queue for batched processing. + */ + public readonly topic: sns.Topic; + + /** + * The SQS queue that buffers messages before processing. + * + * This queue collects both direct STAC items from SNS and S3 event + * notifications, batching them for efficient database operations. + * Configured with a visibility timeout that accommodates Lambda + * processing time plus buffer. + */ + public readonly queue: sqs.Queue; + + /** + * Dead letter queue for failed item loading attempts. + * + * Messages that fail processing after 5 attempts are sent here + * for inspection and potential replay. Retains messages for 14 days + * to allow for debugging and manual intervention. + * + * **User Responsibility**: This construct provides NO automated monitoring, + * alerting, or reprocessing of dead letter queue messages. Applications + * using this construct must implement their own: + * - Dead letter queue depth monitoring and alerting + * - Failed message inspection and debugging workflows + * - Manual or automated reprocessing mechanisms + * - Cleanup procedures for old failed messages + */ + public readonly deadLetterQueue: sqs.Queue; + + /** + * The Lambda function that loads STAC items into the pgstac database. + * + * This Python function receives batches of messages from SQS and processes + * them based on their type: + * - Direct STAC items: Validates and loads directly into pgstac + * - S3 events: Fetches STAC items from S3, validates, and loads into pgstac + * + * The function connects to PostgreSQL using credentials from Secrets Manager + * and uses pypgstac for efficient database operations. + */ + public readonly lambdaFunction: lambda.Function; + + constructor(scope: Construct, id: string, props: StacItemLoaderProps) { + super(scope, id); + + const timeoutSeconds = props.lambdaTimeoutSeconds ?? 300; + const lambdaRuntime = props.lambdaRuntime ?? lambda.Runtime.PYTHON_3_11; + const maxConcurrency = props.maxConcurrency ?? 2; + + // Create dead letter queue + this.deadLetterQueue = new sqs.Queue(this, "DeadLetterQueue", { + retentionPeriod: Duration.days(14), + }); + + // Create main queue + this.queue = new sqs.Queue(this, "Queue", { + visibilityTimeout: Duration.seconds(timeoutSeconds + 10), + encryption: sqs.QueueEncryption.SQS_MANAGED, + deadLetterQueue: { + maxReceiveCount: 5, + queue: this.deadLetterQueue, + }, + }); + + // Create SNS topic + this.topic = new sns.Topic(this, "Topic", { + displayName: `${id}-StacItemLoaderTopic`, + }); + + // Subscribe the queue to the topic + this.topic.addSubscription( + new snsSubscriptions.SqsSubscription(this.queue) + ); + + // Create the lambda function + this.lambdaFunction = new lambda.Function(this, "Function", { + runtime: lambdaRuntime, + handler: "stac_item_loader.handler.handler", + code: lambda.Code.fromDockerBuild(path.join(__dirname, ".."), { + file: "stac-item-loader/runtime/Dockerfile", + platform: "linux/amd64", + buildArgs: { + PYTHON_VERSION: lambdaRuntime.toString().replace("python", ""), + PGSTAC_VERSION: props.pgstacDb.pgstacVersion, + }, + }), + memorySize: props.memorySize ?? 1024, + timeout: Duration.seconds(timeoutSeconds), + reservedConcurrentExecutions: maxConcurrency, + logRetention: logs.RetentionDays.ONE_WEEK, + environment: { + PGSTAC_SECRET_ARN: props.pgstacDb.pgstacSecret.secretArn, + ...props.environment, + }, + }); + + // Grant permissions to read the database secret + props.pgstacDb.pgstacSecret.grantRead(this.lambdaFunction); + + // Add SQS event source to the lambda + this.lambdaFunction.addEventSource( + new lambdaEventSources.SqsEventSource(this.queue, { + batchSize: props.batchSize ?? 500, + maxBatchingWindow: Duration.minutes( + props.maxBatchingWindowMinutes ?? 1 + ), + maxConcurrency: maxConcurrency, + reportBatchItemFailures: true, + }) + ); + + // Create outputs + new CfnOutput(this, "TopicArn", { + value: this.topic.topicArn, + description: "ARN of the StacItemLoader SNS Topic", + exportName: "stac-item-loader-topic-arn", + }); + + new CfnOutput(this, "QueueUrl", { + value: this.queue.queueUrl, + description: "URL of the StacItemLoader SQS Queue", + exportName: "stac-item-loader-queue-url", + }); + + new CfnOutput(this, "DeadLetterQueueUrl", { + value: this.deadLetterQueue.queueUrl, + description: "URL of the StacItemLoader Dead Letter Queue", + exportName: "stac-item-loader-deadletter-queue-url", + }); + + new CfnOutput(this, "FunctionName", { + value: this.lambdaFunction.functionName, + description: "Name of the StacItemLoader Lambda Function", + exportName: "stac-item-loader-function-name", + }); + } +} diff --git a/lib/stac-item-loader/runtime/Dockerfile b/lib/stac-item-loader/runtime/Dockerfile new file mode 100644 index 0000000..522bdc5 --- /dev/null +++ b/lib/stac-item-loader/runtime/Dockerfile @@ -0,0 +1,18 @@ +ARG PYTHON_VERSION=3.11 +FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} +COPY --from=ghcr.io/astral-sh/uv:0.7.8 /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /asset + +COPY stac-item-loader/runtime/pyproject.toml pyproject.toml +COPY stac-item-loader/runtime/src/stac_item_loader/ stac_item_loader/ + +ARG PGSTAC_VERSION=0.9.6 +RUN uv add --no-sync pypgstac==${PGSTAC_VERSION} && \ + uv export --no-dev --no-editable -o requirements.txt && \ + uv pip install --target /asset -r requirements.txt + +CMD ["stac_item_loader.handler.handler"] diff --git a/lib/stac-item-loader/runtime/pyproject.toml b/lib/stac-item-loader/runtime/pyproject.toml new file mode 100644 index 0000000..8d74e4d --- /dev/null +++ b/lib/stac-item-loader/runtime/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "stac-item-loader" +version = "0.1.0" +description = "An application for loading STAC items into a pgstac database" +authors = [ + { name = "hrodmn", email = "henry@developmentseed.org" } +] +requires-python = ">=3.11" +dependencies = [ + "boto3", + "pypgstac[psycopg]", + "stac-pydantic>=3.2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/lib/stac-item-loader/runtime/src/stac_item_loader/__init__.py b/lib/stac-item-loader/runtime/src/stac_item_loader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/stac-item-loader/runtime/src/stac_item_loader/handler.py b/lib/stac-item-loader/runtime/src/stac_item_loader/handler.py new file mode 100644 index 0000000..270a968 --- /dev/null +++ b/lib/stac-item-loader/runtime/src/stac_item_loader/handler.py @@ -0,0 +1,241 @@ +import base64 +import json +import logging +import os +from collections import defaultdict +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + DefaultDict, + Dict, + List, + Optional, + TypedDict, +) + +import boto3.session +from pydantic import ValidationError +from pypgstac.db import PgstacDB +from pypgstac.load import Loader, Methods +from stac_pydantic.item import Item + +if TYPE_CHECKING: + from aws_lambda_typing.context import Context +else: + Context = Annotated[object, "Context object"] + +logger = logging.getLogger() +if logger.hasHandlers(): + logger.handlers.clear() + +log_handler = logging.StreamHandler() # <--- Renamed handler variable + +log_level_name = os.environ.get("LOG_LEVEL", "INFO").upper() +log_level = logging._nameToLevel.get(log_level_name, logging.INFO) +logger.setLevel(log_level) + +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +log_handler.setFormatter(formatter) +logger.addHandler(log_handler) + +botocore_logger = logging.getLogger("botocore") +botocore_logger.setLevel(logging.WARN) + + +class BatchItemFailure(TypedDict): + itemIdentifier: str + + +class PartialBatchFailureResponse(TypedDict): + batchItemFailures: List[BatchItemFailure] + + +def get_secret_dict(secret_name: str) -> Dict: + """Retrieve secrets from AWS Secrets Manager + + Args: + secret_name (str): name of aws secrets manager secret containing database connection secrets + profile_name (str, optional): optional name of aws profile for use in debugger only + + Returns: + secrets (dict): decrypted secrets in dict + """ + + # Create a Secrets Manager client + session = boto3.session.Session() + client = session.client(service_name="secretsmanager") + + get_secret_value_response = client.get_secret_value(SecretId=secret_name) + + if "SecretString" in get_secret_value_response: + return json.loads(get_secret_value_response["SecretString"]) + else: + return json.loads(base64.b64decode(get_secret_value_response["SecretBinary"])) + + +def get_pgstac_dsn() -> str: + secret_arn = os.getenv("PGSTAC_SECRET_ARN") + if not secret_arn: + logger.error("Environment variable PGSTAC_SECRET_ARN is not set.") + raise EnvironmentError("PGSTAC_SECRET_ARN must be set") + + secret_dict = get_secret_dict(secret_name=secret_arn) + + return f"postgres://{secret_dict['username']}:{secret_dict['password']}@{secret_dict['host']}:{secret_dict['port']}/{secret_dict['dbname']}" + + +def is_s3_event(message_str: str) -> bool: + """Check if the event data is an S3 event notification.""" + return "aws:s3" in message_str + + +def get_stac_item_from_s3(bucket_name: str, object_key: str) -> Dict[str, Any]: + """Fetch STAC item JSON from S3.""" + session = boto3.session.Session() + s3_client = session.client("s3") + + try: + logger.debug(f"Fetching STAC item from s3://{bucket_name}/{object_key}") + response = s3_client.get_object(Bucket=bucket_name, Key=object_key) + content = response["Body"].read() + + try: + stac_item_json = content.decode("utf-8") + except UnicodeDecodeError as e: + logger.error( + f"Failed to decode S3 object as UTF-8: s3://{bucket_name}/{object_key}" + ) + raise ValueError("S3 object is not valid UTF-8 text") from e + + stac_item_data = json.loads(stac_item_json) + logger.debug( + f"Successfully parsed STAC item from S3: {stac_item_data.get('id', 'unknown')}" + ) + + return stac_item_data + + except Exception as e: + logger.error( + f"Failed to fetch STAC item from s3://{bucket_name}/{object_key}: {e}" + ) + raise + + +def process_s3_event(message_str: str) -> Dict[str, Any]: + """Process an S3 event notification and return STAC item data.""" + try: + message_data = json.loads(message_str) + records: List[Dict[str, Any]] = message_data.get("Records", []) + if not records: + raise ValueError("no S3 event records!") + elif len(records) > 1: + raise ValueError("more than one S3 event record!") + + s3_data = records[0]["s3"] + bucket_name = s3_data["bucket"]["name"] + object_key = s3_data["object"]["key"] + + # Validate that this looks like a STAC item file + if not object_key.endswith((".json", ".geojson")): + raise ValueError( + f"S3 object key does not appear to be a STAC item: {object_key}" + ) + + stac_item_data = get_stac_item_from_s3(bucket_name, object_key) + + return stac_item_data + + except KeyError as e: + logger.error(f"S3 event missing required field: {e}") + raise ValueError(f"Invalid S3 event structure: missing {e}") from e + except Exception as e: + logger.error(f"Failed to process S3 event: {e}") + raise + + +def handler( + event: Dict[str, Any], context: Context +) -> Optional[PartialBatchFailureResponse]: + records = event.get("Records", []) + aws_request_id = getattr(context, "aws_request_id", "N/A") + remaining_time = getattr(context, "get_remaining_time_in_millis", lambda: "N/A")() + + logger.info(f"Received batch with {len(records)} records.") + logger.debug( + f"Lambda Context: RequestId={aws_request_id}, RemainingTime={remaining_time}ms" + ) + pgstac_dsn = get_pgstac_dsn() + + batch_item_failures: List[BatchItemFailure] = [] + + items_by_collection: DefaultDict[str, List[Dict[str, Any]]] = defaultdict(list) + message_ids_by_collection: DefaultDict[str, List[str]] = defaultdict(list) + + for record in records: + message_id = record.get("messageId") + if not message_id: + logger.warning("Record missing messageId, cannot report failure for it.") + continue + + try: + sqs_body_str = record["body"] + logger.debug(f"[{message_id}] SQS message body: {sqs_body_str}") + sns_notification = json.loads(sqs_body_str) + + message_str = sns_notification["Message"] + logger.debug(f"[{message_id}] SNS Message content: {message_str}") + + if is_s3_event(message_str): + logger.debug(f"[{message_id}] Processing S3 event notification") + message_data = process_s3_event(message_str) + else: + message_data = json.loads(message_str) + + item = Item(**message_data) + + if not item.collection: + raise KeyError(f"item {item.id} is missing a collection id!") + + items_by_collection[item.collection].append(item.model_dump(mode="json")) + message_ids_by_collection[item.collection].append(message_id) + logger.debug(f"[{message_id}] Successfully processed.") + + except (ValueError, KeyError, ValidationError, json.JSONDecodeError) as e: + logger.error(f"[{message_id}] Failed with error: {e}", extra=record) + batch_item_failures.append({"itemIdentifier": message_id}) + except Exception as e: + logger.error(f"[{message_id}] Unexpected error: {e}", extra=record) + batch_item_failures.append({"itemIdentifier": message_id}) + + for collection_id, items in items_by_collection.items(): + try: + with PgstacDB(dsn=pgstac_dsn) as db: + loader = Loader(db=db) + logger.info(f"[{collection_id}] loading items into database.") + loader.load_items( + file=items, # type: ignore + insert_mode=Methods.upsert, + ) + logger.info(f"[{collection_id}] successfully loaded {len(items)} items.") + except Exception as e: + logger.error(f"[{collection_id}] failed to load items: {str(e)}") + + batch_item_failures.extend( + [ + {"itemIdentifier": message_id} + for message_id in message_ids_by_collection[collection_id] + ] + ) + + if batch_item_failures: + logger.warning( + f"Finished processing batch. {len(batch_item_failures)} failure(s) reported." + ) + logger.info( + f"Returning failed item identifiers: {[f['itemIdentifier'] for f in batch_item_failures]}" + ) + return {"batchItemFailures": batch_item_failures} + else: + logger.info("Finished processing batch. All records successful.") + return None diff --git a/lib/stac-item-loader/runtime/tests/conftest.py b/lib/stac-item-loader/runtime/tests/conftest.py new file mode 100644 index 0000000..1ebb786 --- /dev/null +++ b/lib/stac-item-loader/runtime/tests/conftest.py @@ -0,0 +1,164 @@ +from unittest.mock import patch + +import pytest +from pypgstac.db import PgstacDB +from pypgstac.load import Loader +from pypgstac.migrate import Migrate +from pytest_postgresql.janitor import DatabaseJanitor +from stac_pydantic.collection import Collection, Extent, SpatialExtent, TimeInterval +from stac_pydantic.links import Link, Links + +TEST_COLLECTION_IDS = ["test-collection-1", "test-collection-2"] + + +class MockContext: + """Mock AWS Lambda context""" + + def __init__(self): + self.aws_request_id = "test-request-id" + + def get_remaining_time_in_millis(self): + return 30000 + + +@pytest.fixture(scope="session") +def database(postgresql_proc): + """Create Database Fixture.""" + with DatabaseJanitor( + user=postgresql_proc.user, + host=postgresql_proc.host, + port=postgresql_proc.port, + dbname="test_db", + version=postgresql_proc.version, + password="password", + ) as jan: + yield jan + + +@pytest.fixture(scope="session") +def database_url(database): + """Install pgstac on the database and load a collection""" + db_url = f"postgresql://{database.user}:{database.password}@{database.host}:{database.port}/{database.dbname}" + + with PgstacDB(dsn=db_url) as db: + migrator = Migrate(db) + migrator.run_migration() + + test_collections = [ + Collection( + id=collection_id, + description="test", + stac_version="1.1.0", + links=Links([Link(href="http://test/test-collection", rel="self")]), + extent=Extent( + spatial=SpatialExtent(bbox=[[0, 0, 1, 1]]), + temporal=TimeInterval( + interval=[["2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z"]] + ), + ), + type="Collection", + license="license", + ).model_dump(mode="json") + for collection_id in TEST_COLLECTION_IDS + ] + + loader = Loader(db) + loader.load_collections(test_collections) # type: ignore + + return db_url + + +@pytest.fixture +def mock_aws_context(): + return MockContext() + + +@pytest.fixture +def mock_pgstac_dsn(database_url): + """Mock the get_pgstac_dsn function to return the test database URL""" + with patch("stac_item_loader.handler.get_pgstac_dsn", return_value=database_url): + yield + + +@pytest.fixture(autouse=True) +def cleanup_test_items(database_url): + """ + Fixture to clean up test items after each test. + The autouse=True makes this run automatically for each test. + """ + yield + + # After the test completes, clean up items that match our test pattern + with PgstacDB(dsn=database_url) as db: + query = """ + DELETE FROM pgstac.items + """ + db.query(query) + + +def check_item_exists(database_url, collection_id, item_id): + """ + Check if an item with the given ID exists in the specified collection. + + Args: + database_url: Connection string for the database + collection_id: The collection ID to check + item_id: The item ID to check + + Returns: + bool: True if the item exists, False otherwise + """ + with PgstacDB(dsn=database_url) as db: + # Direct SQL query to check if the item exists + query = """ + SELECT COUNT(*) + FROM pgstac.items + WHERE collection = %s AND id = %s + """ + + result = list(db.query(query, (collection_id, item_id))) + return result[0][0] > 0 + + +def get_all_collection_items(database_url, collection_id): + """ + Get all items in a collection from the database. + + Args: + database_url: Connection string for the database + collection_id: The collection ID to query + + Returns: + list: List of item dictionaries + """ + with PgstacDB(dsn=database_url) as db: + query = """ + SELECT id, collection, content + FROM pgstac.items + WHERE collection = %s + """ + + result = list(db.query(query, (collection_id,))) + return [{"id": row[0], "collection": row[1], "content": row[2]} for row in result] + + +def count_collection_items(database_url, collection_id): + """ + Count the number of items in a collection. + + Args: + database_url: Connection string for the database + collection_id: The collection ID to count items for + + Returns: + int: Number of items in the collection + """ + with PgstacDB(dsn=database_url) as db: + query = """ + SELECT COUNT(*) + FROM pgstac.items + WHERE collection = %s + """ + + result = list(db.query(query, (collection_id,))) + return result[0][0] diff --git a/lib/stac-item-loader/runtime/tests/test_item_load_handler.py b/lib/stac-item-loader/runtime/tests/test_item_load_handler.py new file mode 100644 index 0000000..468d407 --- /dev/null +++ b/lib/stac-item-loader/runtime/tests/test_item_load_handler.py @@ -0,0 +1,783 @@ +import json +import os +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from conftest import ( + TEST_COLLECTION_IDS, + check_item_exists, + count_collection_items, + get_all_collection_items, +) +from stac_item_loader.handler import get_pgstac_dsn, handler + + +def create_sqs_record(item_data, message_id="test-message-id"): + """Helper to create a mock SQS record with SNS message""" + sns_message = {"Message": json.dumps(item_data)} + return {"messageId": message_id, "body": json.dumps(sns_message)} + + +def create_valid_stac_item(collection_id=TEST_COLLECTION_IDS[0], item_id="test-item"): + """Create a valid STAC item""" + return { + "id": item_id, + "type": "Feature", + "collection": collection_id, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + "bbox": [0, 0, 1, 1], + "properties": { + "datetime": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + }, + "assets": {}, + "links": [], + } + + +def test_get_pgstac_dsn_missing_env_var(): + """Test get_pgstac_dsn when environment variable is missing""" + # Save current env var if it exists + original_value = os.environ.get("PGSTAC_SECRET_ARN") + + # Remove the env var + if "PGSTAC_SECRET_ARN" in os.environ: + del os.environ["PGSTAC_SECRET_ARN"] + + # Should raise an error + with pytest.raises(EnvironmentError): + get_pgstac_dsn() + + # Restore original value if it existed + if original_value is not None: + os.environ["PGSTAC_SECRET_ARN"] = original_value + + +def test_handler_with_valid_item(mock_aws_context, mock_pgstac_dsn, database_url): + """Test handler with a valid STAC item""" + # Create a valid STAC item for our test collection + collection_id = TEST_COLLECTION_IDS[0] + item_id = "test-item" + valid_item = create_valid_stac_item(collection_id=collection_id, item_id=item_id) + + # Create event with one record + event = {"Records": [create_sqs_record(valid_item, message_id="test-message-1")]} + + # Call handler + result = handler(event, mock_aws_context) + + # Check result - should be None for successful processing + assert result is None + + # Verify the item was added to the database + assert check_item_exists( + database_url, collection_id, item_id + ), "Item was not found in the database" + + +def test_handler_with_valid_items_multiple_collections( + mock_aws_context, mock_pgstac_dsn, database_url +): + """Test handler with a valid STAC items for multiple collections""" + # Create items with unique IDs for easier verification + items = [] + for i, collection_id in enumerate(TEST_COLLECTION_IDS): + item_id = f"multi-collection-test-item-{i}" + items.append( + ( + collection_id, + item_id, + create_valid_stac_item(collection_id=collection_id, item_id=item_id), + ) + ) + + # Create event with one record per collection + event = { + "Records": [ + create_sqs_record( + item_data, + message_id=f"test-message-{i}", + ) + for i, (_, _, item_data) in enumerate(items) + ] + } + + # Call handler + result = handler(event, mock_aws_context) + + # Check result - should be None for successful processing + assert result is None + + # Verify all items were added to their respective collections + for collection_id, item_id, _ in items: + assert check_item_exists( + database_url, collection_id, item_id + ), f"Item {item_id} was not found in collection {collection_id}" + + +def test_handler_with_invalid_item(mock_aws_context, mock_pgstac_dsn): + """Test handler with an invalid STAC item (missing collection)""" + # Create an invalid STAC item (missing collection) + invalid_item = create_valid_stac_item() + del invalid_item["collection"] # Make it invalid + + message_id = "test-invalid-message" + event = {"Records": [create_sqs_record(invalid_item, message_id=message_id)]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should return a failure response + assert result is not None + assert "batchItemFailures" in result + # The message ID of the failed item should be in the response + assert any( + failure["itemIdentifier"] == message_id for failure in result["batchItemFailures"] + ) + + +def test_handler_with_multiple_items(mock_aws_context, mock_pgstac_dsn, database_url): + """Test handler with multiple valid STAC items""" + collection_id = TEST_COLLECTION_IDS[0] + + # Create multiple valid items + item_ids = [f"test-item-{i}" for i in range(3)] + items = [ + create_valid_stac_item(item_id=item_id, collection_id=collection_id) + for item_id in item_ids + ] + + # Create event with multiple records + event = { + "Records": [ + create_sqs_record(item, message_id=f"test-message-{i}") + for i, item in enumerate(items) + ] + } + + # Get initial count of items in the collection + initial_count = count_collection_items(database_url, collection_id) + + # Call handler + result = handler(event, mock_aws_context) + + # All should succeed + assert result is None + + # Verify all items were added + for item_id in item_ids: + assert check_item_exists( + database_url, collection_id, item_id + ), f"Item {item_id} was not found in the database" + + # Verify the count increased by the expected amount + new_count = count_collection_items(database_url, collection_id) + assert new_count == initial_count + len( + items + ), f"Expected {initial_count + len(items)} items, but found {new_count}" + + +def test_handler_with_mixed_items(mock_aws_context, mock_pgstac_dsn, database_url): + """Test handler with a mix of valid and invalid items""" + collection_id = TEST_COLLECTION_IDS[0] + valid_item_id = "valid-test-item" + invalid_item_id = "invalid-item" + + # Create one valid and one invalid item + valid_item = create_valid_stac_item( + collection_id=collection_id, item_id=valid_item_id + ) + + invalid_item = create_valid_stac_item(item_id=invalid_item_id) + del invalid_item["collection"] # Make it invalid + + valid_message_id = "valid-message" + invalid_message_id = "invalid-message" + + event = { + "Records": [ + create_sqs_record(valid_item, message_id=valid_message_id), + create_sqs_record(invalid_item, message_id=invalid_message_id), + ] + } + + # Get initial count + initial_count = count_collection_items(database_url, collection_id) + + # Call handler + result = handler(event, mock_aws_context) + + # Should have a partial failure + assert result is not None + assert "batchItemFailures" in result + failures = [f["itemIdentifier"] for f in result["batchItemFailures"]] + assert invalid_message_id in failures + assert valid_message_id not in failures + + # Verify only the valid item was added + assert check_item_exists( + database_url, collection_id, valid_item_id + ), "Valid item was not found in the database" + + # Verify count increased by exactly 1 + new_count = count_collection_items(database_url, collection_id) + assert ( + new_count == initial_count + 1 + ), f"Expected {initial_count + 1} items, but found {new_count}" + + +def test_handler_with_empty_event(mock_aws_context, mock_pgstac_dsn): + """Test handler with an empty event""" + event = {"Records": []} + + # Call handler + result = handler(event, mock_aws_context) + + # Should succeed with no failures + assert result is None + + +def test_handler_with_malformed_sqs_message(mock_aws_context, mock_pgstac_dsn): + """Test handler with a malformed SQS message""" + # Create a record with invalid JSON in the body + record = {"messageId": "malformed-message", "body": "{not valid json"} + + event = {"Records": [record]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "malformed-message" for f in result["batchItemFailures"] + ) + + +@pytest.mark.parametrize("missing_field", ["id", "type", "geometry", "properties"]) +def test_handler_with_missing_required_fields( + mock_aws_context, mock_pgstac_dsn, missing_field +): + """Test handler with items missing required STAC fields""" + item = create_valid_stac_item(collection_id=TEST_COLLECTION_IDS[0]) + + # Remove a required field + if missing_field == "properties.datetime": + del item["properties"]["datetime"] + else: + del item[missing_field] + + message_id = f"missing-{missing_field}" + event = {"Records": [create_sqs_record(item, message_id=message_id)]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any(f["itemIdentifier"] == message_id for f in result["batchItemFailures"]) + + +def test_handler_with_nonexistent_collection(mock_aws_context, mock_pgstac_dsn): + """Test handler with an item referencing a collection that doesn't exist""" + # Create an item with a non-existent collection + item = create_valid_stac_item(collection_id="nonexistent-collection") + + message_id = "nonexistent-collection-message" + event = {"Records": [create_sqs_record(item, message_id=message_id)]} + + # Call handler + result = handler(event, mock_aws_context) + + # This might pass or fail depending on if pypgstac enforces collection existence + # If it fails validation: + assert result + assert "batchItemFailures" in result + assert any(f["itemIdentifier"] == message_id for f in result["batchItemFailures"]) + + +@patch("stac_item_loader.handler.PgstacDB") +def test_handler_with_database_connection_error( + mock_pgstac_db, mock_aws_context, mock_pgstac_dsn +): + """Test handler when database connection fails""" + # Make the database connection raise an exception + mock_pgstac_db.side_effect = Exception("Database connection error") + + # Create a valid item + valid_item = create_valid_stac_item(collection_id=TEST_COLLECTION_IDS[0]) + + message_id = "db-error-message" + event = {"Records": [create_sqs_record(valid_item, message_id=message_id)]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any(f["itemIdentifier"] == message_id for f in result["batchItemFailures"]) + + +@patch("pypgstac.load.Loader.load_items") +def test_handler_with_load_error(mock_load_items, mock_aws_context, mock_pgstac_dsn): + """Test handler when item loading fails""" + # Make the load_items method raise an exception + mock_load_items.side_effect = Exception("Failed to load items") + + # Create a valid item + valid_item = create_valid_stac_item(collection_id=TEST_COLLECTION_IDS[0]) + + message_id = "load-error-message" + event = {"Records": [create_sqs_record(valid_item, message_id=message_id)]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any(f["itemIdentifier"] == message_id for f in result["batchItemFailures"]) + + +def test_handler_upsert_existing_item(mock_aws_context, mock_pgstac_dsn, database_url): + """Test handler correctly updates an existing item via upsert""" + collection_id = TEST_COLLECTION_IDS[0] + item_id = "upsert-test-item" + + # Create initial item + initial_item = create_valid_stac_item(collection_id=collection_id, item_id=item_id) + + # Add a specific property to identify the first version + initial_item["properties"]["version"] = "1.0" + + # Insert the initial item + event = {"Records": [create_sqs_record(initial_item, message_id="initial-insert")]} + handler(event, mock_aws_context) + + # Verify the initial item was inserted + assert check_item_exists(database_url, collection_id, item_id) + + # Get the inserted item to verify its properties + items = get_all_collection_items(database_url, collection_id) + initial_db_item = next((item for item in items if item["id"] == item_id), None) + assert initial_db_item is not None + assert initial_db_item["content"]["properties"]["version"] == "1.0" + + # Create an updated version of the same item + updated_item = create_valid_stac_item(collection_id=collection_id, item_id=item_id) + updated_item["properties"]["version"] = "2.0" + + # Update the item using the handler + event = {"Records": [create_sqs_record(updated_item, message_id="update-message")]} + result = handler(event, mock_aws_context) + + # Check result - should be None for successful processing + assert result is None + + # Verify the item was updated + items = get_all_collection_items(database_url, collection_id) + updated_db_item = next((item for item in items if item["id"] == item_id), None) + assert updated_db_item is not None + assert ( + updated_db_item["content"]["properties"]["version"] == "2.0" + ), "Item was not properly updated with new version" + + # Count should remain the same (1 item was updated, not added) + count = count_collection_items(database_url, collection_id) + assert count == len(items), "Item count changed unexpectedly after upsert" + + +def create_s3_event_notification(bucket_name, object_key): + """Helper to create a mock S3 event notification""" + return { + "Records": [ + { + "eventSource": "aws:s3", + "eventName": "ObjectCreated:Put", + "eventTime": "2023-01-01T12:00:00.000Z", + "awsRegion": "us-east-1", + "s3": { + "bucket": {"name": bucket_name}, + "object": {"key": object_key, "size": 1024}, + }, + } + ] + } + + +def create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="test-s3-message-id" +): + """Helper to create a mock SQS record containing an S3 event notification via SNS""" + s3_event = create_s3_event_notification(bucket_name, object_key) + sns_message = {"Message": json.dumps(s3_event)} + return {"messageId": message_id, "body": json.dumps(sns_message)} + + +def test_handler_with_s3_event(mock_aws_context, mock_pgstac_dsn, database_url): + """Test handler with a valid S3 event notification""" + collection_id = TEST_COLLECTION_IDS[0] + item_id = "s3-test-item" + bucket_name = "test-bucket" + object_key = "stac/items/test-item.json" + + # Create a valid STAC item that will be "returned" from S3 + valid_item = create_valid_stac_item(collection_id=collection_id, item_id=item_id) + + # Create SQS record containing S3 event notification via SNS + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-message-1" + ) + event = {"Records": [s3_record]} + + # Mock S3 client to return our STAC item + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + + # Mock S3 get_object response + mock_response = {"Body": MagicMock()} + mock_response["Body"].read.return_value = json.dumps(valid_item).encode("utf-8") + mock_s3_client.get_object.return_value = mock_response + + # Call handler + result = handler(event, mock_aws_context) + + # Check result - should be None for successful processing + assert result is None + + # Verify S3 get_object was called with correct parameters + mock_s3_client.get_object.assert_called_once_with( + Bucket=bucket_name, Key=object_key + ) + + # Verify the item was added to the database + assert check_item_exists( + database_url, collection_id, item_id + ), "Item from S3 was not found in the database" + + +def test_handler_with_s3_event_invalid_extension(mock_aws_context, mock_pgstac_dsn): + """Test handler with S3 event for non-JSON file""" + bucket_name = "test-bucket" + object_key = "data/image.tif" # Not a JSON file + + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-invalid-ext" + ) + event = {"Records": [s3_record]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "s3-invalid-ext" for f in result["batchItemFailures"] + ) + + +def test_handler_with_s3_event_s3_error(mock_aws_context, mock_pgstac_dsn): + """Test handler when S3 get_object fails""" + bucket_name = "test-bucket" + object_key = "stac/items/missing-item.json" + + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-error" + ) + event = {"Records": [s3_record]} + + # Mock S3 client to raise an exception + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + mock_s3_client.get_object.side_effect = Exception("S3 object not found") + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any(f["itemIdentifier"] == "s3-error" for f in result["batchItemFailures"]) + + +def test_handler_with_s3_event_invalid_json(mock_aws_context, mock_pgstac_dsn): + """Test handler with S3 event that returns invalid JSON""" + bucket_name = "test-bucket" + object_key = "stac/items/invalid.json" + + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-invalid-json" + ) + event = {"Records": [s3_record]} + + # Mock S3 client to return invalid JSON + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + + mock_response = {"Body": MagicMock()} + mock_response["Body"].read.return_value = b"{invalid json content" + mock_s3_client.get_object.return_value = mock_response + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "s3-invalid-json" for f in result["batchItemFailures"] + ) + + +def test_handler_with_s3_event_invalid_stac_item(mock_aws_context, mock_pgstac_dsn): + """Test handler with S3 event that returns invalid STAC item""" + bucket_name = "test-bucket" + object_key = "stac/items/invalid-stac.json" + + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-invalid-stac" + ) + event = {"Records": [s3_record]} + + # Create invalid STAC item (missing required fields) + invalid_stac = {"id": "test", "type": "Feature"} # Missing required fields + + # Mock S3 client to return invalid STAC item + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + + mock_response = {"Body": MagicMock()} + mock_response["Body"].read.return_value = json.dumps(invalid_stac).encode("utf-8") + mock_s3_client.get_object.return_value = mock_response + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "s3-invalid-stac" for f in result["batchItemFailures"] + ) + + +def test_handler_with_mixed_s3_and_sqs_events( + mock_aws_context, mock_pgstac_dsn, database_url +): + """Test handler with both S3 and SQS events in the same batch""" + collection_id = TEST_COLLECTION_IDS[0] + + # Create SQS event with STAC item + sqs_item_id = "sqs-item" + sqs_item = create_valid_stac_item(collection_id=collection_id, item_id=sqs_item_id) + sqs_record = create_sqs_record(sqs_item, message_id="sqs-message") + + # Create S3 event + s3_item_id = "s3-item" + s3_item = create_valid_stac_item(collection_id=collection_id, item_id=s3_item_id) + bucket_name = "test-bucket" + object_key = "stac/items/s3-item.json" + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-message" + ) + + event = {"Records": [sqs_record, s3_record]} + + # Mock S3 client for the S3 event + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + + mock_response = {"Body": MagicMock()} + mock_response["Body"].read.return_value = json.dumps(s3_item).encode("utf-8") + mock_s3_client.get_object.return_value = mock_response + + # Call handler + result = handler(event, mock_aws_context) + + # Both should succeed + assert result is None + + # Verify both items were added to the database + assert check_item_exists( + database_url, collection_id, sqs_item_id + ), "SQS item was not found in the database" + assert check_item_exists( + database_url, collection_id, s3_item_id + ), "S3 item was not found in the database" + + +def test_handler_with_s3_event_binary_content(mock_aws_context, mock_pgstac_dsn): + """Test handler with S3 event that returns binary content""" + bucket_name = "test-bucket" + object_key = "stac/items/binary.json" + + s3_record = create_sqs_record_with_s3_event( + bucket_name, object_key, message_id="s3-binary" + ) + event = {"Records": [s3_record]} + + # Mock S3 client to return binary content that can't be decoded as UTF-8 + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + + mock_response = {"Body": MagicMock()} + # Return binary content that cannot be decoded as UTF-8 + mock_response["Body"].read.return_value = b"\xff\xfe\x00\x00" + mock_s3_client.get_object.return_value = mock_response + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "s3-binary" for f in result["batchItemFailures"] + ) + + +def test_handler_with_malformed_sns_message(mock_aws_context, mock_pgstac_dsn): + """Test handler with a malformed SNS message""" + # Create a record with invalid SNS structure + record = { + "messageId": "malformed-sns-message", + "body": json.dumps({"InvalidField": "missing Message field"}), + } + + event = {"Records": [record]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "malformed-sns-message" + for f in result["batchItemFailures"] + ) + + +def test_handler_with_empty_sns_message(mock_aws_context, mock_pgstac_dsn): + """Test handler with an empty SNS message""" + # Create a record with empty SNS message + record = {"messageId": "empty-sns-message", "body": json.dumps({"Message": ""})} + + event = {"Records": [record]} + + # Call handler + result = handler(event, mock_aws_context) + + # Should report the message as failed + assert result is not None + assert "batchItemFailures" in result + assert any( + f["itemIdentifier"] == "empty-sns-message" for f in result["batchItemFailures"] + ) + + +def test_handler_with_sqs_record_missing_message_id(mock_aws_context, mock_pgstac_dsn): + """Test handler with SQS record missing messageId""" + # Create a record without messageId + record = {"body": json.dumps({"Message": json.dumps(create_valid_stac_item())})} + + event = {"Records": [record]} + + # Call handler - should complete without crashing + result = handler(event, mock_aws_context) + + # Should succeed since the record without messageId is skipped + assert result is None + + +def test_is_s3_event_function(): + """Test the is_s3_event helper function""" + from stac_item_loader.handler import is_s3_event + + # Test with S3 event + s3_event = json.dumps(create_s3_event_notification("bucket", "key")) + assert is_s3_event(s3_event) is True + + # Test with STAC item + stac_item = json.dumps(create_valid_stac_item()) + assert is_s3_event(stac_item) is False + + # Test with arbitrary JSON + other_json = json.dumps({"some": "data"}) + assert is_s3_event(other_json) is False + + +def test_process_s3_event_function(mock_aws_context): + """Test the process_s3_event helper function""" + from stac_item_loader.handler import process_s3_event + + bucket_name = "test-bucket" + object_key = "stac/items/test.json" + valid_item = create_valid_stac_item() + + # Create S3 event message + s3_event = create_s3_event_notification(bucket_name, object_key) + message_str = json.dumps(s3_event) + + # Mock S3 client to return our STAC item + with patch("stac_item_loader.handler.boto3.session.Session") as mock_session: + mock_s3_client = MagicMock() + mock_session.return_value.client.return_value = mock_s3_client + + mock_response = {"Body": MagicMock()} + mock_response["Body"].read.return_value = json.dumps(valid_item).encode("utf-8") + mock_s3_client.get_object.return_value = mock_response + + # Call function + result = process_s3_event(message_str) + + # Verify result + assert result == valid_item + + # Verify S3 was called correctly + mock_s3_client.get_object.assert_called_once_with( + Bucket=bucket_name, Key=object_key + ) + + +def test_process_s3_event_with_no_records(): + """Test process_s3_event with message containing no records""" + from stac_item_loader.handler import process_s3_event + + # Create message with empty Records + message_str = json.dumps({"Records": []}) + + # Should raise ValueError + with pytest.raises(ValueError, match="no S3 event records"): + process_s3_event(message_str) + + +def test_process_s3_event_with_multiple_records(): + """Test process_s3_event with message containing multiple records""" + from stac_item_loader.handler import process_s3_event + + # Create message with multiple records + s3_event = { + "Records": [ + create_s3_event_notification("bucket1", "key1")["Records"][0], + create_s3_event_notification("bucket2", "key2")["Records"][0], + ] + } + message_str = json.dumps(s3_event) + + # Should raise ValueError + with pytest.raises(ValueError, match="more than one S3 event record"): + process_s3_event(message_str) diff --git a/lib/stactools-item-generator/index.ts b/lib/stactools-item-generator/index.ts new file mode 100644 index 0000000..7a7d5a7 --- /dev/null +++ b/lib/stactools-item-generator/index.ts @@ -0,0 +1,351 @@ +import { + aws_lambda as lambda, + aws_sqs as sqs, + aws_sns as sns, + aws_sns_subscriptions as snsSubscriptions, + aws_lambda_event_sources as lambdaEventSources, + aws_logs as logs, + Duration, + CfnOutput, +} from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { Platform } from "aws-cdk-lib/aws-ecr-assets"; +import * as path from "path"; + +/** + * Configuration properties for the StactoolsItemGenerator construct. + * + * The StactoolsItemGenerator is part of a two-phase serverless STAC ingestion pipeline + * that generates STAC items from source data. This construct creates the + * infrastructure for the first phase of the pipeline - processing metadata + * about assets and transforming them into standardized STAC items. + * + * @example + * const generator = new StactoolsItemGenerator(this, 'ItemGenerator', { + * itemLoadTopicArn: loader.topic.topicArn, + * lambdaTimeoutSeconds: 120, + * maxConcurrency: 100, + * batchSize: 10 + * }); + */ +export interface StactoolsItemGeneratorProps { + /** + * The lambda runtime to use for the item generation function. + * + * The function is containerized using Docker and can accommodate various + * stactools packages. The runtime version should be compatible with the + * packages you plan to use for STAC item generation. + * + * @default lambda.Runtime.PYTHON_3_11 + */ + readonly lambdaRuntime?: lambda.Runtime; + + /** + * The timeout for the item generation lambda in seconds. + * + * This should accommodate the time needed to: + * - Install stactools packages using uvx + * - Download and process source data + * - Generate STAC metadata + * - Publish results to SNS + * + * The SQS visibility timeout will be set to this value plus 10 seconds. + * + * @default 120 + */ + readonly lambdaTimeoutSeconds?: number; + + /** + * Memory size for the lambda function in MB. + * + * Higher memory allocation may be needed for processing large geospatial + * datasets or when stactools packages have high memory requirements. + * More memory also provides proportionally more CPU power. + * + * @default 1024 + */ + readonly memorySize?: number; + + /** + * Maximum number of concurrent executions. + * + * This controls how many item generation tasks can run simultaneously. + * Higher concurrency enables faster processing of large batches but + * may strain downstream systems or external data sources. + * + * @default 100 + */ + readonly maxConcurrency?: number; + + /** + * SQS batch size for lambda event source. + * + * This determines how many generation requests are processed together + * in a single lambda invocation. Unlike the loader, generation typically + * processes items individually, so smaller batch sizes are common. + * + * @default 10 + */ + readonly batchSize?: number; + + /** + * Additional environment variables for the lambda function. + * + * These will be merged with default environment variables including + * ITEM_LOAD_TOPIC_ARN and LOG_LEVEL. Use this for custom configuration + * or to pass credentials for external data sources. + */ + readonly environment?: { [key: string]: string }; + + /** + * ARN of the SNS topic to publish generated items to. + * + * This is typically the topic from a StacItemLoader construct. + * Generated STAC items will be published here for downstream + * processing and database insertion. + */ + readonly itemLoadTopicArn: string; +} + +/** + * AWS CDK Construct for STAC Item Generation Infrastructure + * + * The StactoolsItemGenerator creates a serverless, event-driven system for generating + * STAC (SpatioTemporal Asset Catalog) items from source data. This construct + * implements the first phase of a two-stage ingestion pipeline that transforms + * raw geospatial data into standardized STAC metadata. + * + * ## Architecture Overview + * + * This construct creates the following AWS resources: + * - **SNS Topic**: Entry point for triggering item generation workflows + * - **SQS Queue**: Buffers generation requests (120-second visibility timeout) + * - **Dead Letter Queue**: Captures failed messages after 5 processing attempts + * - **Lambda Function**: Containerized function that generates STAC items using stactools + * + * ## Data Flow + * + * 1. External systems publish ItemRequest messages to the SNS topic with metadata about assets + * 2. The SQS queue buffers these messages and triggers the Lambda function + * 3. The Lambda function: + * - Uses `uvx` to install the required stactools package + * - Executes the `create-item` CLI command with provided arguments + * - Publishes generated STAC items to the ItemLoad topic + * 4. Failed processing attempts are sent to the dead letter queue + * + * ## Operational Characteristics + * + * - **Scalability**: Lambda scales automatically based on queue depth (up to maxConcurrency) + * - **Flexibility**: Supports any stactools package through dynamic installation + * - **Reliability**: Dead letter queue captures failed generation attempts + * - **Isolation**: Each generation task runs in a fresh container environment + * - **Observability**: CloudWatch logs retained for one week + * + * ## Message Schema + * + * The function expects messages matching the ItemRequest model: + * + * ```json + * { + * "package_name": "stactools-glad-global-forest-change", + * "group_name": "gladglobalforestchange", + * "create_item_args": [ + * "https://example.com/data.tif" + * ], + * "collection_id": "glad-global-forest-change-1.11" + * } + * ``` + * + * ## Usage Example + * + * ```typescript + * // Create item loader first (or get existing topic ARN) + * const loader = new StacItemLoader(this, 'ItemLoader', { + * pgstacDb: database + * }); + * + * // Create item generator that feeds the loader + * const generator = new StactoolsItemGenerator(this, 'ItemGenerator', { + * itemLoadTopicArn: loader.topic.topicArn, + * lambdaTimeoutSeconds: 120, // Allow time for package installation + * maxConcurrency: 100, // Control parallel processing + * batchSize: 10 // Process 10 requests per invocation + * }); + * + * // Grant permission to publish to the loader topic + * loader.topic.grantPublish(generator.lambdaFunction); + * ``` + * + * ## Publishing Generation Requests + * + * Send messages to the generator topic to trigger item creation: + * + * ```bash + * aws sns publish --topic-arn $ITEM_GEN_TOPIC --message '{ + * "package_name": "stactools-glad-global-forest-change", + * "group_name": "gladglobalforestchange", + * "create_item_args": [ + * "https://storage.googleapis.com/earthenginepartners-hansen/GFC-2023-v1.11/Hansen_GFC-2023-v1.11_gain_40N_080W.tif" + * ], + * "collection_id": "glad-global-forest-change-1.11" + * }' + * ``` + * + * ## Batch Processing Example + * + * For processing many assets, you can loop through URLs: + * + * ```bash + * while IFS= read -r url; do + * aws sns publish --topic-arn "$ITEM_GEN_TOPIC" --message "{ + * \"package_name\": \"stactools-glad-glclu2020\", + * \"group_name\": \"gladglclu2020\", + * \"create_item_args\": [\"$url\"] + * }" + * done < urls.txt + * ``` + * + * ## Monitoring and Troubleshooting + * + * - Monitor Lambda logs: `/aws/lambda/{FunctionName}` + * - Check dead letter queue for failed generation attempts + * - Use CloudWatch metrics to track processing rates and errors + * - Failed items can be replayed from the dead letter queue + * + * ## Supported Stactools Packages + * + * Any package available on PyPI that follows the stactools plugin pattern + * can be used. Examples include: + * - `stactools-glad-global-forest-change` + * - `stactools-glad-glclu2020` + * - `stactools-landsat` + * - `stactools-sentinel2` + * + * @see {@link https://github.com/stactools-packages} for available stactools packages + * @see {@link https://stactools.readthedocs.io/} for stactools documentation + */ +export class StactoolsItemGenerator extends Construct { + /** + * The SQS queue that buffers item generation requests. + * + * This queue receives messages from the SNS topic containing ItemRequest + * payloads. It's configured with a visibility timeout that matches the + * Lambda timeout plus buffer time to prevent duplicate processing. + */ + public readonly queue: sqs.Queue; + + /** + * Dead letter queue for failed item generation attempts. + * + * Messages that fail processing after 5 attempts are sent here for + * inspection and potential replay. This helps with debugging stactools + * package issues, network failures, or malformed requests. + */ + public readonly deadLetterQueue: sqs.Queue; + + /** + * The SNS topic that receives item generation requests. + * + * External systems publish ItemRequest messages to this topic to trigger + * STAC item generation. The topic fans out to the SQS queue for processing. + */ + public readonly topic: sns.Topic; + + /** + * The containerized Lambda function that generates STAC items. + * + * This Docker-based function dynamically installs stactools packages + * using uvx, processes source data, and publishes generated STAC items + * to the configured ItemLoad SNS topic. + */ + public readonly lambdaFunction: lambda.DockerImageFunction; + + constructor(scope: Construct, id: string, props: StactoolsItemGeneratorProps) { + super(scope, id); + + const timeoutSeconds = props.lambdaTimeoutSeconds ?? 120; + const lambdaRuntime = props.lambdaRuntime ?? lambda.Runtime.PYTHON_3_11; + + // Create dead letter queue + this.deadLetterQueue = new sqs.Queue(this, "DeadLetterQueue", { + retentionPeriod: Duration.days(14), + }); + + // Create main queue + this.queue = new sqs.Queue(this, "Queue", { + visibilityTimeout: Duration.seconds(timeoutSeconds + 10), + encryption: sqs.QueueEncryption.SQS_MANAGED, + deadLetterQueue: { + maxReceiveCount: 5, + queue: this.deadLetterQueue, + }, + }); + + // Create SNS topic + this.topic = new sns.Topic(this, "Topic", { + displayName: `${id}-ItemGenTopic`, + }); + + // Subscribe the queue to the topic + this.topic.addSubscription( + new snsSubscriptions.SqsSubscription(this.queue) + ); + + // Create the lambda function + this.lambdaFunction = new lambda.DockerImageFunction(this, "Function", { + code: lambda.DockerImageCode.fromImageAsset(path.join(__dirname, ".."), { + file: "stactools-item-generator/runtime/Dockerfile", + platform: Platform.LINUX_AMD64, + buildArgs: { + PYTHON_VERSION: lambdaRuntime.toString().replace("python", ""), + }, + }), + memorySize: props.memorySize ?? 1024, + timeout: Duration.seconds(timeoutSeconds), + logRetention: logs.RetentionDays.ONE_WEEK, + environment: { + ITEM_LOAD_TOPIC_ARN: props.itemLoadTopicArn, + LOG_LEVEL: "INFO", + ...props.environment, + }, + }); + + // Add SQS event source to the lambda + this.lambdaFunction.addEventSource( + new lambdaEventSources.SqsEventSource(this.queue, { + batchSize: props.batchSize ?? 10, + reportBatchItemFailures: true, + maxConcurrency: props.maxConcurrency ?? 100, + }) + ); + + // Grant permissions to publish to the item load topic + // Note: This will be granted externally since we only have the ARN + // The consuming construct should handle this permission + + // Create outputs + new CfnOutput(this, "TopicArn", { + value: this.topic.topicArn, + description: "ARN of the StactoolsItemGenerator SNS Topic", + exportName: "stactools-item-generator-topic-arn", + }); + + new CfnOutput(this, "QueueUrl", { + value: this.queue.queueUrl, + description: "URL of the StactoolsItemGenerator SQS Queue", + exportName: "stactools-item-generator-queue-url", + }); + + new CfnOutput(this, "DeadLetterQueueUrl", { + value: this.deadLetterQueue.queueUrl, + description: "URL of the StactoolsItemGenerator Dead Letter Queue", + exportName: "stactools-item-generator-deadletter-queue-url", + }); + + new CfnOutput(this, "FunctionName", { + value: this.lambdaFunction.functionName, + description: "Name of the StactoolsItemGenerator Lambda Function", + exportName: "stactools-item-generator-function-name", + }); + } +} diff --git a/lib/stactools-item-generator/runtime/Dockerfile b/lib/stactools-item-generator/runtime/Dockerfile new file mode 100644 index 0000000..505b45a --- /dev/null +++ b/lib/stactools-item-generator/runtime/Dockerfile @@ -0,0 +1,20 @@ +ARG PYTHON_VERSION=3.11 +FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} +COPY --from=ghcr.io/astral-sh/uv:0.7.8 /uv /uvx /bin/ + +ENV UV_CACHE_DIR=/tmp/uv-cache/ +ENV UV_COMPILE_BYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV HOME=/tmp +ENV PATH=/tmp/.local/bin:$PATH + +WORKDIR ${LAMBDA_TASK_ROOT} + +COPY stactools-item-generator/runtime/pyproject.toml pyproject.toml +COPY stactools-item-generator/runtime/src/stactools_item_generator/ ${LAMBDA_TASK_ROOT}/stactools_item_generator/ + +RUN uv export --no-dev --no-editable -o requirements.txt && \ + uv pip install --target ${LAMBDA_TASK_ROOT} -r requirements.txt && \ + uv tool install --with requests stactools; + +CMD ["stactools_item_generator.handler.handler"] diff --git a/lib/stactools-item-generator/runtime/pyproject.toml b/lib/stactools-item-generator/runtime/pyproject.toml new file mode 100644 index 0000000..d73005b --- /dev/null +++ b/lib/stactools-item-generator/runtime/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "stactools-item-generator" +version = "0.1.0" +description = "An application for generating STAC metadata with any stactools package" +authors = [ + { name = "hrodmn", email = "henry@developmentseed.org" } +] +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2.11.0", + "stac-pydantic>=3.2.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/lib/stactools-item-generator/runtime/src/stactools_item_generator/__init__.py b/lib/stactools-item-generator/runtime/src/stactools_item_generator/__init__.py new file mode 100644 index 0000000..036cf15 --- /dev/null +++ b/lib/stactools-item-generator/runtime/src/stactools_item_generator/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from stactools-uvx!") diff --git a/lib/stactools-item-generator/runtime/src/stactools_item_generator/handler.py b/lib/stactools-item-generator/runtime/src/stactools_item_generator/handler.py new file mode 100644 index 0000000..1112f4c --- /dev/null +++ b/lib/stactools-item-generator/runtime/src/stactools_item_generator/handler.py @@ -0,0 +1,176 @@ +"""AWS Lambda handler for STAC Item Generation.""" + +import json +import logging +import os +import subprocess +import traceback +from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Optional, TypedDict + +import boto3 +from pydantic import ValidationError + +if TYPE_CHECKING: + from aws_lambda_typing.context import Context +else: + Context = Annotated[object, "Context object"] + +from stactools_item_generator.item import ItemRequest, create_stac_item + +logger = logging.getLogger() +if logger.hasHandlers(): + logger.handlers.clear() + +log_handler = logging.StreamHandler() # <--- Renamed handler variable + +log_level_name = os.environ.get("LOG_LEVEL", "INFO").upper() +log_level = logging._nameToLevel.get(log_level_name, logging.INFO) +logger.setLevel(log_level) + +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +log_handler.setFormatter(formatter) +logger.addHandler(log_handler) + + +def get_topic_arn() -> str: + item_load_topic_arn = os.environ.get("ITEM_LOAD_TOPIC_ARN") + if not item_load_topic_arn: + logger.error("Environment variable ITEM_LOAD_TOPIC_ARN is not set.") + raise EnvironmentError("ITEM_LOAD_TOPIC_ARN must be set") + + return item_load_topic_arn + + +def process_record(record: Dict[str, Any], sns_client) -> None: + """ + Processes a single SQS record (within a batch). + Extracts the request, calls create_stac_item, and publishes the result. + Raises exceptions on failure. + """ + message_id = record.get("messageId", "UNKNOWN_ID") + logger.info(f"Processing record: {message_id}") + message_str = None + try: + sqs_body_str = record["body"] + logger.debug(f"[{message_id}] SQS message body: {sqs_body_str}") + sns_notification = json.loads(sqs_body_str) + + message_str = sns_notification["Message"] + logger.debug(f"[{message_id}] SNS Message content: {message_str}") + + message_data = json.loads(message_str) + item_request = ItemRequest(**message_data) + logger.info( + f"[{message_id}] Parsed ItemRequest for package: {item_request.package_name}" + ) + logger.debug(f"[{message_id}] Full ItemRequest: {item_request.model_dump_json()}") + + stac_item = create_stac_item(item_request) + logger.info(f"[{message_id}] Successfully created STAC item: {stac_item.id}") + logger.debug( + f"[{message_id}] Generated STAC Item JSON (sample): " + f"{ {k: v for k, v in stac_item.model_dump().items() if k in ['id', 'collection', 'properties']} }" + ) + + stac_item_json = stac_item.model_dump_json() + + item_load_topic_arn = get_topic_arn() + logger.info( + f"[{message_id}] Publishing STAC item {stac_item.id} to {item_load_topic_arn}" + ) + response = sns_client.publish( + TopicArn=item_load_topic_arn, + Message=stac_item_json, + ) + logger.info( + f"[{message_id}] SNS publish response MessageId: {response.get('MessageId')}" + ) + + except json.JSONDecodeError as e: + logger.error(f"[{message_id}] Failed to decode JSON: {e}") + logger.error(f"[{message_id}] Problematic data (SQS Body): {record.get('body')}") + raise + except ValidationError as e: + logger.error(f"[{message_id}] Failed to validate ItemRequest: {e}") + logger.error(f"[{message_id}] Validation errors:\n{e.errors()}") + problem_data = message_str if message_str is not None else record.get("body") + logger.error( + f"[{message_id}] Problematic data (SNS Message or SQS Body): {problem_data}" + ) + raise + except ( + subprocess.CalledProcessError + ) as e: # <--- Catching the imported exception type + logger.error(f"[{message_id}] Subprocess command failed:") + logger.error(f"[{message_id}] Command: {' '.join(e.cmd)}") + logger.error(f"[{message_id}] Return code: {e.returncode}") + logger.error(f"[{message_id}] Stdout: {e.stdout}") + logger.error(f"[{message_id}] Stderr: {e.stderr}") + raise + except Exception as e: + logger.error( + f"[{message_id}] An unexpected error occurred processing record: {e}" + ) + logger.error(traceback.format_exc()) + raise + + +class BatchItemFailure(TypedDict): + itemIdentifier: str + + +class PartialBatchFailureResponse(TypedDict): + batchItemFailures: List[BatchItemFailure] + + +def handler( + event: Dict[str, Any], context: Context +) -> Optional[PartialBatchFailureResponse]: + """ + AWS Lambda handler function triggered by SQS with batching enabled. + + Processes messages in batches, attempts to generate STAC items, publishes + successful results to SNS, and reports partial batch failures to SQS. + """ + try: + sns_client = boto3.client("sns", region_name=os.getenv("AWS_DEFAULT_REGION")) + except Exception as e: + logging.error(f"Error: {str(e)}") + raise EnvironmentError("AWS_DEFAULT_REGION must be set") from e + + records = event.get("Records", []) + aws_request_id = getattr(context, "aws_request_id", "N/A") + remaining_time = getattr(context, "get_remaining_time_in_millis", lambda: "N/A")() + + logger.info(f"Received batch with {len(records)} records.") + logger.debug( + f"Lambda Context: RequestId={aws_request_id}, RemainingTime={remaining_time}ms" + ) + + batch_item_failures: List[BatchItemFailure] = [] + + for record in records: + message_id = record.get("messageId") + if not message_id: + logger.warning("Record missing messageId, cannot report failure for it.") + continue + + try: + process_record(record, sns_client) + logger.info(f"[{message_id}] Successfully processed.") + + except Exception: + logger.error(f"[{message_id}] Marked as failed.") + batch_item_failures.append({"itemIdentifier": message_id}) + + if batch_item_failures: + logger.warning( + f"Finished processing batch. {len(batch_item_failures)} failure(s) reported." + ) + logger.info( + f"Returning failed item identifiers: {[f['itemIdentifier'] for f in batch_item_failures]}" + ) + return {"batchItemFailures": batch_item_failures} + else: + logger.info("Finished processing batch. All records successful.") + return None diff --git a/lib/stactools-item-generator/runtime/src/stactools_item_generator/item.py b/lib/stactools-item-generator/runtime/src/stactools_item_generator/item.py new file mode 100644 index 0000000..fdf3370 --- /dev/null +++ b/lib/stactools-item-generator/runtime/src/stactools_item_generator/item.py @@ -0,0 +1,77 @@ +import json +import logging +import subprocess +from tempfile import NamedTemporaryFile +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field +from stac_pydantic.item import Item + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +class ItemRequest(BaseModel): + package_name: str = Field(..., description="Name of the stactools package") + group_name: str = Field(..., description="Group name for the STAC item") + create_item_args: List[str] = Field( + ..., description="Arguments for create-item command" + ) + create_item_options: Dict[str, str] = Field( + default_factory=dict, description="Options for create-item command" + ) + collection_id: Optional[str] = Field( + None, description="value for the collection field of the item json" + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "package_name": "stactools-glad-glclu2020", + "group_name": "gladglclu2020", + "create_item_args": [ + "https://storage.googleapis.com/earthenginepartners-hansen/GLCLU2000-2020/v2/2000/50N_090W.tif" + ], + } + } + ) + + +def create_stac_item(request: ItemRequest) -> Item: + """ + Create a STAC item using a stactools package + """ + logger.info(f"Received request: {json.dumps(request.model_dump())}") + + if not request.package_name: + raise ValueError("Missing required parameter: package_name") + + command = [ + "uvx", + "--with", + f"requests,{request.package_name}", + "--from", + "stactools", + "stac", + request.group_name, + "create-item", + *request.create_item_args, + ] + + for option, value in request.create_item_options.items(): + command.extend([f"--{option}", value]) + + logger.info(f"Executing command: {' '.join(command)}") + + with NamedTemporaryFile(suffix=".json") as output: + command.append(output.name) + result = subprocess.run(command, capture_output=True, text=True, check=True) + + logger.info(f"Command output: {result.stdout}") + with open(output.name) as f: + item_dict = json.load(f) + + if request.collection_id: + item_dict["collection"] = request.collection_id + + return Item(**item_dict) diff --git a/lib/stactools-item-generator/runtime/tests/test_item.py b/lib/stactools-item-generator/runtime/tests/test_item.py new file mode 100644 index 0000000..1688283 --- /dev/null +++ b/lib/stactools-item-generator/runtime/tests/test_item.py @@ -0,0 +1,40 @@ +import pytest +from stactools_item_generator.item import ItemRequest, create_stac_item + + +@pytest.mark.parametrize( + "item_request", + [ + ItemRequest( + package_name="stactools-glad-glclu2020", + group_name="gladglclu2020", + create_item_args=[ + "https://storage.googleapis.com/earthenginepartners-hansen/GLCLU2000-2020/v2/2000/50N_090W.tif" + ], + collection_id=None, + ), + ItemRequest( + package_name="stactools-glad-global-forest-change==0.1.2", + group_name="gladglobalforestchange", + create_item_args=[ + "https://storage.googleapis.com/earthenginepartners-hansen/GFC-2023-v1.11/Hansen_GFC-2023-v1.11_gain_40N_080W.tif", + "https://storage.googleapis.com/earthenginepartners-hansen/GFC-2023-v1.11/Hansen_GFC-2023-v1.11_treecover2000_40N_080W.tif", + "https://storage.googleapis.com/earthenginepartners-hansen/GFC-2023-v1.11/Hansen_GFC-2023-v1.11_lossyear_40N_080W.tif", + "https://storage.googleapis.com/earthenginepartners-hansen/GFC-2023-v1.11/Hansen_GFC-2023-v1.11_datamask_40N_080W.tif", + ], + collection_id=None, + ), + ItemRequest( + package_name="stactools-glad-glclu2020", + group_name="gladglclu2020", + create_item_args=[ + "https://storage.googleapis.com/earthenginepartners-hansen/GLCLU2000-2020/v2/2000/50N_090W.tif" + ], + collection_id="test", + ), + ], +) +def test_item(item_request: ItemRequest) -> None: + stac_item = create_stac_item(item_request) + if item_request.collection_id: + assert stac_item.collection == item_request.collection_id diff --git a/lib/stactools-item-generator/runtime/tests/test_item_gen_handler.py b/lib/stactools-item-generator/runtime/tests/test_item_gen_handler.py new file mode 100644 index 0000000..63c8f85 --- /dev/null +++ b/lib/stactools-item-generator/runtime/tests/test_item_gen_handler.py @@ -0,0 +1,519 @@ +import json +import logging +import os +import subprocess +from unittest.mock import patch + +import pytest +from stac_pydantic.item import Item +from stactools_item_generator import handler as item_gen_handler +from stactools_item_generator.item import ItemRequest + + +@pytest.fixture(autouse=True) +def setup_environment(monkeypatch): + """Set necessary environment variables for tests.""" + monkeypatch.setenv( + "ITEM_LOAD_TOPIC_ARN", "arn:aws:sns:us-east-1:123456789012:fake-topic" + ) + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + + +@pytest.fixture +def mock_context(mocker): + """Create a mock Lambda context object.""" + mock_ctx = mocker.MagicMock() + mock_ctx.aws_request_id = "test-request-id" + mock_ctx.get_remaining_time_in_millis.return_value = 300000 # 5 minutes + return mock_ctx + + +@pytest.fixture +def mock_sns_client(mocker): + """Mock the boto3 SNS client and its publish method.""" + mock_client_instance = mocker.MagicMock() + mock_client_instance.publish.return_value = {"MessageId": "fake-sns-message-id"} + + mock_boto_client = patch( + "stactools_item_generator.handler.boto3.client", + return_value=mock_client_instance, + ).start() + + yield mock_client_instance + + mock_boto_client.stop() + + +@pytest.fixture +def mock_create_stac_item(mocker): + """Mock the create_stac_item function.""" + # Create a realistic-looking mock Item object + mock_item_dict = { + "type": "Feature", + "stac_version": "1.0.0", + "id": "test_item_id", + "properties": { + "datetime": "2023-01-01T00:00:00Z", + }, + "geometry": {"type": "Point", "coordinates": [0, 0]}, + "links": [], + "assets": {}, + "bbox": [0, 0, 0, 0], + "stac_extensions": [], + "collection": "test_collection", + } + mock_item = Item(**mock_item_dict) + + # Patch the function to return our mock item + mock_func = patch( + "stactools_item_generator.handler.create_stac_item", return_value=mock_item + ).start() + + # Store the mock item for easy access + mock_func.mock_item = mock_item + mock_func.mock_item_dict = mock_item_dict + mock_func.mock_item_json = mock_item.model_dump_json() + + yield mock_func + + mock_func.stop() + + +# --- Helper Function --- + + +def create_sqs_event(messages: list[dict]) -> dict: + """Helper function to create an SQS event structure.""" + records = [] + for i, msg_data in enumerate(messages): + # Simulate the SNS -> SQS structure + sns_message_str = json.dumps(msg_data) + sns_notification = { + "Type": "Notification", + "MessageId": f"sns-msg-id-{i}", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:source-topic", + "Subject": "Test Subject", + "Message": sns_message_str, + "Timestamp": "2023-01-01T12:00:00.000Z", + "SignatureVersion": "1", + } + sqs_body_str = json.dumps(sns_notification) + records.append( + { + "messageId": f"sqs-msg-id-{i}", + "receiptHandle": f"receipt-handle-{i}", + "body": sqs_body_str, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1672574400000", + "SenderId": "ARO...", + "ApproximateFirstReceiveTimestamp": "1672574400010", + }, + "messageAttributes": {}, + "md5OfBody": f"md5-{i}", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:your-queue", + "awsRegion": "us-east-1", + } + ) + return {"Records": records} + + +# --- Test Cases --- + + +def test_handler_success_single_message( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test successful processing of a single valid SQS message.""" + # Arrange + caplog.set_level(logging.INFO) + item_request_data = { + "package_name": "stactools-test", + "group_name": "testgroup", + "create_item_args": ["input/file.tif"], + "create_item_options": {"option1": "value1"}, + "collection_id": "test_collection_input", + } + event = create_sqs_event([item_request_data]) + + # Set up mock to respect collection_id + mock_item_with_collection = Item( + **{ + **mock_create_stac_item.mock_item_dict, + "collection": item_request_data["collection_id"], + } + ) + mock_create_stac_item.return_value = mock_item_with_collection + mock_create_stac_item.mock_item = mock_item_with_collection + mock_create_stac_item.mock_item_json = mock_item_with_collection.model_dump_json() + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + assert result is None # Successful batch processing returns None + + # Check create_stac_item call + mock_create_stac_item.assert_called_once() + call_args, call_kwargs = mock_create_stac_item.call_args + assert len(call_args) == 1 + assert isinstance(call_args[0], ItemRequest) + assert call_args[0].package_name == item_request_data["package_name"] + assert call_args[0].group_name == item_request_data["group_name"] + assert call_args[0].create_item_args == item_request_data["create_item_args"] + assert call_args[0].create_item_options == item_request_data["create_item_options"] + assert call_args[0].collection_id == item_request_data["collection_id"] + + # Check SNS publish call + mock_sns_client.publish.assert_called_once_with( + TopicArn=os.environ["ITEM_LOAD_TOPIC_ARN"], + Message=mock_create_stac_item.mock_item_json, + ) + + # Check logs + assert "Received batch with 1 records." in caplog.text + assert f"Processing record: {event['Records'][0]['messageId']}" in caplog.text + assert ( + f"Parsed ItemRequest for package: {item_request_data['package_name']}" + in caplog.text + ) + assert "Successfully created STAC item:" in caplog.text + assert "Publishing STAC item" in caplog.text + assert "SNS publish response MessageId: fake-sns-message-id" in caplog.text + assert "Successfully processed." in caplog.text + assert "Finished processing batch. All records successful." in caplog.text + + +def test_handler_success_multiple_messages( + mock_context, mock_sns_client, mock_create_stac_item, mocker, caplog +): + """Test successful processing of multiple valid SQS messages.""" + # Arrange + item_request_data1 = { + "package_name": "stactools-test1", + "group_name": "testgroup1", + "create_item_args": ["input1.tif"], + } + item_request_data2 = { + "package_name": "stactools-test2", + "group_name": "testgroup2", + "create_item_args": ["input2.tif"], + "collection_id": "coll2", + } + event = create_sqs_event([item_request_data1, item_request_data2]) + + # Configure mock to return different items for each call + item1_dict = {**mock_create_stac_item.mock_item_dict, "id": "item1"} + item2_dict = { + **mock_create_stac_item.mock_item_dict, + "id": "item2", + "collection": "coll2", + } + + item1 = Item(**item1_dict) + item2 = Item(**item2_dict) + + item1_json = item1.model_dump_json() + item2_json = item2.model_dump_json() + + mock_create_stac_item.side_effect = [item1, item2] + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + assert result is None + assert mock_create_stac_item.call_count == 2 + assert mock_sns_client.publish.call_count == 2 + + # Check publish calls + assert mock_sns_client.publish.call_args_list[0] == mocker.call( + TopicArn=os.environ["ITEM_LOAD_TOPIC_ARN"], Message=item1_json + ) + assert mock_sns_client.publish.call_args_list[1] == mocker.call( + TopicArn=os.environ["ITEM_LOAD_TOPIC_ARN"], Message=item2_json + ) + + assert "Successfully processed." in caplog.text + assert caplog.text.count("Successfully processed.") == 2 + assert "Finished processing batch. All records successful." in caplog.text + + +def test_handler_partial_failure_create_item( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test partial batch failure when create_stac_item raises an error.""" + # Arrange + item_request_data_ok = { + "package_name": "stactools-ok", + "group_name": "okgroup", + "create_item_args": ["ok.tif"], + "collection_id": "coll_ok", + } + item_request_data_fail = { + "package_name": "stactools-fail", + "group_name": "failgroup", + "create_item_args": ["fail.tif"], + } + event = create_sqs_event([item_request_data_ok, item_request_data_fail]) + + # Set up mock to succeed for first call and fail for second + mock_item_ok_dict = { + **mock_create_stac_item.mock_item_dict, + "id": "item_ok", + "collection": "coll_ok", + } + mock_item_ok = Item(**mock_item_ok_dict) + mock_item_ok_json = mock_item_ok.model_dump_json() + + # Simulate subprocess error + mock_exception = subprocess.CalledProcessError( + returncode=1, + cmd=["uvx", "..."], + output="stdout data", + stderr="stderr error message", + ) + + mock_create_stac_item.side_effect = [mock_item_ok, mock_exception] + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + expected_failures = [{"itemIdentifier": event["Records"][1]["messageId"]}] + assert result == {"batchItemFailures": expected_failures} + + # Check calls + assert mock_create_stac_item.call_count == 2 + mock_sns_client.publish.assert_called_once_with( + TopicArn=os.environ["ITEM_LOAD_TOPIC_ARN"], Message=mock_item_ok_json + ) + + # Check logs + assert f"[{event['Records'][0]['messageId']}] Successfully processed." in caplog.text + assert ( + f"[{event['Records'][1]['messageId']}] Subprocess command failed:" in caplog.text + ) + assert "stderr error message" in caplog.text + assert f"[{event['Records'][1]['messageId']}] Marked as failed." in caplog.text + assert "Finished processing batch. 1 failure(s) reported." in caplog.text + + +def test_handler_partial_failure_json_decode( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test partial batch failure when JSON decoding fails.""" + # Arrange + item_request_data_ok = { + "package_name": "stactools-ok", + "group_name": "okgroup", + "create_item_args": ["ok.tif"], + "collection_id": "coll_ok", + } + invalid_json_body = ( + '{"Message": "{"key": "value", }", "Type": "Notification"}' # Invalid JSON + ) + + event = create_sqs_event([item_request_data_ok]) + # Add malformed record + malformed_record = { + "messageId": "sqs-msg-id-malformed", + "receiptHandle": "receipt-handle-malformed", + "body": invalid_json_body, + "attributes": {}, + "messageAttributes": {}, + "md5OfBody": "md5-malformed", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:your-queue", + "awsRegion": "us-east-1", + } + event["Records"].append(malformed_record) + + # Configure mock for good record + mock_item_ok_dict = { + **mock_create_stac_item.mock_item_dict, + "id": "item_ok", + "collection": "coll_ok", + } + mock_item_ok = Item(**mock_item_ok_dict) + mock_item_ok_json = mock_item_ok.model_dump_json() + mock_create_stac_item.return_value = mock_item_ok + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + expected_failures = [{"itemIdentifier": malformed_record["messageId"]}] + assert result == {"batchItemFailures": expected_failures} + + # Check calls + mock_create_stac_item.assert_called_once() + mock_sns_client.publish.assert_called_once_with( + TopicArn=os.environ["ITEM_LOAD_TOPIC_ARN"], Message=mock_item_ok_json + ) + + # Check logs + assert f"[{malformed_record['messageId']}] Failed to decode JSON:" in caplog.text + assert f"Problematic data (SQS Body): {invalid_json_body}" in caplog.text + assert f"[{malformed_record['messageId']}] Marked as failed." in caplog.text + + +def test_handler_partial_failure_validation( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test partial batch failure when ItemRequest validation fails.""" + # Arrange + item_request_data_ok = { + "package_name": "stactools-ok", + "group_name": "okgroup", + "create_item_args": ["ok.tif"], + } + item_request_data_invalid = { + # Missing required field 'package_name' + "group_name": "invalidgroup", + "create_item_args": ["invalid.tif"], + } + event = create_sqs_event([item_request_data_ok, item_request_data_invalid]) + + # Configure mock for good record + mock_item_ok_dict = {**mock_create_stac_item.mock_item_dict, "id": "item_ok"} + mock_item_ok = Item(**mock_item_ok_dict) + mock_item_ok_json = mock_item_ok.model_dump_json() + mock_create_stac_item.return_value = mock_item_ok + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + expected_failures = [{"itemIdentifier": event["Records"][1]["messageId"]}] + assert result == {"batchItemFailures": expected_failures} + + # Check calls + mock_create_stac_item.assert_called_once() + mock_sns_client.publish.assert_called_once_with( + TopicArn=os.environ["ITEM_LOAD_TOPIC_ARN"], Message=mock_item_ok_json + ) + + # Check logs + assert ( + f"[{event['Records'][1]['messageId']}] Failed to validate ItemRequest:" + in caplog.text + ) + assert "Validation errors:" in caplog.text + invalid_request_str = json.dumps(item_request_data_invalid) + assert ( + f"Problematic data (SNS Message or SQS Body): {invalid_request_str}" + in caplog.text + ) + assert f"[{event['Records'][1]['messageId']}] Marked as failed." in caplog.text + + +def test_handler_all_records_fail( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test when all records in a batch fail.""" + # Arrange + invalid_request_1 = { + # Missing required fields + "create_item_args": ["file1.tif"] + } + invalid_request_2 = { + # Missing required fields + "create_item_args": ["file2.tif"] + } + event = create_sqs_event([invalid_request_1, invalid_request_2]) + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + expected_failures = [ + {"itemIdentifier": event["Records"][0]["messageId"]}, + {"itemIdentifier": event["Records"][1]["messageId"]}, + ] + assert result == {"batchItemFailures": expected_failures} + + # Check calls - should never call these since validation fails + mock_create_stac_item.assert_not_called() + mock_sns_client.publish.assert_not_called() + + # Check logs + assert "Finished processing batch. 2 failure(s) reported." in caplog.text + + +def test_handler_empty_batch( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test handling an empty batch of records.""" + # Arrange + event = {"Records": []} + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + assert result is None + mock_create_stac_item.assert_not_called() + mock_sns_client.publish.assert_not_called() + assert "Received batch with 0 records." in caplog.text + assert "Finished processing batch. All records successful." in caplog.text + + +def test_handler_with_general_exception( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test handling of unexpected exceptions during processing.""" + # Arrange + item_request_data = { + "package_name": "stactools-test", + "group_name": "testgroup", + "create_item_args": ["input/file.tif"], + } + event = create_sqs_event([item_request_data]) + message_id = event["Records"][0]["messageId"] + + # Set up mock to raise an unexpected exception + mock_create_stac_item.side_effect = Exception("Unexpected error during processing") + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + expected_failures = [{"itemIdentifier": message_id}] + assert result == {"batchItemFailures": expected_failures} + + # Check logs - updated to match the actual log message format + assert ( + f"[{message_id}] An unexpected error occurred processing record: Unexpected error during processing" + in caplog.text + ) + assert "Unexpected error" in caplog.text + assert f"[{message_id}] Marked as failed." in caplog.text + + +def test_handler_sns_publish_failure( + mock_context, mock_sns_client, mock_create_stac_item, caplog +): + """Test handling of SNS publish failures.""" + # Arrange + item_request_data = { + "package_name": "stactools-test", + "group_name": "testgroup", + "create_item_args": ["input/file.tif"], + } + event = create_sqs_event([item_request_data]) + + # Configure mock to simulate SNS publish failure + mock_sns_client.publish.side_effect = Exception("SNS publish failed") + + # Act + result = item_gen_handler.handler(event, mock_context) + + # Assert + expected_failures = [{"itemIdentifier": event["Records"][0]["messageId"]}] + assert result == {"batchItemFailures": expected_failures} + + # Check logs + assert "SNS publish failed" in caplog.text + assert f"[{event['Records'][0]['messageId']}] Marked as failed." in caplog.text diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f346621 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "eoapi-cdk" +version = "0.0" +requires-python = ">=3.11" +dependencies = [ + "stactools-item-generator", + "stac-item-loader", +] + +[tool.uv.sources] +stactools-item-generator = { workspace = true } +stac-item-loader = { workspace = true } + +[tool.uv.workspace] +members = ["lib/stac-item-loader/runtime", "lib/stactools-item-generator/runtime"] + +[dependency-groups] +deploy = [ + "aws-cdk-lib==2.190.0", + "constructs==10.3.0", + "pydantic>=2.11.5", + "pydantic-settings[yaml]>=2.8.1", + "python-dotenv>=1.1.0", + "pyyaml>=6.0.2", + "types-pyyaml>=6.0.12.20250516", +] +dev = [ + "aws-lambda-typing>=2.20.0", + "httpx>=0.28.1", + "pytest>=8.3.5", + "pytest-mock>=3.14.0", + "pytest-postgresql>=7.0.1", +] + +[tool.pytest.ini_options] +addopts = "-vv --ignore=cdk.out --no-header --tb=native" +pythonpath = "." +testpaths = [ + "lib/stactools-item-generator/runtime/tests", + "lib/stac-item-loader/runtime/tests", +] + +[tool.ruff] + +[tool.ruff.lint] diff --git a/tox.ini b/tox.ini index e2c6a55..09fb879 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] skipsdist = True -envlist = py39 +envlist = py312 [testenv] extras = test @@ -9,8 +9,4 @@ passenv = AWS_DEFAULT_REGION commands = pip install -r ./lib/ingestor-api/runtime/requirements.txt pip install -r ./lib/ingestor-api/runtime/dev_requirements.txt - python -m pytest -s -vv - -[pytest] -addopts = -ra -q -testpaths = lib/ingestor-api + python -m pytest -s -vv lib/ingestor-api/runtime/tests diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..94b9328 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1065 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[manifest] +members = [ + "eoapi-cdk", + "stac-item-loader", + "stactools-item-generator", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "aws-cdk-asset-awscli-v1" +version = "2.2.237" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsii" }, + { name = "publication" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/dd/ee1255668c7066825596e6da8a487cc40ecd393a91ece1c91bbe263664a2/aws_cdk_asset_awscli_v1-2.2.237.tar.gz", hash = "sha256:e1dd0086af180c381d3ee81eb963a1f469627763e0507982b6f2d4075446bdf4", size = 19163899, upload-time = "2025-05-19T16:11:35.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f8/419ac6b8b1701a628ccf44000ab50b5e2e70b48219e7cbe5f166d139b1e9/aws_cdk_asset_awscli_v1-2.2.237-py3-none-any.whl", hash = "sha256:642805ba143b35d11d5b5e80ab728db2ec8b894b2837b629ad95601e7e189e4c", size = 19162297, upload-time = "2025-05-19T16:11:32.53Z" }, +] + +[[package]] +name = "aws-cdk-asset-node-proxy-agent-v6" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsii" }, + { name = "publication" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/ab/09ac3ecc0067988d02398328e088d66cbe8555c991563c8ddfa1db5296ae/aws_cdk_asset_node_proxy_agent_v6-2.1.0.tar.gz", hash = "sha256:1f292c0631f86708ba4ee328b3a2b229f7e46ea1c79fbde567ee9eb119c2b0e2", size = 1540231, upload-time = "2024-09-03T09:36:51.634Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/86/1817a6da223aa80aeb94a504f07f930170284694b18f6053729e9930cc6a/aws_cdk.asset_node_proxy_agent_v6-2.1.0-py3-none-any.whl", hash = "sha256:24a388b69a44d03bae6dbf864c4e25ba650d4b61c008b4568b94ffbb9a69e40e", size = 1538724, upload-time = "2024-09-03T09:36:49.8Z" }, +] + +[[package]] +name = "aws-cdk-cloud-assembly-schema" +version = "41.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsii" }, + { name = "publication" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/b4/4f3073b1c29dfe97eacd44c69d3144c5b95d8cc76439dbe40b7d8d7973c9/aws_cdk_cloud_assembly_schema-41.2.0.tar.gz", hash = "sha256:7064ac13f6944fd53f8d8eace611d3c5d8db7014049d629f5c47ede8dc5f2e3b", size = 192007, upload-time = "2025-03-19T07:36:09.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/cd49f3c83768782d3a90ed0748953d5305755e236de624b9b691577372cc/aws_cdk.cloud_assembly_schema-41.2.0-py3-none-any.whl", hash = "sha256:779ca7e3edb02695e0a94a1f38e322b04fbe192cd7944553f80b681a21edd670", size = 190879, upload-time = "2025-03-19T07:36:06.966Z" }, +] + +[[package]] +name = "aws-cdk-lib" +version = "2.190.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aws-cdk-asset-awscli-v1" }, + { name = "aws-cdk-asset-node-proxy-agent-v6" }, + { name = "aws-cdk-cloud-assembly-schema" }, + { name = "constructs" }, + { name = "jsii" }, + { name = "publication" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/bd/cba6299c0c232136b7f1dcfffcdd69fb0a2fd963f728b38dd6fc1ebfcbf7/aws_cdk_lib-2.190.0.tar.gz", hash = "sha256:b7df7834ba9cd510248908b39cf025709cb660886924b1e7a79b93a921a6adb7", size = 40304338, upload-time = "2025-04-17T03:45:42.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/10/18bc69521897d98154895dd3827ae4daaa8d36f8adf274826ec186198974/aws_cdk_lib-2.190.0-py3-none-any.whl", hash = "sha256:afc05f09f978b22167a4a6574e103f388b133955ee6b1592cb9536d30b2130b0", size = 40582151, upload-time = "2025-04-17T03:45:10.71Z" }, +] + +[[package]] +name = "aws-lambda-typing" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/2a/14e7a3dba738265db6eddfd0c9ec1619c0c14e48fcf70627fc75fca83305/aws-lambda-typing-2.20.0.tar.gz", hash = "sha256:78b0d8ebab73b3a6b0da98a7969f4e9c4bb497298ec50f3217da8a8dfba17154", size = 19413, upload-time = "2024-04-02T09:43:03.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/3d/4031f5950d65e89136d20a18b5ac985d5f087c29c902f285b6e8063619e4/aws_lambda_typing-2.20.0-py3-none-any.whl", hash = "sha256:1d44264cabfeab5ac38e67ddd0c874e677b2cbbae77a42d0519df470e6bbb49b", size = 35296, upload-time = "2024-04-02T09:43:05.881Z" }, +] + +[[package]] +name = "boto3" +version = "1.38.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/7d/cdd55376fe9b9102a843649cbd9cba38d49bfd570a89042c090550b23bf5/boto3-1.38.24.tar.gz", hash = "sha256:abdb8c760543e9c22026320e62e2934762b0c4ac4f42e8ea2a756f2d489b3135", size = 111854, upload-time = "2025-05-27T21:26:22.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/cc/78cf9f63bfa84d3f0ac4d5a527a3d141ede40554fd4718ec2634dee08683/boto3-1.38.24-py3-none-any.whl", hash = "sha256:1f95ec3ac88ae6381fa0409e4c2ad0a41f0caf5fd6d8ef45a9525406a3f58b18", size = 139938, upload-time = "2025-05-27T21:26:18.601Z" }, +] + +[[package]] +name = "botocore" +version = "1.38.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1b/1e38f24245e1b0461470176335bc0a443050459e9e64a0d881244a0a8a5e/botocore-1.38.24.tar.gz", hash = "sha256:43563d5c2dfd56ebbcd9e25f482fc45000bfaec5966b26c77b331bd340c46376", size = 13909191, upload-time = "2025-05-27T21:26:08.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/58/197221be8faf51ae4fb72c227601db468ef7981c107efbff27d794445942/botocore-1.38.24-py3-none-any.whl", hash = "sha256:5901667b96d3a8603479879ab097560216cdc4c2918d433fc6509555d0ada29c", size = 13570245, upload-time = "2025-05-27T21:26:04.669Z" }, +] + +[[package]] +name = "cachetools" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/b0/f539a1ddff36644c28a61490056e5bae43bd7386d9f9c69beae2d7e7d6d1/cachetools-6.0.0.tar.gz", hash = "sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf", size = 30160, upload-time = "2025-05-23T20:01:13.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c3/8bb087c903c95a570015ce84e0c23ae1d79f528c349cbc141b5c4e250293/cachetools-6.0.0-py3-none-any.whl", hash = "sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e", size = 10964, upload-time = "2025-05-23T20:01:11.323Z" }, +] + +[[package]] +name = "cattrs" +version = "24.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/7b/da4aa2f95afb2f28010453d03d6eedf018f9e085bd001f039e15731aba89/cattrs-24.1.3.tar.gz", hash = "sha256:981a6ef05875b5bb0c7fb68885546186d306f10f0f6718fe9b96c226e68821ff", size = 426684, upload-time = "2025-03-25T15:01:00.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/ee/d68a3de23867a9156bab7e0a22fb9a0305067ee639032a22982cf7f725e7/cattrs-24.1.3-py3-none-any.whl", hash = "sha256:adf957dddd26840f27ffbd060a6c4dd3b2192c5b7c2c0525ef1bd8131d8a83f5", size = 66462, upload-time = "2025-03-25T15:00:58.663Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "constructs" +version = "10.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsii" }, + { name = "publication" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/24/62b6b537a7fa0348086b5942bd054cd919153ec392dc5594f4b2c5f19218/constructs-10.3.0.tar.gz", hash = "sha256:518551135ec236f9cc6b86500f4fbbe83b803ccdc6c2cb7684e0b7c4d234e7b1", size = 59978, upload-time = "2023-10-07T12:44:42.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/82/5b1407b9747a8c0133d56433dd9b67ec622558b4b47dad6b1751c9d5aeeb/constructs-10.3.0-py3-none-any.whl", hash = "sha256:2972f514837565ff5b09171cfba50c0159dfa75ee86a42921ea8c86f2941b3d2", size = 58188, upload-time = "2023-10-07T12:44:39.598Z" }, +] + +[[package]] +name = "eoapi-cdk" +version = "0.0" +source = { virtual = "." } +dependencies = [ + { name = "stac-item-loader" }, + { name = "stactools-item-generator" }, +] + +[package.dev-dependencies] +deploy = [ + { name = "aws-cdk-lib" }, + { name = "constructs" }, + { name = "pydantic" }, + { name = "pydantic-settings", extra = ["yaml"] }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "types-pyyaml" }, +] +dev = [ + { name = "aws-lambda-typing" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "pytest-postgresql" }, +] + +[package.metadata] +requires-dist = [ + { name = "stac-item-loader", editable = "lib/stac-item-loader/runtime" }, + { name = "stactools-item-generator", editable = "lib/stactools-item-generator/runtime" }, +] + +[package.metadata.requires-dev] +deploy = [ + { name = "aws-cdk-lib", specifier = "==2.190.0" }, + { name = "constructs", specifier = "==10.3.0" }, + { name = "pydantic", specifier = ">=2.11.5" }, + { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.8.1" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, +] +dev = [ + { name = "aws-lambda-typing", specifier = ">=2.20.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "pytest-postgresql", specifier = ">=7.0.1" }, +] + +[[package]] +name = "fire" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/b6/82c7e601d6d3c3278c40b7bd35e17e82aa227f050aa9f66cb7b7fce29471/fire-0.7.0.tar.gz", hash = "sha256:961550f07936eaf65ad1dc8360f2b2bf8408fad46abbfa4d2a3794f8d2a95cdf", size = 87189, upload-time = "2024-10-01T14:29:31.585Z" } + +[[package]] +name = "geojson-pydantic" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/8e/283745fd586aeaadc226919fdaff87b75b266195d7678197f761a1735791/geojson_pydantic-2.0.0.tar.gz", hash = "sha256:b62e8b44502dd1ad518b5f739035a81924a76f980cbdb3a4e8916ef913be242e", size = 9243, upload-time = "2025-05-05T21:01:02.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/e1/2926925dfc37287661f755937df99dff399d3aea2163e11cfd08ca6af3b2/geojson_pydantic-2.0.0-py3-none-any.whl", hash = "sha256:fd75876768a1dcab30dd04c478773191e0c19d678ef74580a9bad7c4576bfe98", size = 8712, upload-time = "2025-05-05T21:01:01.057Z" }, +] + +[[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 = "hydraters" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/fc/5ef0898c3ff29e7c8be6f9502a7891922465f32c2b49eb039245639986dd/hydraters-0.1.2.tar.gz", hash = "sha256:be5b7084deaed77a84d0194814a95138a7585c770e86bc743299e1eb78789f83", size = 76408, upload-time = "2024-12-03T20:21:56.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/53/a135b3932b0db64d27472a93ebf49fc6755ed5175c1f025cf02f533535fa/hydraters-0.1.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b0cc5837d33aa52f8b3bd8db267cb279f01accd78be11eaedd7f325fa2cff14b", size = 205434, upload-time = "2024-12-03T20:20:55.472Z" }, + { url = "https://files.pythonhosted.org/packages/ac/1c/2d03ccc6bef70248cbf51c1634f0a23366dbf5f0896b0b7bb2a1321131c0/hydraters-0.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:642e16272b32a5d045bb231091028afb14781048012ab4fe43ca93467a5f00b7", size = 199913, upload-time = "2024-12-03T20:20:49.843Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/4701caa63cd1060d6c89d2008f3f16020a6bfb08661d9733eed9c879dd00/hydraters-0.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:580f94f1dda43d57a4095f1b2d94bf3d5530e1c08e8f08ebe7fa47baeeefaa39", size = 225394, upload-time = "2024-12-03T20:19:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/2f9494b2452c54aa3f72ed67a4187b3d506ddc86f64ae8b6792e64e5a01a/hydraters-0.1.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:80232269ef05a4cc1616f3e1ffb5f5743998ac3473341f4fedff063825f24c77", size = 233057, upload-time = "2024-12-03T20:19:47.238Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8e/8a14e353ba018e7fd8cd28f4ab6bd4ba50d5c97da94a8e72501e1a775c56/hydraters-0.1.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6153a09853caf21b618d1649628c3324545a247ea23db3a91e13ee664930857c", size = 266263, upload-time = "2024-12-03T20:20:01.857Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2f/5e6a7cc5750d52747b7f6fa63b0a13b4812c697181eff93c3dcb31b98ac6/hydraters-0.1.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46e2fbe19816add6ba5a171c5fdc0f6590afaaa134ebe2be610c263db9d9bdcf", size = 262733, upload-time = "2024-12-03T20:20:18.383Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/0af74de4177abf144bcc616f96fc7271ab95c4cae81a6ea1868bb9b4cb95/hydraters-0.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ba4a3323a06d70a3061358ffec21d4fc3f71d5eb56c6b6c159fed78d5238a61", size = 226180, upload-time = "2024-12-03T20:20:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/4e8c1ea008c9ba167676a75f46f9593044b891b869102ce52a514aa141e4/hydraters-0.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3623f315d947965e75395da83eb3887fdeaf82d2e37b496abc87612390699d04", size = 240012, upload-time = "2024-12-03T20:20:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/a9/37/ef3d4929487fc6435b9537129806c0e7fb8c245a894eef881362cd3f2150/hydraters-0.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67b929f3d21f029e8396fd4c1680cb4281fce88b40d78ed2985ad4d23e7a8bcf", size = 404867, upload-time = "2024-12-03T20:21:02.581Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/bc06bd6f2df4caccf8f9deec3036487e658b7c629a915991f250b9586411/hydraters-0.1.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:309f8c36b4a064a7c316a3b81cf89ce9834d27c6e27f40203a752d15523f3792", size = 496753, upload-time = "2024-12-03T20:21:15.311Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4f/a5b5aaedebb40adcde1a88f3954b6a857cbb0d6be95c3a15724bbc867355/hydraters-0.1.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:836811298457617a2ba6725b5d5143e268e2728f9c3f2dc1d22e286a8809e684", size = 422269, upload-time = "2024-12-03T20:21:30.724Z" }, + { url = "https://files.pythonhosted.org/packages/f6/bd/a449220216ea7665abb10b765b9b118dda38094846bfa7436d9ef1af9b88/hydraters-0.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc26ae2d0a225b4bea4a48cae609327c07227412188a284cff976ed7a8705bd4", size = 397514, upload-time = "2024-12-03T20:21:43.759Z" }, + { url = "https://files.pythonhosted.org/packages/61/01/bb7bbae2fbeedba3b839d0258312062749a0e2ab5632ab5e1d1f9d8005f0/hydraters-0.1.2-cp311-cp311-win32.whl", hash = "sha256:50efad0fdbdc0b3d5cc911bddab34b38bd3ce157d7d98be434ec443f6b302fe2", size = 97099, upload-time = "2024-12-03T20:22:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/67c08e1b831e14cda8d42b64d362a8733648f0bcbfbfeb3903c747a649dc/hydraters-0.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:0421f229f6e0c4b9803f5882d4b6971c6f0a12901a781af13f0c89893afe1fda", size = 102334, upload-time = "2024-12-03T20:21:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/a6/46/b3257a5e396a9719fac9fa08119bbd46fa52525fa1dac494ee8d8b14679c/hydraters-0.1.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb802931167ff4abdb562eafa783e671662b8cae0f148feba21b0b2630b7e5d8", size = 203935, upload-time = "2024-12-03T20:20:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/78/3c/ba7f71547bd3bd97c60dfd9a4c68d2490b6c62f528abab51a652b78c94a6/hydraters-0.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3d1e59e9139d116be0531d541ca32b1dc5db3e1e63c1856e99969c1e5295087f", size = 197920, upload-time = "2024-12-03T20:20:51.996Z" }, + { url = "https://files.pythonhosted.org/packages/e4/47/062c04bebe4e111039cb5d77940fe47300515ecf7959395d4036ad653730/hydraters-0.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3a14f7f864bcaf6782c4f2d1cdab5b2db4505f20e53a8d4661b87f909102b9", size = 225395, upload-time = "2024-12-03T20:19:34.809Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/2689b6fc6d22e648e9f8ace7d160e2dbdf2a55177fef36ddcfc096ff5950/hydraters-0.1.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f862266ac2fa2ac3c057ae050a88e6fa23b1b48d3521ffb2253b5a30d6a0a6ee", size = 233058, upload-time = "2024-12-03T20:19:49.353Z" }, + { url = "https://files.pythonhosted.org/packages/3b/98/4c13aa85192b74f73a333464edce116f6c4b6f0c58266394584165a64c30/hydraters-0.1.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94340e8e230888ac309d100c076e266e62cc45792a13ceaf422c2be0b4df825a", size = 266264, upload-time = "2024-12-03T20:20:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bc/0f802ec7f19bc3080ecd955c9d280c072c8fb4f485e37e5e601464c30e2f/hydraters-0.1.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e4953c26a55ce01a3824fcce1709ef9958e36571e17ab15462e98eda32bcb80", size = 262734, upload-time = "2024-12-03T20:20:20.516Z" }, + { url = "https://files.pythonhosted.org/packages/18/74/20fa2c07866acc82033979d58ccc0f1875f6f0ca081a49e494fb3a1fbe63/hydraters-0.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930589a608a7b039da89d9aa407e68e7d41e34315a74ae351d65060b87c02597", size = 225012, upload-time = "2024-12-03T20:20:42.126Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b2/66b61785146b138427ad1093540b16dcc1aa9b90e83110ec34865136da6e/hydraters-0.1.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ad255a87ab6c741016a04e65ff1bfb8e2178f6f5fbbb70eb4e8856f2121ac93", size = 238517, upload-time = "2024-12-03T20:20:33.223Z" }, + { url = "https://files.pythonhosted.org/packages/18/dd/e97ad8f58fc72b56e1d6a1d846ae1e75156ae0368f804d11895c6b426618/hydraters-0.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf0685477dd5ce36e7808a648ab8eb43811f76db8a45b493a72f86cd9dbcc35d", size = 404867, upload-time = "2024-12-03T20:21:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/81/a4/81f38da134a9d7ad06493332a0297c92483ed9868278c5b0af67a803b099/hydraters-0.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:213653ff6196b59f5f0d080b76ae290a7a014efa430d9a9d56789d7af48396f5", size = 496754, upload-time = "2024-12-03T20:21:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a2/d9c5a1022d7dad3323c8293575abea06032c0ac3da73ad056458b7aa8014/hydraters-0.1.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ab767f780d7d8446cf473239c252b5dc2160d3d3827e587f77fd8f29427658c9", size = 422268, upload-time = "2024-12-03T20:21:32.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/74/04ec19355ab1a135ae38532cab45ffd2ee6b12ad2eca2129c4a9b41357d6/hydraters-0.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d385c2c28cf4608f3bae446a4b485c31fb40e6a451660fc8f92c1b27ca2ffee", size = 397513, upload-time = "2024-12-03T20:21:45.305Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/e6194818716b58c609bf53658c6dec799a4645ee8573f55c784085667309/hydraters-0.1.2-cp312-cp312-win32.whl", hash = "sha256:5bf6339afc4f8e749c0294352503e00c11df7c99da91cc7e90e677539556b891", size = 96435, upload-time = "2024-12-03T20:22:06.238Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/d3270dc9557504496dfeb5f8ae2e07e47d7bc179d22f2001523f981d6401/hydraters-0.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:d2986a8896a388530337fa16db8a429075a6b90825d5eddfeb1aa4d84a006600", size = 101948, upload-time = "2024-12-03T20:21:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/f9e010d3b595ccf0940a2972763090441ef6e46c9dc6c6615996211fb0e8/hydraters-0.1.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ec496ec7ccd4eafc5dcb76ca5b905ddb4f22776b6a97ddecc37c0cd83eea8662", size = 203686, upload-time = "2024-12-03T20:20:59.221Z" }, + { url = "https://files.pythonhosted.org/packages/9e/09/e88939706c6c75ed558b58f68bf334595879bd40f1627390aa693356619e/hydraters-0.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:af307108c65286324508de60513b0013aa1d739c04de4a509dd370737de14666", size = 197842, upload-time = "2024-12-03T20:20:53.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/c0/f0b09e54f92974847aa072bd83662e5c3f8496e9a9a9e2826e913db9a339/hydraters-0.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98927659862a08c35cd60b6a9c28d250017badd923e4c6f81c02b73ba4e6fcc8", size = 225394, upload-time = "2024-12-03T20:19:36.274Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0d/48f36c3c458d6568f752c485908f19c9bcbec7732e8f118f65cb1a8d00db/hydraters-0.1.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d601481f900f1f8973289cd1c736e6dbd73f43152a904e3c73434cc378450", size = 233060, upload-time = "2024-12-03T20:19:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/18/ff/b5f26ef2690fa95432861a9dd92638cffb0cee5d1aff4ed362720f87d365/hydraters-0.1.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d64dbc4e95e7c3f0cd180198e89ad3b768f4a42e4c7ae9dd64f436e2511e25d", size = 266263, upload-time = "2024-12-03T20:20:05.206Z" }, + { url = "https://files.pythonhosted.org/packages/4d/49/90c209fdc48834f246cfa010ffa16fba47389f252a7825766e4e59f19468/hydraters-0.1.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:137ae05375763df23994621c87b77a3d8fcb00034aa6c2d3f9bad0d480392cc0", size = 262734, upload-time = "2024-12-03T20:20:22.109Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/85c1f72c70beac42f88c85044574bf6067c964e4222425f8a4fe0de41908/hydraters-0.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c3ff70f20556941756c41bafe87e2fd8db358c529bb01e618dc0948e1be999c", size = 224958, upload-time = "2024-12-03T20:20:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/b9f0c2246222c0b9ea6c473717e351a11e61a4929053a44d31f4c028e890/hydraters-0.1.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1e924ee658e77ee3e86147378af24bd90be3de2fb6ab7c54ce3e8a85320fe61", size = 238558, upload-time = "2024-12-03T20:20:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/ba8a9dab0ec567e205d63899f376e010b29e6dfc8740d58b6d406ae40348/hydraters-0.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7379dc26d4ccca395a039d8ec0ee42194d737cda69c0ca6b117b6920c9996da", size = 404869, upload-time = "2024-12-03T20:21:05.861Z" }, + { url = "https://files.pythonhosted.org/packages/8a/74/94173ac624127a05a16fdf802e5cc647539ffdc99165087013a12b50ab39/hydraters-0.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a16f7087ed10524641e6e9beddc512248c67c01f000766e93ff963bbec0dac44", size = 496753, upload-time = "2024-12-03T20:21:18.798Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/6803467f94e685b4fe969f6b938dbeaec1b360881ca14c9cec0ca31b7fb8/hydraters-0.1.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b067c0949fd24dbf74bc0a57bf350bd7164652969cbb0c95f2446bd9904733cc", size = 422269, upload-time = "2024-12-03T20:21:33.903Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a9/957faadaac9703e29e167592ec1aac9cacb8b18a71f15a1e1cb44e4840bc/hydraters-0.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fee78fe14fd21d40d89625b6edfec70e0c822b286e5d6d47ba2cecb27c047a82", size = 397513, upload-time = "2024-12-03T20:21:46.992Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/d0aacc97a137eb5d7fb36ff94e18df82ff06c126a9d8a773d1206f7aa75c/hydraters-0.1.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:919d54f86e2bcd700381f54b9318c28db869b025b2aee105439c4374ea7f2e65", size = 225399, upload-time = "2024-12-03T20:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/60/d6/b4f95a797fa72a78cf73962bcf3c050a121d8ae75e79091393e14c1b0eed/hydraters-0.1.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aaa3d7fb10dfa477907469cec38d3669bc30e3dcefc0568c9d60903865eddf62", size = 233063, upload-time = "2024-12-03T20:19:53.097Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b3/a5b462b5a080e4a1ff111ffa0c8e15aa470f898342a9c26bada034c6437e/hydraters-0.1.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f03e9760536e1f1c4a575df4be3b683c1f6bf4b8b008bdc3333c1a0ec301d5d", size = 266267, upload-time = "2024-12-03T20:20:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/74/d9/b0d3708c42a2ca0e74102910bd96d657ef77be58e75c1c8ab4653025728e/hydraters-0.1.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e047563219b6cf47ce5fd44265441cba7b687fa159644f311ef5ba7f7584ba8c", size = 262739, upload-time = "2024-12-03T20:20:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/e0/85/0fd7c0f530cc0305920dd0194f0075a55a94cdec3af82912348a84fabab3/hydraters-0.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e2b0acdb9a2994ccd62390f63fa6aabe3c582790974cb5d914cbe3c9d6c6eb4d", size = 404872, upload-time = "2024-12-03T20:21:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/4a/16/a19d6a15095f4d0098c723e7e5ae2d505a859900c01ba1ef8648589d3da2/hydraters-0.1.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:388315764f12db409755d38ffae39a2300fc2a5ecfb7f370a058d91fb2593237", size = 496757, upload-time = "2024-12-03T20:21:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/c64b8d6ab1aa73a09f85721c04a61c225c43c0b2b10fd5652f140fbd41c1/hydraters-0.1.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:44d965c8ba146517df8dc3251f2d18edc2d3f257f76fc64b449d71d122c6efc0", size = 422273, upload-time = "2024-12-03T20:21:35.518Z" }, + { url = "https://files.pythonhosted.org/packages/4d/41/436d3c3ddf0c61dc4b54b7a64846702250c9f1ba0ce937f499848dd3950a/hydraters-0.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:665a2661ce41b457219225ab64c1102cc25299b45344f2bc70fa02d82449b271", size = 397519, upload-time = "2024-12-03T20:21:48.546Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "jsii" +version = "1.112.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "importlib-resources" }, + { name = "publication" }, + { name = "python-dateutil" }, + { name = "typeguard" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/3e/270b5236035fc7bb2cdd7f55ea25f85489d35d971870cbec32c3d9e99d7f/jsii-1.112.0.tar.gz", hash = "sha256:6b7d19f361c2565b76828ecbe8cbed8b8d6028a82aa98a46b206a4ee5083157e", size = 624533, upload-time = "2025-05-07T14:45:52.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/af/8554b632e2b82f37a7422782aba5db2a1fbff4887faa7ec850818def8407/jsii-1.112.0-py3-none-any.whl", hash = "sha256:6510c223074d9b206fd0570849a791e4d9ecfff7ffe68428de73870cea9f55a1", size = 600681, upload-time = "2025-05-07T14:45:51.136Z" }, +] + +[[package]] +name = "mirakuru" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil", marker = "sys_platform != 'cygwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/e7/f03bd274ddc73ca7e52fcf96ea7a4eaa21b3bec11179af95c669aec5272d/mirakuru-2.6.0.tar.gz", hash = "sha256:3256fcf81ef090a30be97a8ce50ff0c178292d7e542866c5fedc5ae6801e3a17", size = 30054, upload-time = "2025-02-07T10:17:41.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/72/98992e3f0ced01b154b0962a2bb2855f2e0ca9021388b8a486b13556cc61/mirakuru-2.6.0-py3-none-any.whl", hash = "sha256:0ff7080997e63289dc309d0237e137ca2cfa863b3d26b3d5e8fd4e1c2b2ef659", size = 29183, upload-time = "2025-02-07T10:17:40.188Z" }, +] + +[[package]] +name = "orjson" +version = "3.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810, upload-time = "2025-04-29T23:30:08.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c7/c54a948ce9a4278794f669a353551ce7db4ffb656c69a6e1f2264d563e50/orjson-3.10.18-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e0a183ac3b8e40471e8d843105da6fbe7c070faab023be3b08188ee3f85719b8", size = 248929, upload-time = "2025-04-29T23:28:30.716Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a9c674ef1dd8ab22b5b10f9300e7e70444d4e3cda4b8258d6c2488c32143/orjson-3.10.18-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5ef7c164d9174362f85238d0cd4afdeeb89d9e523e4651add6a5d458d6f7d42d", size = 133364, upload-time = "2025-04-29T23:28:32.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4e/f7d1bdd983082216e414e6d7ef897b0c2957f99c545826c06f371d52337e/orjson-3.10.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd14c5d99cdc7bf93f22b12ec3b294931518aa019e2a147e8aa2f31fd3240f7", size = 136995, upload-time = "2025-04-29T23:28:34.024Z" }, + { url = "https://files.pythonhosted.org/packages/17/89/46b9181ba0ea251c9243b0c8ce29ff7c9796fa943806a9c8b02592fce8ea/orjson-3.10.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b672502323b6cd133c4af6b79e3bea36bad2d16bca6c1f645903fce83909a7a", size = 132894, upload-time = "2025-04-29T23:28:35.318Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dd/7bce6fcc5b8c21aef59ba3c67f2166f0a1a9b0317dcca4a9d5bd7934ecfd/orjson-3.10.18-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51f8c63be6e070ec894c629186b1c0fe798662b8687f3d9fdfa5e401c6bd7679", size = 137016, upload-time = "2025-04-29T23:28:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4a/b8aea1c83af805dcd31c1f03c95aabb3e19a016b2a4645dd822c5686e94d/orjson-3.10.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9478ade5313d724e0495d167083c6f3be0dd2f1c9c8a38db9a9e912cdaf947", size = 138290, upload-time = "2025-04-29T23:28:38.3Z" }, + { url = "https://files.pythonhosted.org/packages/36/d6/7eb05c85d987b688707f45dcf83c91abc2251e0dd9fb4f7be96514f838b1/orjson-3.10.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187aefa562300a9d382b4b4eb9694806e5848b0cedf52037bb5c228c61bb66d4", size = 142829, upload-time = "2025-04-29T23:28:39.657Z" }, + { url = "https://files.pythonhosted.org/packages/d2/78/ddd3ee7873f2b5f90f016bc04062713d567435c53ecc8783aab3a4d34915/orjson-3.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da552683bc9da222379c7a01779bddd0ad39dd699dd6300abaf43eadee38334", size = 132805, upload-time = "2025-04-29T23:28:40.969Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/c8e047f73d2c5d21ead9c180203e111cddeffc0848d5f0f974e346e21c8e/orjson-3.10.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e450885f7b47a0231979d9c49b567ed1c4e9f69240804621be87c40bc9d3cf17", size = 135008, upload-time = "2025-04-29T23:28:42.284Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4b/dccbf5055ef8fb6eda542ab271955fc1f9bf0b941a058490293f8811122b/orjson-3.10.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5e3c9cc2ba324187cd06287ca24f65528f16dfc80add48dc99fa6c836bb3137e", size = 413419, upload-time = "2025-04-29T23:28:43.673Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/1eac0c5e2d6d6790bd2025ebfbefcbd37f0d097103d76f9b3f9302af5a17/orjson-3.10.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:50ce016233ac4bfd843ac5471e232b865271d7d9d44cf9d33773bcd883ce442b", size = 153292, upload-time = "2025-04-29T23:28:45.573Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b4/ef0abf64c8f1fabf98791819ab502c2c8c1dc48b786646533a93637d8999/orjson-3.10.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b3ceff74a8f7ffde0b2785ca749fc4e80e4315c0fd887561144059fb1c138aa7", size = 137182, upload-time = "2025-04-29T23:28:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a3/6ea878e7b4a0dc5c888d0370d7752dcb23f402747d10e2257478d69b5e63/orjson-3.10.18-cp311-cp311-win32.whl", hash = "sha256:fdba703c722bd868c04702cac4cb8c6b8ff137af2623bc0ddb3b3e6a2c8996c1", size = 142695, upload-time = "2025-04-29T23:28:48.564Z" }, + { url = "https://files.pythonhosted.org/packages/79/2a/4048700a3233d562f0e90d5572a849baa18ae4e5ce4c3ba6247e4ece57b0/orjson-3.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:c28082933c71ff4bc6ccc82a454a2bffcef6e1d7379756ca567c772e4fb3278a", size = 134603, upload-time = "2025-04-29T23:28:50.442Z" }, + { url = "https://files.pythonhosted.org/packages/03/45/10d934535a4993d27e1c84f1810e79ccf8b1b7418cef12151a22fe9bb1e1/orjson-3.10.18-cp311-cp311-win_arm64.whl", hash = "sha256:a6c7c391beaedd3fa63206e5c2b7b554196f14debf1ec9deb54b5d279b1b46f5", size = 131400, upload-time = "2025-04-29T23:28:51.838Z" }, + { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184, upload-time = "2025-04-29T23:28:53.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279, upload-time = "2025-04-29T23:28:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799, upload-time = "2025-04-29T23:28:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791, upload-time = "2025-04-29T23:28:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059, upload-time = "2025-04-29T23:29:00.129Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359, upload-time = "2025-04-29T23:29:01.704Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853, upload-time = "2025-04-29T23:29:03.576Z" }, + { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131, upload-time = "2025-04-29T23:29:05.753Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834, upload-time = "2025-04-29T23:29:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368, upload-time = "2025-04-29T23:29:09.301Z" }, + { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359, upload-time = "2025-04-29T23:29:10.813Z" }, + { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466, upload-time = "2025-04-29T23:29:12.26Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683, upload-time = "2025-04-29T23:29:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754, upload-time = "2025-04-29T23:29:15.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218, upload-time = "2025-04-29T23:29:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087, upload-time = "2025-04-29T23:29:19.083Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273, upload-time = "2025-04-29T23:29:20.602Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779, upload-time = "2025-04-29T23:29:22.062Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811, upload-time = "2025-04-29T23:29:23.602Z" }, + { url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018, upload-time = "2025-04-29T23:29:25.094Z" }, + { url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368, upload-time = "2025-04-29T23:29:26.609Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840, upload-time = "2025-04-29T23:29:28.153Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135, upload-time = "2025-04-29T23:29:29.726Z" }, + { url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810, upload-time = "2025-04-29T23:29:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491, upload-time = "2025-04-29T23:29:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277, upload-time = "2025-04-29T23:29:34.946Z" }, + { url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367, upload-time = "2025-04-29T23:29:36.52Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687, upload-time = "2025-04-29T23:29:38.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794, upload-time = "2025-04-29T23:29:40.349Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186, upload-time = "2025-04-29T23:29:41.922Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "plpygis" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/b6/c658eb83673b14ef553a2b0f9c3a6e94edaf3b54b11cb89ba88c20b472eb/plpygis-0.5.5.tar.gz", hash = "sha256:153f23ef00726b389f1a49442f8b469d07b9e62a1004024ec8322d00d1a882ed", size = 34097, upload-time = "2024-09-08T13:49:32.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/3a/2abfb2e649a7c20630d3c4d2d497373098e3f7052abea3048ecef9ee330f/plpygis-0.5.5-py3-none-any.whl", hash = "sha256:d8317146615f017aa150723ce907571a4c40c7ebff4b8be58ad44749938f68dc", size = 25416, upload-time = "2024-09-08T13:49:30.624Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "port-for" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/84/ad5114c85217426d7a5170a74a6f9d6b724df117c2f3b75e41fc9d6c6811/port_for-0.7.4.tar.gz", hash = "sha256:fc7713e7b22f89442f335ce12536653656e8f35146739eccaeff43d28436028d", size = 25077, upload-time = "2024-10-09T12:28:38.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/a2/579dcefbb0285b31f8d65b537f8a9932ed51319e0a3694e01b5bbc271f92/port_for-0.7.4-py3-none-any.whl", hash = "sha256:08404aa072651a53dcefe8d7a598ee8a1dca320d9ac44ac464da16ccf2a02c4a", size = 21369, upload-time = "2024-10-09T12:28:37.853Z" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "psycopg" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/84/259ea58aca48e03c3c793b4ccfe39ed63db7b8081ef784d039330d9eed96/psycopg_binary-3.2.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4", size = 4040785, upload-time = "2025-05-13T16:07:07.569Z" }, + { url = "https://files.pythonhosted.org/packages/25/22/ce58ffda2b7e36e45042b4d67f1bbd4dd2ccf4cfd2649696685c61046475/psycopg_binary-3.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b", size = 4087601, upload-time = "2025-05-13T16:07:11.75Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/b043e85268650c245025e80039b79663d8986f857bc3d3a72b1de67f3550/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a", size = 4676524, upload-time = "2025-05-13T16:07:17.038Z" }, + { url = "https://files.pythonhosted.org/packages/da/29/7afbfbd3740ea52fda488db190ef2ef2a9ff7379b85501a2142fb9f7dd56/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc", size = 4495671, upload-time = "2025-05-13T16:07:21.709Z" }, + { url = "https://files.pythonhosted.org/packages/ea/eb/df69112d18a938cbb74efa1573082248437fa663ba66baf2cdba8a95a2d0/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b", size = 4768132, upload-time = "2025-05-13T16:07:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/76/fe/4803b20220c04f508f50afee9169268553f46d6eed99640a08c8c1e76409/psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95", size = 4458394, upload-time = "2025-05-13T16:07:29.148Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0f/5ecc64607ef6f62b04e610b7837b1a802ca6f7cb7211339f5d166d55f1dd/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891", size = 3776879, upload-time = "2025-05-13T16:07:32.503Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d8/1c3d6e99b7db67946d0eac2cd15d10a79aa7b1e3222ce4aa8e7df72027f5/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186", size = 3333329, upload-time = "2025-05-13T16:07:35.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/a4e82099816559f558ccaf2b6945097973624dc58d5d1c91eb1e54e5a8e9/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d", size = 3435683, upload-time = "2025-05-13T16:07:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f27055290d58e8818bed8a297162a096ef7f8ecdf01d98772d4b02af46c4/psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a", size = 3497124, upload-time = "2025-05-13T16:07:40.567Z" }, + { url = "https://files.pythonhosted.org/packages/67/3d/17ed07579625529534605eeaeba34f0536754a5667dbf20ea2624fc80614/psycopg_binary-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a", size = 2939520, upload-time = "2025-05-13T16:07:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/29/6f/ec9957e37a606cd7564412e03f41f1b3c3637a5be018d0849914cb06e674/psycopg_binary-3.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e", size = 4022205, upload-time = "2025-05-13T16:07:48.195Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/497b8bea72b20a862ac95a94386967b745a472d9ddc88bc3f32d5d5f0d43/psycopg_binary-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0", size = 4083795, upload-time = "2025-05-13T16:07:50.917Z" }, + { url = "https://files.pythonhosted.org/packages/42/07/af9503e8e8bdad3911fd88e10e6a29240f9feaa99f57d6fac4a18b16f5a0/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff", size = 4655043, upload-time = "2025-05-13T16:07:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/aff8c9850df1648cc6a5cc7a381f11ee78d98a6b807edd4a5ae276ad60ad/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724", size = 4477972, upload-time = "2025-05-13T16:07:57.925Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/8e9d1b77ec1a632818fe2f457c3a65af83c68710c4c162d6866947d08cc5/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d", size = 4737516, upload-time = "2025-05-13T16:08:01.616Z" }, + { url = "https://files.pythonhosted.org/packages/46/ec/222238f774cd5a0881f3f3b18fb86daceae89cc410f91ef6a9fb4556f236/psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6", size = 4436160, upload-time = "2025-05-13T16:08:04.278Z" }, + { url = "https://files.pythonhosted.org/packages/37/78/af5af2a1b296eeca54ea7592cd19284739a844974c9747e516707e7b3b39/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40", size = 3753518, upload-time = "2025-05-13T16:08:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ac/8a3ed39ea069402e9e6e6a2f79d81a71879708b31cc3454283314994b1ae/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795", size = 3313598, upload-time = "2025-05-13T16:08:09.999Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/26549af068347c808fbfe5f07d2fa8cef747cfff7c695136172991d2378b/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a", size = 3407289, upload-time = "2025-05-13T16:08:12.66Z" }, + { url = "https://files.pythonhosted.org/packages/67/55/ea8d227c77df8e8aec880ded398316735add8fda5eb4ff5cc96fac11e964/psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4", size = 3472493, upload-time = "2025-05-13T16:08:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/6ff2a5bc53c3cd653d281666728e29121149179c73fddefb1e437024c192/psycopg_binary-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21", size = 2927400, upload-time = "2025-05-13T16:08:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/28/0b/f61ff4e9f23396aca674ed4d5c9a5b7323738021d5d72d36d8b865b3deaf/psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e", size = 4017127, upload-time = "2025-05-13T16:08:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/7e181fb1179fbfc24493738b61efd0453d4b70a0c4b12728e2b82db355fd/psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5", size = 4080322, upload-time = "2025-05-13T16:08:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/94fc267c1d1392c4211e54ccb943be96ea4032e761573cf1047951887494/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351", size = 4655097, upload-time = "2025-05-13T16:08:27.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/17/31b3acf43de0b2ba83eac5878ff0dea5a608ca2a5c5dd48067999503a9de/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab", size = 4482114, upload-time = "2025-05-13T16:08:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/78/b4d75e5fd5a85e17f2beb977abbba3389d11a4536b116205846b0e1cf744/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748", size = 4737693, upload-time = "2025-05-13T16:08:34.625Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/7325a8550e3388b00b5e54f4ced5e7346b531eb4573bf054c3dbbfdc14fe/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87", size = 4437423, upload-time = "2025-05-13T16:08:37.444Z" }, + { url = "https://files.pythonhosted.org/packages/1a/db/cef77d08e59910d483df4ee6da8af51c03bb597f500f1fe818f0f3b925d3/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f", size = 3758667, upload-time = "2025-05-13T16:08:40.116Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/252fcbffb47189aa84d723b54682e1bb6d05c8875fa50ce1ada914ae6e28/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2", size = 3320576, upload-time = "2025-05-13T16:08:43.243Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439, upload-time = "2025-05-13T16:08:47.321Z" }, + { url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477, upload-time = "2025-05-13T16:08:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009, upload-time = "2025-05-13T16:08:53.67Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/13/1e7850bb2c69a63267c3dbf37387d3f71a00fd0e2fa55c5db14d64ba1af4/psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5", size = 29770, upload-time = "2025-02-26T12:03:47.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/fd/4feb52a55c1a4bd748f2acaed1903ab54a723c47f6d0242780f4d97104d4/psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7", size = 38252, upload-time = "2025-02-26T12:03:45.073Z" }, +] + +[[package]] +name = "publication" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/8e/8c9fe7e32fdf9c386f83d59610cc819a25dadb874b5920f2d0ef7d35f46d/publication-0.0.3.tar.gz", hash = "sha256:68416a0de76dddcdd2930d1c8ef853a743cc96c82416c4e4d3b5d901c6276dc4", size = 5484, upload-time = "2019-01-15T07:52:23.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d3/6308debad7afcdb3ea5f50b4b3d852f41eb566a311fbcb4da23755a28155/publication-0.0.3-py2.py3-none-any.whl", hash = "sha256:0248885351febc11d8a1098d5c8e3ab2dabcf3e8c0c96db1e17ecd12b53afbe6", size = 7687, upload-time = "2019-01-15T07:52:22.151Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, +] + +[package.optional-dependencies] +yaml = [ + { name = "pyyaml" }, +] + +[[package]] +name = "pypgstac" +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "fire" }, + { name = "hydraters" }, + { name = "orjson" }, + { name = "plpygis" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "smart-open" }, + { name = "tenacity" }, + { name = "version-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/a2/d7c5dc592ab00f32d211b7389116e5968d3644ea5088675c4a21bd698b15/pypgstac-0.9.6.tar.gz", hash = "sha256:47d14f5c8d519e001ad7ae6b38d1a022b6a83683bae94ec77a79942c01cd8b99", size = 1443343, upload-time = "2025-04-10T16:05:08.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/98279956c1cecba75a92013898bbd0fcd2cff3cd76462cf13c0fc92ded5a/pypgstac-0.9.6-py3-none-any.whl", hash = "sha256:317c95ddb88b5a86396c58f481f9fa56a1c40d6e5dedddc62265d438fdd50b50", size = 1606251, upload-time = "2025-04-10T16:05:06.373Z" }, +] + +[package.optional-dependencies] +psycopg = [ + { name = "psycopg", extra = ["binary"] }, + { name = "psycopg-pool" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "pytest-postgresql" +version = "7.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mirakuru" }, + { name = "packaging" }, + { name = "port-for" }, + { name = "psycopg" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/15/b3c07d1537c7608c3f45d3ee6f778a56b1daa480221bb500abc9e44e01a0/pytest_postgresql-7.0.2.tar.gz", hash = "sha256:57c8d3f7d4e91d0ea8b2eac786d04f60080fa6ed6e66f1f94d747c71c9e5a4f4", size = 50691, upload-time = "2025-05-17T20:17:59.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/57/f2db5a80b10c3ac48ce41786cb9b14172f997509ee1b1055ab7db4238e5e/pytest_postgresql-7.0.2-py3-none-any.whl", hash = "sha256:0b0d31c51620a9c1d6be93286af354256bc58a47c379f56f4147b22da6e81fb5", size = 41447, upload-time = "2025-05-17T20:17:58.011Z" }, +] + +[[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 = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/5d/9dcc100abc6711e8247af5aa561fc07c4a046f72f659c3adea9a449e191a/s3transfer-0.13.0.tar.gz", hash = "sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177", size = 150232, upload-time = "2025-05-22T19:24:50.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/17/22bf8155aa0ea2305eefa3a6402e040df7ebe512d1310165eda1e233c3f8/s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be", size = 85152, upload-time = "2025-05-22T19:24:48.703Z" }, +] + +[[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 = "smart-open" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/30/1f41c3d3b8cec82024b4b277bfd4e5b18b765ae7279eb9871fa25c503778/smart_open-7.1.0.tar.gz", hash = "sha256:a4f09f84f0f6d3637c6543aca7b5487438877a21360e7368ccf1f704789752ba", size = 72044, upload-time = "2024-12-17T13:19:17.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/18/9a8d9f01957aa1f8bbc5676d54c2e33102d247e146c1a3679d3bd5cc2e3a/smart_open-7.1.0-py3-none-any.whl", hash = "sha256:4b8489bb6058196258bafe901730c7db0dcf4f083f316e97269c66f45502055b", size = 61746, upload-time = "2024-12-17T13:19:21.076Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "stac-item-loader" +version = "0.1.0" +source = { editable = "lib/stac-item-loader/runtime" } +dependencies = [ + { name = "boto3" }, + { name = "pypgstac", extra = ["psycopg"] }, + { name = "stac-pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3" }, + { name = "pypgstac", extras = ["psycopg"] }, + { name = "stac-pydantic", specifier = ">=3.2.0" }, +] + +[[package]] +name = "stac-pydantic" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "geojson-pydantic" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/56/2ba7ff7d221e1a3dd3ecd3e3ae865970824535de00477531d18526af550d/stac_pydantic-3.2.0.tar.gz", hash = "sha256:b0a7b8662b359941bc9bb4f50d781fbaa2076b2aceb8186b6a48542555164989", size = 20793, upload-time = "2025-03-20T09:10:09.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/d3/90347937f859f8a092c25a060488c318c5e0c6311f62a654a58f8b3dbe91/stac_pydantic-3.2.0-py3-none-any.whl", hash = "sha256:747dfe36b271dcb940c0a1d4b4f77f573a9cab35af80d6a141591e19c6c7ddef", size = 23396, upload-time = "2025-03-20T09:10:08.164Z" }, +] + +[[package]] +name = "stactools-item-generator" +version = "0.1.0" +source = { editable = "lib/stactools-item-generator/runtime" } +dependencies = [ + { name = "pydantic" }, + { name = "stac-pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.11.0" }, + { name = "stac-pydantic", specifier = ">=3.2.0" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + +[[package]] +name = "typeguard" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/38/c61bfcf62a7b572b5e9363a802ff92559cb427ee963048e1442e3aef7490/typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4", size = 40604, upload-time = "2021-12-10T21:09:39.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/bb/d43e5c75054e53efce310e79d63df0ac3f25e34c926be5dffb7d283fb2a8/typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1", size = 17605, upload-time = "2021-12-10T21:09:37.844Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] + +[[package]] +name = "version-parser" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/18/61a661be6225176a05ce1cad4f1bcfc9cba57744a9faacf550c5a9e68070/version_parser-1.0.1.tar.gz", hash = "sha256:7320b00cab8a04694206818c9129864dd0783cec0c0eff25b1c922e7d838dbc0", size = 4357, upload-time = "2021-01-06T16:33:05.526Z" } + +[[package]] +name = "wrapt" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308, upload-time = "2025-01-14T10:33:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488, upload-time = "2025-01-14T10:33:35.264Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776, upload-time = "2025-01-14T10:33:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776, upload-time = "2025-01-14T10:33:40.678Z" }, + { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420, upload-time = "2025-01-14T10:33:41.868Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199, upload-time = "2025-01-14T10:33:43.598Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307, upload-time = "2025-01-14T10:33:48.499Z" }, + { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025, upload-time = "2025-01-14T10:33:51.191Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879, upload-time = "2025-01-14T10:33:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419, upload-time = "2025-01-14T10:33:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773, upload-time = "2025-01-14T10:33:56.323Z" }, + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload-time = "2025-01-14T10:34:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" }, +]