streamwright 0.1.9__tar.gz → 0.1.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {streamwright-0.1.9/src/streamwright.egg-info → streamwright-0.1.10}/PKG-INFO +1 -1
- {streamwright-0.1.9 → streamwright-0.1.10}/pyproject.toml +1 -1
- streamwright-0.1.10/src/streamwright/core/cli/commands/connectors.py +173 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/jobs.py +18 -2
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/main.py +20 -2
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/parser.py +13 -7
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/cli.py +27 -18
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/engine.py +7 -2
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/executor.py +7 -2
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/guide.md +73 -2
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/rules.py +22 -4
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/types.py +10 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/contract.py +12 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/contract_terms.py +1 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/__init__.py +1 -1
- {streamwright-0.1.9 → streamwright-0.1.10/src/streamwright.egg-info}/PKG-INFO +1 -1
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_cli_subcommands.py +17 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_connector_contract.py +9 -1
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_logging.py +42 -10
- streamwright-0.1.9/src/streamwright/core/cli/commands/connectors.py +0 -103
- {streamwright-0.1.9 → streamwright-0.1.10}/LICENSE +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/README.md +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/setup.cfg +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/setup.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/catalog.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/__main__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/commands/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/commands/jobs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/commands/source.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/cli/log_options.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/config/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/config/inputs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/config/loader.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/config/reader.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/bookmarks.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/builders.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/errors.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/execution.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/factories.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/partitions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/planning.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/queries.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/readers.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/responses.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/rules.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/runner.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/session.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/compilation.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/list_parameters.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/ordering.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/plans.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/sandbox.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/stream_plan.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/tables.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/sql/types.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/values.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/engine/writers.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/graph.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/api_calls.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/capabilities.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/database_connections.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/database_rules.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/databases.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/file_formats.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/file_rules.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/files.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/http.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/local.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/locations.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/queries.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/sandbox.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/io/store_reader.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/calls.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/connections.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/dependencies.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/findings.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/names.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/outputs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/refs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/rules.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/runtime.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/secrets.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/sections.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/spec.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/steps.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/check/structure.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/adapter.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/definition_adapter.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/loading.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/plugin.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/reads.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/session.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/steps.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/writers/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/duckdb/writers/tables.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/engine.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/fields.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/column_rules.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/formats.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/headers.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/mapped.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/model.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/ingest/plain.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/inputs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/manifest.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/naming.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/plan.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/policy.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/refs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/runner.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/schema.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/state/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/state/dialects.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/state/migrations.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/state/protocol.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/state/store.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/validate.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/jobs/write_rules.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/auth.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/downloads.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/encoding.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/errors.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/http.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/paginators.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/net/policies.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/base.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/dlt_columns.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/dlt_output.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/ducklake.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/file_names.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/files.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/loading.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/output.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/staging.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/outputs/warehouse.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/py.typed +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/components.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/connector_types.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/constants.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/contract_declarations.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/contract_guide.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/contract_validation.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/definitions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/formatting.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/handlers.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/lines.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/metrics.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/network.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/record_factory.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/redaction.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/setup.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/logs/summary.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/plugins.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/redact.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/source_connectors.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/templates.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/runtime/testing.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/constants.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/contract.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/engines/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/engines/duckdb.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/engines/source.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/messaging.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/network.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/operations/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/operations/databases.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/operations/files.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/operations/http.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/operations/messages.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/operations/tables.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/py.typed +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/testing/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/testing/contract.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/testing/helpers.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sdk/testing/storage.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sql/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sql/analysis.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sql/conditions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/sql/quote.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/auth.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/checker.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/engine.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/issues.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/job_manifests.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/json_schemas.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/marked_yaml.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/partitions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/requests.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/schema.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/__init__.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/async_jobs.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/document.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/partitions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/references.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/request_partitions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/requests.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/spec.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/stream_context.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/streams.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/tables.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/sections/templates.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/source.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/streams.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright/core/validation/vocabulary.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright.egg-info/SOURCES.txt +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright.egg-info/dependency_links.txt +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright.egg-info/entry_points.txt +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright.egg-info/requires.txt +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/src/streamwright.egg-info/top_level.txt +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_catalog.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_conditions.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_connector_api_family.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_connectors.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_dlt_output.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_engine_reach.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_exact_decimals.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_graph.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_http_engine.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_output_contract.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_output_formats.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_plugin_contract_gate.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_runner.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_sdk_plugins.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_sql.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_sql_quote.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_transform.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_validation.py +0 -0
- {streamwright-0.1.9 → streamwright-0.1.10}/tests/test_warehouse_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamwright
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: Declarative data connectors — pull from APIs, files and databases with YAML, shape with SQL, write anywhere.
|
|
5
5
|
Author-email: Karthick Jaganathan <kjaganathan.sde@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "streamwright"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.10"
|
|
8
8
|
description = "Declarative data connectors — pull from APIs, files and databases with YAML, shape with SQL, write anywhere."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# /*************************************************************************
|
|
3
|
+
# * Copyright 2025 Karthick Jaganathan
|
|
4
|
+
# *
|
|
5
|
+
# * Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# * you may not use this file except in compliance with the License.
|
|
7
|
+
# * You may obtain a copy of the License at
|
|
8
|
+
# *
|
|
9
|
+
# * https://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
# *
|
|
11
|
+
# * Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# * distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# * See the License for the specific language governing permissions and
|
|
15
|
+
# * limitations under the License.
|
|
16
|
+
# **************************************************************************/
|
|
17
|
+
|
|
18
|
+
"""`streamwright connectors`: the installed connectors, and the catalog's (list, search, install)."""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from importlib import metadata
|
|
23
|
+
|
|
24
|
+
import duckdb
|
|
25
|
+
|
|
26
|
+
from streamwright.core import catalog
|
|
27
|
+
from streamwright.core.runtime import components, plugins
|
|
28
|
+
from streamwright.core.runtime.logs.lines import NETWORK
|
|
29
|
+
|
|
30
|
+
_REQUIREMENT_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
31
|
+
# a connector that logs on streamwright.network calls through StreamWright's HTTP client, which is built on requests
|
|
32
|
+
HTTP_CLIENT_LIBRARY = "requests"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _installed(name):
|
|
36
|
+
try:
|
|
37
|
+
return "%s==%s" % (name, metadata.version(name))
|
|
38
|
+
except metadata.PackageNotFoundError:
|
|
39
|
+
return "%s (not installed)" % name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def libraries(distribution, loggers=()):
|
|
43
|
+
"""
|
|
44
|
+
The third-party libraries a connector uses, as "name==version" (or "name (not installed)"): what its distribution
|
|
45
|
+
requires (not its extras', not StreamWright's), and requests when it calls through StreamWright's HTTP client.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
requires = (metadata.requires(distribution) or []) if distribution else []
|
|
49
|
+
except metadata.PackageNotFoundError:
|
|
50
|
+
requires = []
|
|
51
|
+
names = []
|
|
52
|
+
for requirement in requires:
|
|
53
|
+
spec, _, marker = requirement.partition(";")
|
|
54
|
+
match = _REQUIREMENT_NAME.match(spec)
|
|
55
|
+
if "extra" not in marker and match and not match.group(1).lower().startswith("streamwright"):
|
|
56
|
+
names.append(match.group(1))
|
|
57
|
+
if NETWORK.name in loggers and HTTP_CLIENT_LIBRARY not in names:
|
|
58
|
+
names.append(HTTP_CLIENT_LIBRARY)
|
|
59
|
+
return [_installed(name) for name in names]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DuckDBExtensions:
|
|
63
|
+
"""The installed DuckDB's extensions (one in-memory query, made when a connector names one): name@version."""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self._installed = None
|
|
67
|
+
|
|
68
|
+
def _load(self):
|
|
69
|
+
self._installed = {}
|
|
70
|
+
rows = duckdb.connect().execute(
|
|
71
|
+
"SELECT extension_name, aliases, installed, extension_version FROM duckdb_extensions()").fetchall()
|
|
72
|
+
for name, aliases, installed, version in rows:
|
|
73
|
+
for key in (name, *(aliases or ())):
|
|
74
|
+
self._installed[key] = (name, version if installed else None)
|
|
75
|
+
self._installed[None] = ("duckdb", duckdb.__version__)
|
|
76
|
+
|
|
77
|
+
def describe(self, names):
|
|
78
|
+
"""("duckdb==VERSION", ["EXTENSION@VERSION" or "EXTENSION (not installed)", ...]); (None, []) for no names."""
|
|
79
|
+
if not names:
|
|
80
|
+
return None, []
|
|
81
|
+
if self._installed is None:
|
|
82
|
+
self._load()
|
|
83
|
+
found = []
|
|
84
|
+
for name in names:
|
|
85
|
+
extension, version = self._installed.get(name, (name, None))
|
|
86
|
+
found.append("%s@%s" % (extension, version) if version else "%s (not installed)" % extension)
|
|
87
|
+
return "duckdb==%s" % self._installed[None][1], found
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fields(name, definition, entry, extensions):
|
|
91
|
+
"""A connector's `pip show`-style fields."""
|
|
92
|
+
used = libraries(entry.get("distribution"), definition.loggers)
|
|
93
|
+
duckdb_library, loaded = extensions.describe(definition.duckdb_extensions)
|
|
94
|
+
return (("Name", name), ("Title", definition.title), ("Family", definition.family),
|
|
95
|
+
("Summary", definition.summary), ("Package", entry.get("distribution") or "(registered in-process)"),
|
|
96
|
+
("Version", entry.get("version") or ""), ("Libraries", ", ".join(used + [duckdb_library] * bool(loaded))),
|
|
97
|
+
("DuckDB-Extensions", ", ".join(loaded)), ("Loggers", ", ".join(definition.loggers)))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _connector_lines(names=None):
|
|
101
|
+
"""
|
|
102
|
+
The installed (and registered) connectors, sorted by family and name, as `pip show` shows packages (`---` between
|
|
103
|
+
them): name, title, family, summary, distribution and version, the third-party libraries and DuckDB extensions it
|
|
104
|
+
uses (installed versions) and its loggers (for --log). `names`: only these. Only specs are imported.
|
|
105
|
+
"""
|
|
106
|
+
metadata_of = {entry["name"]: entry for entry in plugins.discover(plugins.CONNECTORS)}
|
|
107
|
+
blocks, extensions = [], DuckDBExtensions()
|
|
108
|
+
for name in components.available():
|
|
109
|
+
if names is not None and name not in names:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
definition = components.load(name)
|
|
113
|
+
except components.ComponentLoadError as exc:
|
|
114
|
+
blocks.append(("~", name, (("Name", name), ("Error", str(exc))))) # (after the families)
|
|
115
|
+
continue
|
|
116
|
+
blocks.append((definition.family, name, _fields(name, definition, metadata_of.get(name, {}), extensions)))
|
|
117
|
+
lines = []
|
|
118
|
+
for _, _, fields in sorted(blocks, key=lambda block: block[:2]):
|
|
119
|
+
if lines:
|
|
120
|
+
lines.append("---")
|
|
121
|
+
lines.extend(("%s: %s" % (key, value)).rstrip() for key, value in fields)
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_connectors_command(args):
|
|
126
|
+
"""`streamwright connectors`: bare or installed names = installed connectors; ls/i/find use the catalog (hub)."""
|
|
127
|
+
if getattr(args, "flag_list", False):
|
|
128
|
+
action = "ls"
|
|
129
|
+
query = []
|
|
130
|
+
elif getattr(args, "flag_install", None):
|
|
131
|
+
action = "install"
|
|
132
|
+
query = args.flag_install
|
|
133
|
+
elif getattr(args, "flag_search", None):
|
|
134
|
+
action = "find"
|
|
135
|
+
query = args.flag_search
|
|
136
|
+
else:
|
|
137
|
+
action = getattr(args, "action", None)
|
|
138
|
+
query = getattr(args, "query", [])
|
|
139
|
+
|
|
140
|
+
if action is None:
|
|
141
|
+
lines = _connector_lines()
|
|
142
|
+
print("\n".join(lines) if lines else "no connectors installed (use streamwright connectors list to find one)")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
norm_action = action.lower()
|
|
146
|
+
if norm_action in ("ls", "list", "-ls", "--list"):
|
|
147
|
+
lines = catalog.listing(args.hub_url)
|
|
148
|
+
print("\n".join(lines) if lines else "the catalog is empty")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
if norm_action in ("i", "install", "-i", "--install"):
|
|
152
|
+
if not query:
|
|
153
|
+
sys.stderr.write("streamwright: 'connectors -i' needs at least one connector KEY\n")
|
|
154
|
+
return 2
|
|
155
|
+
return catalog.install_many(query, assume_yes=args.yes, hub_url=args.hub_url)
|
|
156
|
+
|
|
157
|
+
words = [action, *query]
|
|
158
|
+
if norm_action in ("find", "search", "s", "-s", "-f", "--find", "--search"):
|
|
159
|
+
words = query
|
|
160
|
+
elif set(words) <= set(components.available()):
|
|
161
|
+
# installed connector names show those connectors, as `pip show NAME` does (`sw connectors google_ads s3`)
|
|
162
|
+
print("\n".join(_connector_lines(set(words))))
|
|
163
|
+
return 0
|
|
164
|
+
return _search(" ".join(words), args.hub_url) # any other words search the catalog (`sw connectors ads`)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _search(text, hub_url):
|
|
168
|
+
hits = catalog.search(text, hub_url)
|
|
169
|
+
if not hits:
|
|
170
|
+
print("no connectors match %r" % text)
|
|
171
|
+
for key, entry in sorted(hits.items()):
|
|
172
|
+
print(" %-16s %-10s %s" % (key, entry.get("trust", "community"), entry.get("summary", "")))
|
|
173
|
+
return 0
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
|
|
5
|
+
from streamwright.core.cli.log_options import add_log_options
|
|
5
6
|
from streamwright.core.jobs.inputs import InputError
|
|
6
7
|
from streamwright.core.jobs.inputs import parse_set as parse_job_set
|
|
7
8
|
|
|
@@ -19,13 +20,28 @@ def parse_set(text):
|
|
|
19
20
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
JOB_OPTION = "job_option"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_job(parser, args):
|
|
27
|
+
"""The job path of JOB or --job JOB (one of them, or both the same); a usage error otherwise."""
|
|
28
|
+
option = getattr(args, JOB_OPTION, None)
|
|
29
|
+
if args.job and option and args.job != option:
|
|
30
|
+
parser.error("pass the job once: JOB or --job JOB")
|
|
31
|
+
args.job = args.job or option
|
|
32
|
+
if not args.job:
|
|
33
|
+
parser.error("a job manifest is required: JOB or --job JOB")
|
|
34
|
+
return args.job
|
|
35
|
+
|
|
36
|
+
|
|
22
37
|
def add_job_arguments(parser):
|
|
23
38
|
"""Add common job manifest and execution options to an argparse parser."""
|
|
24
|
-
parser.add_argument("job", metavar="JOB")
|
|
39
|
+
parser.add_argument("job", nargs="?", metavar="JOB", help="the job manifest (a YAML file or its folder)")
|
|
40
|
+
parser.add_argument("--job", "-j", dest=JOB_OPTION, metavar="JOB", help="the job manifest, as an option")
|
|
25
41
|
parser.add_argument("--overlay", action="append", default=[], metavar="FILE")
|
|
26
42
|
parser.add_argument("--config", metavar="FILE")
|
|
27
43
|
parser.add_argument("--set", action="append", default=[], type=parse_set, metavar="NAME=VALUE")
|
|
28
44
|
parser.add_argument("--allow-connector", action="append", default=[], metavar="NAME")
|
|
29
|
-
parser.add_argument("--log-level", metavar="LEVEL")
|
|
30
45
|
parser.add_argument("--strict", action="store_true")
|
|
46
|
+
add_log_options(parser)
|
|
31
47
|
return parser
|
|
@@ -25,6 +25,7 @@ import yaml
|
|
|
25
25
|
from streamwright.core.cli.commands import jobs
|
|
26
26
|
from streamwright.core.cli.commands.connectors import run_connectors_command
|
|
27
27
|
from streamwright.core.cli.commands.source import run
|
|
28
|
+
from streamwright.core.cli.jobs import JOB_OPTION
|
|
28
29
|
from streamwright.core.cli.log_options import log_options, log_setup
|
|
29
30
|
from streamwright.core.cli.parser import build_parser, build_validate_parser, command_parsers
|
|
30
31
|
from streamwright.core.validation import engine
|
|
@@ -43,9 +44,14 @@ def _extract_all_paths(args, command="run"):
|
|
|
43
44
|
index = 0
|
|
44
45
|
while index < len(args):
|
|
45
46
|
item = args[index]
|
|
46
|
-
option, equals,
|
|
47
|
+
option, equals, value = item.partition("=")
|
|
47
48
|
action = options.get(option)
|
|
48
49
|
if action is not None:
|
|
50
|
+
if action.dest == JOB_OPTION: # --job JOB: a manifest path too
|
|
51
|
+
if not equals and index + 1 < len(args):
|
|
52
|
+
value = args[index + 1]
|
|
53
|
+
if value:
|
|
54
|
+
paths.append(value)
|
|
49
55
|
if not equals and action.nargs != 0:
|
|
50
56
|
index += 1
|
|
51
57
|
if action.nargs in ("+", "*"):
|
|
@@ -118,7 +124,7 @@ def _job_kind(argv):
|
|
|
118
124
|
"""The manifest kind of a job command's path (None: a source, or not a job command); raises ValueError."""
|
|
119
125
|
if argv[0] not in ("run", "validate", "plan"):
|
|
120
126
|
return None
|
|
121
|
-
paths = _extract_all_paths(argv[1:], argv[0])
|
|
127
|
+
paths = list(dict.fromkeys(_extract_all_paths(argv[1:], argv[0]))) # JOB and --job JOB may name the same
|
|
122
128
|
kinds = {detect_kind(path) for path in paths} - {None, "source"}
|
|
123
129
|
if len(kinds) > 1 or (kinds and len(paths) > 1):
|
|
124
130
|
raise ValueError("a job command requires one manifest kind and path")
|
|
@@ -145,6 +151,18 @@ def main(argv=None):
|
|
|
145
151
|
return run_connectors_command(args)
|
|
146
152
|
if hasattr(args, "command_handler"):
|
|
147
153
|
return args.command_handler(args)
|
|
154
|
+
return _run_source(args)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_source(args):
|
|
158
|
+
"""`streamwright run SOURCE`: a job manifest (JOB or --job JOB) was routed to its engine before."""
|
|
159
|
+
if getattr(args, JOB_OPTION, None):
|
|
160
|
+
sys.stderr.write("streamwright: --job %s is not a job manifest (kind: DuckDBJob, SparkJob); run a source "
|
|
161
|
+
"as: streamwright run SOURCE\n" % getattr(args, JOB_OPTION))
|
|
162
|
+
return 2
|
|
163
|
+
if not args.source:
|
|
164
|
+
sys.stderr.write("streamwright: run needs a SOURCE, or --job JOB\n")
|
|
165
|
+
return 2
|
|
148
166
|
setup = log_setup(args)
|
|
149
167
|
if setup is None:
|
|
150
168
|
return 2
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import argparse
|
|
21
21
|
|
|
22
22
|
from streamwright.core.cli.commands import jobs
|
|
23
|
-
from streamwright.core.cli.jobs import add_job_arguments
|
|
23
|
+
from streamwright.core.cli.jobs import JOB_OPTION, add_job_arguments
|
|
24
24
|
from streamwright.core.cli.log_options import add_log_options, key_value
|
|
25
25
|
from streamwright.core.config.inputs import SECRET_ENV_PREFIX
|
|
26
26
|
from streamwright.core.runtime import plugins
|
|
@@ -43,8 +43,13 @@ def build_parser():
|
|
|
43
43
|
run = commands.add_parser("run", help="run a source and write its records",
|
|
44
44
|
description="Run a source. Records go to stdout as Singer messages unless --output "
|
|
45
45
|
"names a folder for files or a warehouse; logs go to stderr.")
|
|
46
|
-
run.add_argument("source", metavar="SOURCE",
|
|
47
|
-
help="a source folder (source.yaml and one file per stream in streams/) or a source file"
|
|
46
|
+
run.add_argument("source", nargs="?", metavar="SOURCE",
|
|
47
|
+
help="a source folder (source.yaml and one file per stream in streams/) or a source file; or a "
|
|
48
|
+
"job manifest (kind: DuckDBJob, SparkJob), which runs on its engine")
|
|
49
|
+
run.add_argument("--job", "-j", dest=JOB_OPTION, metavar="JOB",
|
|
50
|
+
help="a job manifest (kind: DuckDBJob, SparkJob) to run on its engine, e.g. "
|
|
51
|
+
"sw run --job jobs/campaigns.yaml (the same as passing it as the path); the job engine's "
|
|
52
|
+
"options apply: sw duckdb run --help, sw spark run --help")
|
|
48
53
|
run.add_argument("--config", metavar="FILE",
|
|
49
54
|
help="YAML/JSON file with `config` (values for spec.config) and `streams` (streams/exports to "
|
|
50
55
|
"run), e.g. one client's settings for the source")
|
|
@@ -85,8 +90,8 @@ def build_parser():
|
|
|
85
90
|
add_log_options(run)
|
|
86
91
|
|
|
87
92
|
conn = commands.add_parser("connectors",
|
|
88
|
-
help="list, find or install connectors (bare
|
|
89
|
-
"
|
|
93
|
+
help="show, list, find or install connectors (bare or NAME ...: the installed "
|
|
94
|
+
"connectors, their libraries, DuckDB extensions and loggers)")
|
|
90
95
|
conn.add_argument("-ls", "--list", action="store_true", dest="flag_list",
|
|
91
96
|
help="list catalog and installed connectors")
|
|
92
97
|
conn.add_argument("-i", "--install", nargs="+", dest="flag_install", metavar="KEY",
|
|
@@ -94,9 +99,10 @@ def build_parser():
|
|
|
94
99
|
conn.add_argument("-s", "-f", "--search", "--find", nargs="+", dest="flag_search", metavar="TEXT",
|
|
95
100
|
help="find/search connectors in the catalog by keyword")
|
|
96
101
|
conn.add_argument("action", nargs="?", metavar="{ls,i,find}",
|
|
97
|
-
help="ls (catalog + installed), -i KEY (install),
|
|
102
|
+
help="ls (catalog + installed), -i KEY (install), find TEXT (search catalog), or installed "
|
|
103
|
+
"connector names (show them, like pip show)")
|
|
98
104
|
conn.add_argument("query", nargs="*", metavar="KEY|TEXT",
|
|
99
|
-
help="connector keys (install: one or more) or search words")
|
|
105
|
+
help="connector keys (install, show: one or more) or search words")
|
|
100
106
|
conn.add_argument("--yes", action="store_true", help="skip the confirmation prompt when installing")
|
|
101
107
|
conn.add_argument("--hub-url", metavar="URL",
|
|
102
108
|
help="the connector hub (default: $STREAMWRIGHT_HUB_URL, else the live StreamWright hub; when "
|
|
@@ -22,7 +22,8 @@ The DuckDBJob command line (registered for `kind: DuckDBJob` in the `streamwrigh
|
|
|
22
22
|
plan JOB
|
|
23
23
|
run JOB [--secrets FILE] [--dry-run]
|
|
24
24
|
state list|retry|abandon JOB [--batch ID] [--secrets FILE]
|
|
25
|
-
(every command: --config FILE, --set NAME=VALUE, --overlay FILE, --allow-connector NAME, --strict,
|
|
25
|
+
(every command: --config FILE, --set NAME=VALUE, --overlay FILE, --allow-connector NAME, --strict, and the logging
|
|
26
|
+
options of `streamwright run`: --log-level, --log NAME=LEVEL, --log-format, --log-config, --log-max-chars)
|
|
26
27
|
|
|
27
28
|
The job options, exit codes and logging are the shared ones of `streamwright.core.cli.jobs`: 0 success, 1 invalid
|
|
28
29
|
job or failed run, 2 usage or input error. Validation and planning never need secret values.
|
|
@@ -31,14 +32,14 @@ job or failed run, 2 usage or input error. Validation and planning never need se
|
|
|
31
32
|
import argparse
|
|
32
33
|
import sys
|
|
33
34
|
|
|
34
|
-
from streamwright.core.cli.jobs import FAILURE, INVALID_INPUT, SUCCESS, add_job_arguments
|
|
35
|
+
from streamwright.core.cli.jobs import FAILURE, INVALID_INPUT, SUCCESS, add_job_arguments, resolve_job
|
|
36
|
+
from streamwright.core.cli.log_options import log_setup
|
|
35
37
|
from streamwright.core.config.inputs import InputError, read_values_file
|
|
36
38
|
from streamwright.core.jobs.duckdb.engine import JobInvalidError, job_state, redactor, run_job, validate_job
|
|
37
39
|
from streamwright.core.jobs.inputs import read_config_values
|
|
38
40
|
from streamwright.core.jobs.manifest import JobError
|
|
39
41
|
from streamwright.core.jobs.runner import owner_id
|
|
40
42
|
from streamwright.core.jobs.state import StateError
|
|
41
|
-
from streamwright.core.runtime.logs import LogSetup
|
|
42
43
|
|
|
43
44
|
__all__ = ["main"]
|
|
44
45
|
|
|
@@ -100,8 +101,16 @@ def _cmd_run(args):
|
|
|
100
101
|
for name, rows in sorted(result.outputs.items()):
|
|
101
102
|
if name in result.skipped:
|
|
102
103
|
sys.stdout.write(" %-28s already committed in an earlier attempt\n" % name)
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
continue
|
|
105
|
+
locations = [location for location in result.locations.get(name, ()) if location]
|
|
106
|
+
written = "%s rows" % ("?" if rows is None else rows)
|
|
107
|
+
if len(locations) == 1:
|
|
108
|
+
written += " -> %s" % locations[0]
|
|
109
|
+
elif locations:
|
|
110
|
+
written += " -> %d files" % len(locations)
|
|
111
|
+
sys.stdout.write(" %-28s %s\n" % (name, written))
|
|
112
|
+
if len(locations) > 1:
|
|
113
|
+
sys.stdout.writelines(" %s\n" % location for location in locations)
|
|
105
114
|
if result.exit_code:
|
|
106
115
|
_err("Run failed: %s" % (result.error or "see the log"))
|
|
107
116
|
return result.exit_code
|
|
@@ -146,25 +155,26 @@ def _inputs(parser, secrets=False):
|
|
|
146
155
|
|
|
147
156
|
|
|
148
157
|
def _build_parser(prog):
|
|
149
|
-
|
|
158
|
+
# no prefix matching: --log must not be taken for --log-level
|
|
159
|
+
parser = argparse.ArgumentParser(prog=prog, description="StreamWright DuckDBJob engine.", allow_abbrev=False)
|
|
150
160
|
sub = parser.add_subparsers(dest="subcommand")
|
|
151
|
-
validate = sub.add_parser("validate", help="check a DuckDBJob manifest (no secrets needed)")
|
|
161
|
+
validate = sub.add_parser("validate", allow_abbrev=False, help="check a DuckDBJob manifest (no secrets needed)")
|
|
152
162
|
validate.add_argument("--graph", action="store_true", help="print the plan of a valid job")
|
|
153
163
|
_inputs(validate)
|
|
154
|
-
validate.set_defaults(func=_cmd_validate)
|
|
155
|
-
plan = sub.add_parser("plan", help="print the execution plan (no secrets needed)")
|
|
164
|
+
validate.set_defaults(func=_cmd_validate, parser=validate)
|
|
165
|
+
plan = sub.add_parser("plan", allow_abbrev=False, help="print the execution plan (no secrets needed)")
|
|
156
166
|
_inputs(plan)
|
|
157
|
-
plan.set_defaults(func=_cmd_plan)
|
|
158
|
-
run = sub.add_parser("run", help="run a DuckDBJob (unfinished batches resume first)")
|
|
167
|
+
plan.set_defaults(func=_cmd_plan, parser=plan)
|
|
168
|
+
run = sub.add_parser("run", allow_abbrev=False, help="run a DuckDBJob (unfinished batches resume first)")
|
|
159
169
|
run.add_argument("--dry-run", action="store_true", help="check and plan, write nothing")
|
|
160
170
|
_inputs(run, secrets=True)
|
|
161
|
-
run.set_defaults(func=_cmd_run)
|
|
162
|
-
state = sub.add_parser("state", help="list, retry or abandon a job's batches (runtime.state)")
|
|
171
|
+
run.set_defaults(func=_cmd_run, parser=run)
|
|
172
|
+
state = sub.add_parser("state", allow_abbrev=False, help="list, retry or abandon a job's batches (runtime.state)")
|
|
163
173
|
state.add_argument("action", choices=["list", "retry", "abandon"])
|
|
164
174
|
state.add_argument("--batch", help="the batch id (retry, abandon)")
|
|
165
175
|
state.add_argument("--limit", type=int, default=20, help="batches to list")
|
|
166
176
|
_inputs(state, secrets=True)
|
|
167
|
-
state.set_defaults(func=_cmd_state)
|
|
177
|
+
state.set_defaults(func=_cmd_state, parser=state)
|
|
168
178
|
return parser
|
|
169
179
|
|
|
170
180
|
|
|
@@ -174,10 +184,9 @@ def main(argv=None, prog="streamwright"):
|
|
|
174
184
|
if not getattr(args, "func", None):
|
|
175
185
|
parser.print_help()
|
|
176
186
|
return INVALID_INPUT
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
_err("Error: %s" % exc)
|
|
187
|
+
resolve_job(args.parser, args)
|
|
188
|
+
args.log_setup = log_setup(args)
|
|
189
|
+
if args.log_setup is None:
|
|
181
190
|
return INVALID_INPUT
|
|
182
191
|
try:
|
|
183
192
|
return args.func(args)
|
|
@@ -42,6 +42,7 @@ from streamwright.core.jobs.refs import resolve
|
|
|
42
42
|
from streamwright.core.jobs.runner import Runner
|
|
43
43
|
from streamwright.core.jobs.schema import json_schema as generate_schema
|
|
44
44
|
from streamwright.core.jobs.state import DEFAULT_SCHEMA, open_sqlite, open_store
|
|
45
|
+
from streamwright.core.jobs.types import written_files
|
|
45
46
|
from streamwright.core.runtime.redact import Redactor
|
|
46
47
|
|
|
47
48
|
__all__ = [
|
|
@@ -78,6 +79,8 @@ class RunResult:
|
|
|
78
79
|
outputs: dict[str, int | None] = field(default_factory=dict) # output -> rows, from the receipts
|
|
79
80
|
skipped: set[str] = field(default_factory=set) # outputs already committed in an earlier attempt
|
|
80
81
|
error: str | None = None
|
|
82
|
+
# output -> where it wrote: its files (a file_name template's, split_by's), else its destination
|
|
83
|
+
locations: dict[str, list[str]] = field(default_factory=dict)
|
|
81
84
|
|
|
82
85
|
|
|
83
86
|
def _providers(job):
|
|
@@ -236,14 +239,16 @@ def _run(raw, *, config, secret_map, allowed_connectors, dry_run, base_dir, reda
|
|
|
236
239
|
engine_metadata={"engine": "duckdb"})
|
|
237
240
|
session.start_clock()
|
|
238
241
|
code = runner.run(mode="once", run_key=state.get("run_key"), max_messages=0 if resume_only else 500)
|
|
239
|
-
outputs = {}
|
|
242
|
+
outputs, locations = {}, {}
|
|
240
243
|
skipped = set()
|
|
241
244
|
for batch_id in runner.processed:
|
|
242
245
|
for name, receipt in store.get_receipts(job["name"], batch_id).items():
|
|
243
246
|
outputs[name] = receipt.get("rows")
|
|
247
|
+
locations[name] = written_files(receipt) or [receipt.get("destination")]
|
|
244
248
|
if receipt.get("already_committed"):
|
|
245
249
|
skipped.add(name)
|
|
246
|
-
return RunResult(code, job["name"], plan.describe(), list(runner.processed), outputs, skipped, runner.error
|
|
250
|
+
return RunResult(code, job["name"], plan.describe(), list(runner.processed), outputs, skipped, runner.error,
|
|
251
|
+
locations)
|
|
247
252
|
finally:
|
|
248
253
|
session.close()
|
|
249
254
|
|
|
@@ -26,8 +26,9 @@ from streamwright.core.jobs.duckdb.adapter import ReadContext, WriteContext
|
|
|
26
26
|
from streamwright.core.jobs.duckdb.steps import build_step
|
|
27
27
|
from streamwright.core.jobs.policy import SKIPPED, ExecutionPolicy, classify_transient, guard
|
|
28
28
|
from streamwright.core.jobs.refs import RefError, decode_value, encode_value
|
|
29
|
-
from streamwright.core.jobs.types import ExecutionResult, WriteReceipt
|
|
29
|
+
from streamwright.core.jobs.types import ExecutionResult, WriteReceipt, written_files
|
|
30
30
|
from streamwright.core.jobs.write_rules import OutputSpec, check_output
|
|
31
|
+
from streamwright.core.runtime.logs.lines import fields
|
|
31
32
|
from streamwright.core.sql.quote import quote_ident
|
|
32
33
|
|
|
33
34
|
__all__ = ["DuckDBExecutor", "ExecutionError"]
|
|
@@ -131,7 +132,11 @@ class DuckDBExecutor:
|
|
|
131
132
|
if receipt.already_committed:
|
|
132
133
|
LOG.info("output %s: already committed in an earlier attempt", node.name)
|
|
133
134
|
else:
|
|
134
|
-
LOG.info("output %s: %s rows to %s", node.name, receipt.rows, receipt.destination
|
|
135
|
+
LOG.info("output %s: %s rows to %s", node.name, receipt.rows, receipt.destination,
|
|
136
|
+
extra=fields(event="output_end", export=node.name, records=receipt.rows))
|
|
137
|
+
for path in written_files(receipt):
|
|
138
|
+
LOG.info("output %s: wrote %s", node.name, path,
|
|
139
|
+
extra=fields(event="output_file", export=node.name, path=path))
|
|
135
140
|
return ExecutionResult(schema_signatures=tuple(run.schema_signatures),
|
|
136
141
|
schema_reports=tuple(run.schema_reports))
|
|
137
142
|
|
|
@@ -25,12 +25,14 @@ secret values. Exit codes: 0 success, 1 invalid job or failed run, 2 usage or in
|
|
|
25
25
|
`row_index`, `columns`, `column_groups`, `path_pattern`, `vocabulary`, `on_unmapped_column`; databases:
|
|
26
26
|
`table` or `query`). `path` may be `{{ step.column }}`. Planned reads pin their file list in the batch.
|
|
27
27
|
- call: a connection and a connector call (`service`/`method`/`arguments`, or `request`), optional
|
|
28
|
-
`partitions: [{from, field, name}]` (crossed) and `sql` over the table `records` (column `record`)
|
|
28
|
+
`partitions: [{from, field, name}]` (crossed) and `sql` over the table `records` (column `record`); see Call
|
|
29
|
+
steps.
|
|
29
30
|
- `source` + `stream`: a StreamWright source folder (relative to the job's folder) and one of its streams.
|
|
30
31
|
- `outputs`: `connection`, `step` or `sql`, and a required `mode`. What else is allowed depends on the writer:
|
|
31
32
|
tables (`append | overwrite | merge`, `table`, `primary_key`), files (`overwrite | error`, `path`, `format`:
|
|
32
33
|
`csv | tsv | json | jsonl | parquet`; `path` is the file, or with a `file_name` template the folder: `file_name`
|
|
33
|
-
(and `layout`, its folders, e.g. `{today:%Y/%m}`) names one file, or with `split_by` one file per split
|
|
34
|
+
(and `layout`, its folders, e.g. `{today:%Y/%m}`) names one file, or with `split_by` one file per split (see File
|
|
35
|
+
names); or
|
|
34
36
|
`partition_by` for Hive folders), Delta
|
|
35
37
|
(`append | overwrite | merge` with `replace_where` or `whole_table`, `condition`, `when_*`, `target_predicate`,
|
|
36
38
|
`schema_evolution`). Modes are the same words on every engine. A key the writer does not read is an error;
|
|
@@ -39,6 +41,75 @@ secret values. Exit codes: 0 success, 1 invalid job or failed run, 2 usage or in
|
|
|
39
41
|
`read_groups`) and `state`: `{connection: NAME, schema: streamwright}` (a `duckdb` or `postgres` connection) or
|
|
40
42
|
`{sqlite: PATH}`, plus `ttl_seconds` and `run_key` (a batch with that key runs once).
|
|
41
43
|
|
|
44
|
+
## Call steps
|
|
45
|
+
A call step runs a connector call on the driver, once per partition, and loads the records into a temp table in
|
|
46
|
+
batches, as JSON. A `source` + `stream` step loads its records the same way.
|
|
47
|
+
|
|
48
|
+
- **The `records` table.** With `sql`, the step is that query over the table `records`, which has **one column,
|
|
49
|
+
`record` (JSON)**: one row per record, as the connector returned it. Without `sql`, the records' top-level keys
|
|
50
|
+
become the step's columns. `records` exists only inside the step's own `sql`.
|
|
51
|
+
- **Partitions.** `partitions: [{from, field, name}]` calls once per distinct, non-null value of `field` in the
|
|
52
|
+
earlier step `from` (several partitions are crossed); the call's arguments read the value as
|
|
53
|
+
`{{ partition.NAME }}` (`name` defaults to `field`). The values are pinned in the batch, so a replay makes the same
|
|
54
|
+
calls. **Partition values are not columns of `records`**: to keep one, request it in the call (e.g. select
|
|
55
|
+
`campaign.id` in the query) and extract it from `record`.
|
|
56
|
+
- **Extract scalars with `->>`.** `record->>'$.campaign.id'` returns VARCHAR; cast it for other types
|
|
57
|
+
(`(record->>'$.metrics.clicks')::BIGINT`). Dot notation (`record.campaign.id`) and `->` keep the value as JSON,
|
|
58
|
+
so a CSV or TSV output writes it as a quoted JSON string (`"""18497872503"""`), while Parquet hides the issue.
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
- name: ad_groups
|
|
62
|
+
connection: google_ads_api
|
|
63
|
+
service: GoogleAdsService
|
|
64
|
+
method: search_stream
|
|
65
|
+
partitions: [{from: campaigns, field: campaign_id}]
|
|
66
|
+
arguments:
|
|
67
|
+
customer_id: "{{ config.customer_id }}"
|
|
68
|
+
query: {gaql: {select: [campaign.id, ad_group.id, ad_group.name], from: ad_group,
|
|
69
|
+
where: [{field: campaign.id, op: "=", type: int, value: "{{ partition.campaign_id }}"}]}}
|
|
70
|
+
sql: |
|
|
71
|
+
SELECT record->>'$.campaign.id' AS campaign_id,
|
|
72
|
+
record->>'$.ad_group.id' AS ad_group_id,
|
|
73
|
+
record->>'$.ad_group.name' AS ad_group_name
|
|
74
|
+
FROM records
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## File names
|
|
78
|
+
A file output's `path` is the file; with `file_name` it is the folder, and `file_name` names the file inside it
|
|
79
|
+
(`layout` adds subfolders). With `split_by: [col_a, col_b]` the output writes one file per distinct combination,
|
|
80
|
+
and both templates may use `{col_a}` and `{col_b}`; without `split_by` they name the one file.
|
|
81
|
+
|
|
82
|
+
| Placeholder | Renders |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `{today}`, `{today:%Y-%m-%d}` | the batch's UTC date (default format `%Y%m%d`) |
|
|
85
|
+
| `{now}`, `{batch_ts}`, `{now:%Y%m%d%H%M%S}` | the batch's UTC time (default format `%Y%m%dT%H%M%SZ`) |
|
|
86
|
+
| `{today-1d:%Y%m%d}`, `{now+2h:%H}` | a time with offsets: `+`/`-` N then `s m h d w`, combinable (`-1d+6h`) |
|
|
87
|
+
| `{batch_id}` | the batch id (no offset or format) |
|
|
88
|
+
| `{seq}` | the split's 5-digit number, `00001`, splits ordered by their values (no offset or format) |
|
|
89
|
+
| `{col}`, `{col:%Y%m}` | a `split_by` column's value, reduced to `[A-Za-z0-9._-]`; with a format (or offset), a date, timestamp or ISO text column |
|
|
90
|
+
|
|
91
|
+
- Formats are strftime directives, rendered in UTC and English: `Y y m d e H M S I j u w U G V a A b B p Z z s F %`;
|
|
92
|
+
`%-d` drops zero padding. `%T` is not supported (colons are unsafe in names): use `%H%M%S`.
|
|
93
|
+
- `today`, `now` and `batch_ts` are the batch's time, pinned when the batch starts, so a replay renders the same
|
|
94
|
+
names and overwrites the same files.
|
|
95
|
+
- `file_name` may not contain `/`; `layout` may (`{today:%Y/%m}`, `client={client_id}`). Literal text may use letters,
|
|
96
|
+
digits and `. _ - = , @ +`; `..` is never allowed.
|
|
97
|
+
- `file_name` needs at least one placeholder; for a fixed name, set the file as `path` and leave `file_name` out.
|
|
98
|
+
With `split_by`, two splits that render the same name fail the run: use a split column or `{seq}`.
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
outputs:
|
|
102
|
+
- name: daily_export
|
|
103
|
+
step: campaign_stats
|
|
104
|
+
connection: exports
|
|
105
|
+
mode: overwrite
|
|
106
|
+
path: reports
|
|
107
|
+
format: tsv
|
|
108
|
+
split_by: [customer_id]
|
|
109
|
+
layout: "{today:%Y/%m}"
|
|
110
|
+
file_name: "campaigns_{customer_id}_{today-1d:%Y%m%d}.tsv"
|
|
111
|
+
```
|
|
112
|
+
|
|
42
113
|
## Delivery
|
|
43
114
|
Every run takes the job's lease (renewed while it runs; every output write is fenced by it), resumes unfinished
|
|
44
115
|
batches first and skips outputs that already have a receipt. Delta writes commit an application transaction, so a
|
|
@@ -34,6 +34,12 @@ __all__ = ["KIND", "DuckDBRules"]
|
|
|
34
34
|
|
|
35
35
|
KIND = "DuckDBJob"
|
|
36
36
|
_TEXT = Field("string")
|
|
37
|
+
FILE_NAME_HELP = (
|
|
38
|
+
"file outputs: the file's name inside `path` (then a folder). Placeholders: {today} and {now}/{batch_ts}, the "
|
|
39
|
+
"batch's UTC date and time, with offsets and strftime formats ({today-1d:%Y%m%d}, {now+2h:%Y%m%d%H%M%S}; "
|
|
40
|
+
"offsets +/-N with s m h d w); {batch_id}; {seq}, the split's 5-digit number; and the split_by columns ({col}). "
|
|
41
|
+
"No '/', and at least one placeholder"
|
|
42
|
+
)
|
|
37
43
|
# the execution-policy vocabulary (contract v1.2 §6a), accepted structurally on steps and outputs
|
|
38
44
|
_POLICY_FIELDS = MappingProxyType({
|
|
39
45
|
"retry": Field("object"), "rate_limit": Field("object"), "paginator": Field("object"),
|
|
@@ -51,7 +57,9 @@ _COLUMN_GROUP = Field("object", fields={
|
|
|
51
57
|
"keep_nulls": Field("boolean")})
|
|
52
58
|
_STEP_FIELDS = MappingProxyType({
|
|
53
59
|
"name": Field("string", required=True, pattern=NAME_PATTERN), "description": _TEXT,
|
|
54
|
-
"sql": Field("string", description="DuckDB SQL over earlier steps
|
|
60
|
+
"sql": Field("string", description="DuckDB SQL over earlier steps; in a call or source step, over the table "
|
|
61
|
+
"`records`, whose one column `record` (JSON) holds each record: extract "
|
|
62
|
+
"scalars with ->> (record->>'$.campaign.id'), which returns VARCHAR, not JSON"),
|
|
55
63
|
"materialize": Field("string", enum=("view", "table")),
|
|
56
64
|
"connection": _TEXT,
|
|
57
65
|
"path": Field("string", description="a file, glob or {{ step.column }} (relative to the connection's roots)"),
|
|
@@ -71,7 +79,11 @@ _STEP_FIELDS = MappingProxyType({
|
|
|
71
79
|
"paginator": Field("object"), "retry": Field("object"), "rate_limit": Field("object"),
|
|
72
80
|
"page_size": _POLICY_FIELDS["page_size"], "on_error": _TEXT, "timeout": _POLICY_FIELDS["timeout"],
|
|
73
81
|
"partitions": Field("array", items=_named(**{"from": Field("string", required=True),
|
|
74
|
-
"field": Field("string", required=True), "name": _TEXT})
|
|
82
|
+
"field": Field("string", required=True), "name": _TEXT}),
|
|
83
|
+
description="call steps: one call per distinct value of `field` in the step `from` (crossed "
|
|
84
|
+
"when several), read as {{ partition.NAME }} in the call's arguments (`name` "
|
|
85
|
+
"defaults to `field`). The values are not columns of `records`: request the key "
|
|
86
|
+
"in the call and extract it from `record`"),
|
|
75
87
|
"source": Field("string", description="a StreamWright source folder (relative to the job's folder)"),
|
|
76
88
|
"stream": _TEXT, "config": Field("object"),
|
|
77
89
|
})
|
|
@@ -84,8 +96,14 @@ _OUTPUT_FIELDS = MappingProxyType({
|
|
|
84
96
|
"when_matched": Field("array", items=Field("object")), "when_not_matched": Field("array", items=Field("object")),
|
|
85
97
|
"when_not_matched_by_source": Field("array", items=Field("object")),
|
|
86
98
|
"schema_evolution": Field("string", enum=("strict", "add_columns")),
|
|
87
|
-
"partition_by": Field("array", items=_TEXT
|
|
88
|
-
"
|
|
99
|
+
"partition_by": Field("array", items=_TEXT, description="file outputs: Hive folders (col=value/) under `path`"),
|
|
100
|
+
"split_by": Field("array", items=_TEXT,
|
|
101
|
+
description="file outputs: one file per distinct combination of these columns; file_name and "
|
|
102
|
+
"layout may use {col} (with a format, {col:%Y%m%d}, for a date or timestamp column)"),
|
|
103
|
+
"file_name": Field("string", description=FILE_NAME_HELP),
|
|
104
|
+
"layout": Field("string", description="the subfolders of file_name's file under `path`; the placeholders of "
|
|
105
|
+
"file_name, and '/', e.g. {today:%Y/%m} or client={client_id}"),
|
|
106
|
+
"options": Field("object"),
|
|
89
107
|
**_POLICY_FIELDS,
|
|
90
108
|
})
|
|
91
109
|
_RUNTIME = _named(
|