truthound 1.0.8__py3-none-any.whl
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.
- truthound/__init__.py +162 -0
- truthound/adapters.py +100 -0
- truthound/api.py +365 -0
- truthound/audit/__init__.py +248 -0
- truthound/audit/core.py +967 -0
- truthound/audit/filters.py +620 -0
- truthound/audit/formatters.py +707 -0
- truthound/audit/logger.py +902 -0
- truthound/audit/middleware.py +571 -0
- truthound/audit/storage.py +1083 -0
- truthound/benchmark/__init__.py +123 -0
- truthound/benchmark/base.py +757 -0
- truthound/benchmark/comparison.py +635 -0
- truthound/benchmark/generators.py +706 -0
- truthound/benchmark/reporters.py +718 -0
- truthound/benchmark/runner.py +635 -0
- truthound/benchmark/scenarios.py +712 -0
- truthound/cache.py +252 -0
- truthound/checkpoint/__init__.py +136 -0
- truthound/checkpoint/actions/__init__.py +164 -0
- truthound/checkpoint/actions/base.py +324 -0
- truthound/checkpoint/actions/custom.py +234 -0
- truthound/checkpoint/actions/discord_notify.py +290 -0
- truthound/checkpoint/actions/email_notify.py +405 -0
- truthound/checkpoint/actions/github_action.py +406 -0
- truthound/checkpoint/actions/opsgenie.py +1499 -0
- truthound/checkpoint/actions/pagerduty.py +226 -0
- truthound/checkpoint/actions/slack_notify.py +233 -0
- truthound/checkpoint/actions/store_result.py +249 -0
- truthound/checkpoint/actions/teams_notify.py +1570 -0
- truthound/checkpoint/actions/telegram_notify.py +419 -0
- truthound/checkpoint/actions/update_docs.py +552 -0
- truthound/checkpoint/actions/webhook.py +293 -0
- truthound/checkpoint/analytics/__init__.py +147 -0
- truthound/checkpoint/analytics/aggregations/__init__.py +23 -0
- truthound/checkpoint/analytics/aggregations/rollup.py +481 -0
- truthound/checkpoint/analytics/aggregations/time_bucket.py +306 -0
- truthound/checkpoint/analytics/analyzers/__init__.py +17 -0
- truthound/checkpoint/analytics/analyzers/anomaly.py +386 -0
- truthound/checkpoint/analytics/analyzers/base.py +270 -0
- truthound/checkpoint/analytics/analyzers/forecast.py +421 -0
- truthound/checkpoint/analytics/analyzers/trend.py +314 -0
- truthound/checkpoint/analytics/models.py +292 -0
- truthound/checkpoint/analytics/protocols.py +549 -0
- truthound/checkpoint/analytics/service.py +718 -0
- truthound/checkpoint/analytics/stores/__init__.py +16 -0
- truthound/checkpoint/analytics/stores/base.py +306 -0
- truthound/checkpoint/analytics/stores/memory_store.py +353 -0
- truthound/checkpoint/analytics/stores/sqlite_store.py +557 -0
- truthound/checkpoint/analytics/stores/timescale_store.py +501 -0
- truthound/checkpoint/async_actions.py +794 -0
- truthound/checkpoint/async_base.py +708 -0
- truthound/checkpoint/async_checkpoint.py +617 -0
- truthound/checkpoint/async_runner.py +639 -0
- truthound/checkpoint/checkpoint.py +527 -0
- truthound/checkpoint/ci/__init__.py +61 -0
- truthound/checkpoint/ci/detector.py +355 -0
- truthound/checkpoint/ci/reporter.py +436 -0
- truthound/checkpoint/ci/templates.py +454 -0
- truthound/checkpoint/circuitbreaker/__init__.py +133 -0
- truthound/checkpoint/circuitbreaker/breaker.py +542 -0
- truthound/checkpoint/circuitbreaker/core.py +252 -0
- truthound/checkpoint/circuitbreaker/detection.py +459 -0
- truthound/checkpoint/circuitbreaker/middleware.py +389 -0
- truthound/checkpoint/circuitbreaker/registry.py +357 -0
- truthound/checkpoint/distributed/__init__.py +139 -0
- truthound/checkpoint/distributed/backends/__init__.py +35 -0
- truthound/checkpoint/distributed/backends/celery_backend.py +503 -0
- truthound/checkpoint/distributed/backends/kubernetes_backend.py +696 -0
- truthound/checkpoint/distributed/backends/local_backend.py +397 -0
- truthound/checkpoint/distributed/backends/ray_backend.py +625 -0
- truthound/checkpoint/distributed/base.py +774 -0
- truthound/checkpoint/distributed/orchestrator.py +765 -0
- truthound/checkpoint/distributed/protocols.py +842 -0
- truthound/checkpoint/distributed/registry.py +449 -0
- truthound/checkpoint/idempotency/__init__.py +120 -0
- truthound/checkpoint/idempotency/core.py +295 -0
- truthound/checkpoint/idempotency/fingerprint.py +454 -0
- truthound/checkpoint/idempotency/locking.py +604 -0
- truthound/checkpoint/idempotency/service.py +592 -0
- truthound/checkpoint/idempotency/stores.py +653 -0
- truthound/checkpoint/monitoring/__init__.py +134 -0
- truthound/checkpoint/monitoring/aggregators/__init__.py +15 -0
- truthound/checkpoint/monitoring/aggregators/base.py +372 -0
- truthound/checkpoint/monitoring/aggregators/realtime.py +300 -0
- truthound/checkpoint/monitoring/aggregators/window.py +493 -0
- truthound/checkpoint/monitoring/collectors/__init__.py +17 -0
- truthound/checkpoint/monitoring/collectors/base.py +257 -0
- truthound/checkpoint/monitoring/collectors/memory_collector.py +617 -0
- truthound/checkpoint/monitoring/collectors/prometheus_collector.py +451 -0
- truthound/checkpoint/monitoring/collectors/redis_collector.py +518 -0
- truthound/checkpoint/monitoring/events.py +410 -0
- truthound/checkpoint/monitoring/protocols.py +636 -0
- truthound/checkpoint/monitoring/service.py +578 -0
- truthound/checkpoint/monitoring/views/__init__.py +17 -0
- truthound/checkpoint/monitoring/views/base.py +172 -0
- truthound/checkpoint/monitoring/views/queue_view.py +220 -0
- truthound/checkpoint/monitoring/views/task_view.py +240 -0
- truthound/checkpoint/monitoring/views/worker_view.py +263 -0
- truthound/checkpoint/registry.py +337 -0
- truthound/checkpoint/runner.py +356 -0
- truthound/checkpoint/transaction/__init__.py +133 -0
- truthound/checkpoint/transaction/base.py +389 -0
- truthound/checkpoint/transaction/compensatable.py +537 -0
- truthound/checkpoint/transaction/coordinator.py +576 -0
- truthound/checkpoint/transaction/executor.py +622 -0
- truthound/checkpoint/transaction/idempotency.py +534 -0
- truthound/checkpoint/transaction/saga/__init__.py +143 -0
- truthound/checkpoint/transaction/saga/builder.py +584 -0
- truthound/checkpoint/transaction/saga/definition.py +515 -0
- truthound/checkpoint/transaction/saga/event_store.py +542 -0
- truthound/checkpoint/transaction/saga/patterns.py +833 -0
- truthound/checkpoint/transaction/saga/runner.py +718 -0
- truthound/checkpoint/transaction/saga/state_machine.py +793 -0
- truthound/checkpoint/transaction/saga/strategies.py +780 -0
- truthound/checkpoint/transaction/saga/testing.py +886 -0
- truthound/checkpoint/triggers/__init__.py +58 -0
- truthound/checkpoint/triggers/base.py +237 -0
- truthound/checkpoint/triggers/event.py +385 -0
- truthound/checkpoint/triggers/schedule.py +355 -0
- truthound/cli.py +2358 -0
- truthound/cli_modules/__init__.py +124 -0
- truthound/cli_modules/advanced/__init__.py +45 -0
- truthound/cli_modules/advanced/benchmark.py +343 -0
- truthound/cli_modules/advanced/docs.py +225 -0
- truthound/cli_modules/advanced/lineage.py +209 -0
- truthound/cli_modules/advanced/ml.py +320 -0
- truthound/cli_modules/advanced/realtime.py +196 -0
- truthound/cli_modules/checkpoint/__init__.py +46 -0
- truthound/cli_modules/checkpoint/init.py +114 -0
- truthound/cli_modules/checkpoint/list.py +71 -0
- truthound/cli_modules/checkpoint/run.py +159 -0
- truthound/cli_modules/checkpoint/validate.py +67 -0
- truthound/cli_modules/common/__init__.py +71 -0
- truthound/cli_modules/common/errors.py +414 -0
- truthound/cli_modules/common/options.py +419 -0
- truthound/cli_modules/common/output.py +507 -0
- truthound/cli_modules/common/protocol.py +552 -0
- truthound/cli_modules/core/__init__.py +48 -0
- truthound/cli_modules/core/check.py +123 -0
- truthound/cli_modules/core/compare.py +104 -0
- truthound/cli_modules/core/learn.py +57 -0
- truthound/cli_modules/core/mask.py +77 -0
- truthound/cli_modules/core/profile.py +65 -0
- truthound/cli_modules/core/scan.py +61 -0
- truthound/cli_modules/profiler/__init__.py +51 -0
- truthound/cli_modules/profiler/auto_profile.py +175 -0
- truthound/cli_modules/profiler/metadata.py +107 -0
- truthound/cli_modules/profiler/suite.py +283 -0
- truthound/cli_modules/registry.py +431 -0
- truthound/cli_modules/scaffolding/__init__.py +89 -0
- truthound/cli_modules/scaffolding/base.py +631 -0
- truthound/cli_modules/scaffolding/commands.py +545 -0
- truthound/cli_modules/scaffolding/plugins.py +1072 -0
- truthound/cli_modules/scaffolding/reporters.py +594 -0
- truthound/cli_modules/scaffolding/validators.py +1127 -0
- truthound/common/__init__.py +18 -0
- truthound/common/resilience/__init__.py +130 -0
- truthound/common/resilience/bulkhead.py +266 -0
- truthound/common/resilience/circuit_breaker.py +516 -0
- truthound/common/resilience/composite.py +332 -0
- truthound/common/resilience/config.py +292 -0
- truthound/common/resilience/protocols.py +217 -0
- truthound/common/resilience/rate_limiter.py +404 -0
- truthound/common/resilience/retry.py +341 -0
- truthound/datadocs/__init__.py +260 -0
- truthound/datadocs/base.py +571 -0
- truthound/datadocs/builder.py +761 -0
- truthound/datadocs/charts.py +764 -0
- truthound/datadocs/dashboard/__init__.py +63 -0
- truthound/datadocs/dashboard/app.py +576 -0
- truthound/datadocs/dashboard/components.py +584 -0
- truthound/datadocs/dashboard/state.py +240 -0
- truthound/datadocs/engine/__init__.py +46 -0
- truthound/datadocs/engine/context.py +376 -0
- truthound/datadocs/engine/pipeline.py +618 -0
- truthound/datadocs/engine/registry.py +469 -0
- truthound/datadocs/exporters/__init__.py +49 -0
- truthound/datadocs/exporters/base.py +198 -0
- truthound/datadocs/exporters/html.py +178 -0
- truthound/datadocs/exporters/json_exporter.py +253 -0
- truthound/datadocs/exporters/markdown.py +284 -0
- truthound/datadocs/exporters/pdf.py +392 -0
- truthound/datadocs/i18n/__init__.py +86 -0
- truthound/datadocs/i18n/catalog.py +960 -0
- truthound/datadocs/i18n/formatting.py +505 -0
- truthound/datadocs/i18n/loader.py +256 -0
- truthound/datadocs/i18n/plurals.py +378 -0
- truthound/datadocs/renderers/__init__.py +42 -0
- truthound/datadocs/renderers/base.py +401 -0
- truthound/datadocs/renderers/custom.py +342 -0
- truthound/datadocs/renderers/jinja.py +697 -0
- truthound/datadocs/sections.py +736 -0
- truthound/datadocs/styles.py +931 -0
- truthound/datadocs/themes/__init__.py +101 -0
- truthound/datadocs/themes/base.py +336 -0
- truthound/datadocs/themes/default.py +417 -0
- truthound/datadocs/themes/enterprise.py +419 -0
- truthound/datadocs/themes/loader.py +336 -0
- truthound/datadocs/themes.py +301 -0
- truthound/datadocs/transformers/__init__.py +57 -0
- truthound/datadocs/transformers/base.py +268 -0
- truthound/datadocs/transformers/enrichers.py +544 -0
- truthound/datadocs/transformers/filters.py +447 -0
- truthound/datadocs/transformers/i18n.py +468 -0
- truthound/datadocs/versioning/__init__.py +62 -0
- truthound/datadocs/versioning/diff.py +639 -0
- truthound/datadocs/versioning/storage.py +497 -0
- truthound/datadocs/versioning/version.py +358 -0
- truthound/datasources/__init__.py +223 -0
- truthound/datasources/_async_protocols.py +222 -0
- truthound/datasources/_protocols.py +159 -0
- truthound/datasources/adapters.py +428 -0
- truthound/datasources/async_base.py +599 -0
- truthound/datasources/async_factory.py +511 -0
- truthound/datasources/base.py +516 -0
- truthound/datasources/factory.py +433 -0
- truthound/datasources/nosql/__init__.py +47 -0
- truthound/datasources/nosql/base.py +487 -0
- truthound/datasources/nosql/elasticsearch.py +801 -0
- truthound/datasources/nosql/mongodb.py +636 -0
- truthound/datasources/pandas_optimized.py +582 -0
- truthound/datasources/pandas_source.py +216 -0
- truthound/datasources/polars_source.py +395 -0
- truthound/datasources/spark_source.py +479 -0
- truthound/datasources/sql/__init__.py +154 -0
- truthound/datasources/sql/base.py +710 -0
- truthound/datasources/sql/bigquery.py +410 -0
- truthound/datasources/sql/cloud_base.py +199 -0
- truthound/datasources/sql/databricks.py +471 -0
- truthound/datasources/sql/mysql.py +316 -0
- truthound/datasources/sql/oracle.py +427 -0
- truthound/datasources/sql/postgresql.py +321 -0
- truthound/datasources/sql/redshift.py +479 -0
- truthound/datasources/sql/snowflake.py +439 -0
- truthound/datasources/sql/sqlite.py +286 -0
- truthound/datasources/sql/sqlserver.py +437 -0
- truthound/datasources/streaming/__init__.py +47 -0
- truthound/datasources/streaming/base.py +350 -0
- truthound/datasources/streaming/kafka.py +670 -0
- truthound/decorators.py +98 -0
- truthound/docs/__init__.py +69 -0
- truthound/docs/extractor.py +971 -0
- truthound/docs/generator.py +601 -0
- truthound/docs/parser.py +1037 -0
- truthound/docs/renderer.py +999 -0
- truthound/drift/__init__.py +22 -0
- truthound/drift/compare.py +189 -0
- truthound/drift/detectors.py +464 -0
- truthound/drift/report.py +160 -0
- truthound/execution/__init__.py +65 -0
- truthound/execution/_protocols.py +324 -0
- truthound/execution/base.py +576 -0
- truthound/execution/distributed/__init__.py +179 -0
- truthound/execution/distributed/aggregations.py +731 -0
- truthound/execution/distributed/arrow_bridge.py +817 -0
- truthound/execution/distributed/base.py +550 -0
- truthound/execution/distributed/dask_engine.py +976 -0
- truthound/execution/distributed/mixins.py +766 -0
- truthound/execution/distributed/protocols.py +756 -0
- truthound/execution/distributed/ray_engine.py +1127 -0
- truthound/execution/distributed/registry.py +446 -0
- truthound/execution/distributed/spark_engine.py +1011 -0
- truthound/execution/distributed/validator_adapter.py +682 -0
- truthound/execution/pandas_engine.py +401 -0
- truthound/execution/polars_engine.py +497 -0
- truthound/execution/pushdown/__init__.py +230 -0
- truthound/execution/pushdown/ast.py +1550 -0
- truthound/execution/pushdown/builder.py +1550 -0
- truthound/execution/pushdown/dialects.py +1072 -0
- truthound/execution/pushdown/executor.py +829 -0
- truthound/execution/pushdown/optimizer.py +1041 -0
- truthound/execution/sql_engine.py +518 -0
- truthound/infrastructure/__init__.py +189 -0
- truthound/infrastructure/audit.py +1515 -0
- truthound/infrastructure/config.py +1133 -0
- truthound/infrastructure/encryption.py +1132 -0
- truthound/infrastructure/logging.py +1503 -0
- truthound/infrastructure/metrics.py +1220 -0
- truthound/lineage/__init__.py +89 -0
- truthound/lineage/base.py +746 -0
- truthound/lineage/impact_analysis.py +474 -0
- truthound/lineage/integrations/__init__.py +22 -0
- truthound/lineage/integrations/openlineage.py +548 -0
- truthound/lineage/tracker.py +512 -0
- truthound/lineage/visualization/__init__.py +33 -0
- truthound/lineage/visualization/protocols.py +145 -0
- truthound/lineage/visualization/renderers/__init__.py +20 -0
- truthound/lineage/visualization/renderers/cytoscape.py +329 -0
- truthound/lineage/visualization/renderers/d3.py +331 -0
- truthound/lineage/visualization/renderers/graphviz.py +276 -0
- truthound/lineage/visualization/renderers/mermaid.py +308 -0
- truthound/maskers.py +113 -0
- truthound/ml/__init__.py +124 -0
- truthound/ml/anomaly_models/__init__.py +31 -0
- truthound/ml/anomaly_models/ensemble.py +362 -0
- truthound/ml/anomaly_models/isolation_forest.py +444 -0
- truthound/ml/anomaly_models/statistical.py +392 -0
- truthound/ml/base.py +1178 -0
- truthound/ml/drift_detection/__init__.py +26 -0
- truthound/ml/drift_detection/concept.py +381 -0
- truthound/ml/drift_detection/distribution.py +361 -0
- truthound/ml/drift_detection/feature.py +442 -0
- truthound/ml/drift_detection/multivariate.py +495 -0
- truthound/ml/monitoring/__init__.py +88 -0
- truthound/ml/monitoring/alerting/__init__.py +33 -0
- truthound/ml/monitoring/alerting/handlers.py +427 -0
- truthound/ml/monitoring/alerting/rules.py +508 -0
- truthound/ml/monitoring/collectors/__init__.py +19 -0
- truthound/ml/monitoring/collectors/composite.py +105 -0
- truthound/ml/monitoring/collectors/drift.py +324 -0
- truthound/ml/monitoring/collectors/performance.py +179 -0
- truthound/ml/monitoring/collectors/quality.py +369 -0
- truthound/ml/monitoring/monitor.py +536 -0
- truthound/ml/monitoring/protocols.py +451 -0
- truthound/ml/monitoring/stores/__init__.py +15 -0
- truthound/ml/monitoring/stores/memory.py +201 -0
- truthound/ml/monitoring/stores/prometheus.py +296 -0
- truthound/ml/rule_learning/__init__.py +25 -0
- truthound/ml/rule_learning/constraint_miner.py +443 -0
- truthound/ml/rule_learning/pattern_learner.py +499 -0
- truthound/ml/rule_learning/profile_learner.py +462 -0
- truthound/multitenancy/__init__.py +326 -0
- truthound/multitenancy/core.py +852 -0
- truthound/multitenancy/integration.py +597 -0
- truthound/multitenancy/isolation.py +630 -0
- truthound/multitenancy/manager.py +770 -0
- truthound/multitenancy/middleware.py +765 -0
- truthound/multitenancy/quota.py +537 -0
- truthound/multitenancy/resolvers.py +603 -0
- truthound/multitenancy/storage.py +703 -0
- truthound/observability/__init__.py +307 -0
- truthound/observability/context.py +531 -0
- truthound/observability/instrumentation.py +611 -0
- truthound/observability/logging.py +887 -0
- truthound/observability/metrics.py +1157 -0
- truthound/observability/tracing/__init__.py +178 -0
- truthound/observability/tracing/baggage.py +310 -0
- truthound/observability/tracing/config.py +426 -0
- truthound/observability/tracing/exporter.py +787 -0
- truthound/observability/tracing/integration.py +1018 -0
- truthound/observability/tracing/otel/__init__.py +146 -0
- truthound/observability/tracing/otel/adapter.py +982 -0
- truthound/observability/tracing/otel/bridge.py +1177 -0
- truthound/observability/tracing/otel/compat.py +681 -0
- truthound/observability/tracing/otel/config.py +691 -0
- truthound/observability/tracing/otel/detection.py +327 -0
- truthound/observability/tracing/otel/protocols.py +426 -0
- truthound/observability/tracing/processor.py +561 -0
- truthound/observability/tracing/propagator.py +757 -0
- truthound/observability/tracing/provider.py +569 -0
- truthound/observability/tracing/resource.py +515 -0
- truthound/observability/tracing/sampler.py +487 -0
- truthound/observability/tracing/span.py +676 -0
- truthound/plugins/__init__.py +198 -0
- truthound/plugins/base.py +599 -0
- truthound/plugins/cli.py +680 -0
- truthound/plugins/dependencies/__init__.py +42 -0
- truthound/plugins/dependencies/graph.py +422 -0
- truthound/plugins/dependencies/resolver.py +417 -0
- truthound/plugins/discovery.py +379 -0
- truthound/plugins/docs/__init__.py +46 -0
- truthound/plugins/docs/extractor.py +444 -0
- truthound/plugins/docs/renderer.py +499 -0
- truthound/plugins/enterprise_manager.py +877 -0
- truthound/plugins/examples/__init__.py +19 -0
- truthound/plugins/examples/custom_validators.py +317 -0
- truthound/plugins/examples/slack_notifier.py +312 -0
- truthound/plugins/examples/xml_reporter.py +254 -0
- truthound/plugins/hooks.py +558 -0
- truthound/plugins/lifecycle/__init__.py +43 -0
- truthound/plugins/lifecycle/hot_reload.py +402 -0
- truthound/plugins/lifecycle/manager.py +371 -0
- truthound/plugins/manager.py +736 -0
- truthound/plugins/registry.py +338 -0
- truthound/plugins/security/__init__.py +93 -0
- truthound/plugins/security/exceptions.py +332 -0
- truthound/plugins/security/policies.py +348 -0
- truthound/plugins/security/protocols.py +643 -0
- truthound/plugins/security/sandbox/__init__.py +45 -0
- truthound/plugins/security/sandbox/context.py +158 -0
- truthound/plugins/security/sandbox/engines/__init__.py +19 -0
- truthound/plugins/security/sandbox/engines/container.py +379 -0
- truthound/plugins/security/sandbox/engines/noop.py +144 -0
- truthound/plugins/security/sandbox/engines/process.py +336 -0
- truthound/plugins/security/sandbox/factory.py +211 -0
- truthound/plugins/security/signing/__init__.py +57 -0
- truthound/plugins/security/signing/service.py +330 -0
- truthound/plugins/security/signing/trust_store.py +368 -0
- truthound/plugins/security/signing/verifier.py +459 -0
- truthound/plugins/versioning/__init__.py +41 -0
- truthound/plugins/versioning/constraints.py +297 -0
- truthound/plugins/versioning/resolver.py +329 -0
- truthound/profiler/__init__.py +1729 -0
- truthound/profiler/_lazy.py +452 -0
- truthound/profiler/ab_testing/__init__.py +80 -0
- truthound/profiler/ab_testing/analysis.py +449 -0
- truthound/profiler/ab_testing/base.py +257 -0
- truthound/profiler/ab_testing/experiment.py +395 -0
- truthound/profiler/ab_testing/tracking.py +368 -0
- truthound/profiler/auto_threshold.py +1170 -0
- truthound/profiler/base.py +579 -0
- truthound/profiler/cache_patterns.py +911 -0
- truthound/profiler/caching.py +1303 -0
- truthound/profiler/column_profiler.py +712 -0
- truthound/profiler/comparison.py +1007 -0
- truthound/profiler/custom_patterns.py +1170 -0
- truthound/profiler/dashboard/__init__.py +50 -0
- truthound/profiler/dashboard/app.py +476 -0
- truthound/profiler/dashboard/components.py +457 -0
- truthound/profiler/dashboard/config.py +72 -0
- truthound/profiler/distributed/__init__.py +83 -0
- truthound/profiler/distributed/base.py +281 -0
- truthound/profiler/distributed/dask_backend.py +498 -0
- truthound/profiler/distributed/local_backend.py +293 -0
- truthound/profiler/distributed/profiler.py +304 -0
- truthound/profiler/distributed/ray_backend.py +374 -0
- truthound/profiler/distributed/spark_backend.py +375 -0
- truthound/profiler/distributed.py +1366 -0
- truthound/profiler/enterprise_sampling.py +1065 -0
- truthound/profiler/errors.py +488 -0
- truthound/profiler/evolution/__init__.py +91 -0
- truthound/profiler/evolution/alerts.py +426 -0
- truthound/profiler/evolution/changes.py +206 -0
- truthound/profiler/evolution/compatibility.py +365 -0
- truthound/profiler/evolution/detector.py +372 -0
- truthound/profiler/evolution/protocols.py +121 -0
- truthound/profiler/generators/__init__.py +48 -0
- truthound/profiler/generators/base.py +384 -0
- truthound/profiler/generators/ml_rules.py +375 -0
- truthound/profiler/generators/pattern_rules.py +384 -0
- truthound/profiler/generators/schema_rules.py +267 -0
- truthound/profiler/generators/stats_rules.py +324 -0
- truthound/profiler/generators/suite_generator.py +857 -0
- truthound/profiler/i18n.py +1542 -0
- truthound/profiler/incremental.py +554 -0
- truthound/profiler/incremental_validation.py +1710 -0
- truthound/profiler/integration/__init__.py +73 -0
- truthound/profiler/integration/adapters.py +345 -0
- truthound/profiler/integration/context.py +371 -0
- truthound/profiler/integration/executor.py +527 -0
- truthound/profiler/integration/naming.py +75 -0
- truthound/profiler/integration/protocols.py +243 -0
- truthound/profiler/memory.py +1185 -0
- truthound/profiler/migration/__init__.py +60 -0
- truthound/profiler/migration/base.py +345 -0
- truthound/profiler/migration/manager.py +444 -0
- truthound/profiler/migration/v1_0_to_v1_1.py +484 -0
- truthound/profiler/ml/__init__.py +73 -0
- truthound/profiler/ml/base.py +244 -0
- truthound/profiler/ml/classifier.py +507 -0
- truthound/profiler/ml/feature_extraction.py +604 -0
- truthound/profiler/ml/pretrained.py +448 -0
- truthound/profiler/ml_inference.py +1276 -0
- truthound/profiler/native_patterns.py +815 -0
- truthound/profiler/observability.py +1184 -0
- truthound/profiler/process_timeout.py +1566 -0
- truthound/profiler/progress.py +568 -0
- truthound/profiler/progress_callbacks.py +1734 -0
- truthound/profiler/quality.py +1345 -0
- truthound/profiler/resilience.py +1180 -0
- truthound/profiler/sampled_matcher.py +794 -0
- truthound/profiler/sampling.py +1288 -0
- truthound/profiler/scheduling/__init__.py +82 -0
- truthound/profiler/scheduling/protocols.py +214 -0
- truthound/profiler/scheduling/scheduler.py +474 -0
- truthound/profiler/scheduling/storage.py +457 -0
- truthound/profiler/scheduling/triggers.py +449 -0
- truthound/profiler/schema.py +603 -0
- truthound/profiler/streaming.py +685 -0
- truthound/profiler/streaming_patterns.py +1354 -0
- truthound/profiler/suite_cli.py +625 -0
- truthound/profiler/suite_config.py +789 -0
- truthound/profiler/suite_export.py +1268 -0
- truthound/profiler/table_profiler.py +547 -0
- truthound/profiler/timeout.py +565 -0
- truthound/profiler/validation.py +1532 -0
- truthound/profiler/visualization/__init__.py +118 -0
- truthound/profiler/visualization/base.py +346 -0
- truthound/profiler/visualization/generator.py +1259 -0
- truthound/profiler/visualization/plotly_renderer.py +811 -0
- truthound/profiler/visualization/renderers.py +669 -0
- truthound/profiler/visualization/sections.py +540 -0
- truthound/profiler/visualization.py +2122 -0
- truthound/profiler/yaml_validation.py +1151 -0
- truthound/py.typed +0 -0
- truthound/ratelimit/__init__.py +248 -0
- truthound/ratelimit/algorithms.py +1108 -0
- truthound/ratelimit/core.py +573 -0
- truthound/ratelimit/integration.py +532 -0
- truthound/ratelimit/limiter.py +663 -0
- truthound/ratelimit/middleware.py +700 -0
- truthound/ratelimit/policy.py +792 -0
- truthound/ratelimit/storage.py +763 -0
- truthound/rbac/__init__.py +340 -0
- truthound/rbac/core.py +976 -0
- truthound/rbac/integration.py +760 -0
- truthound/rbac/manager.py +1052 -0
- truthound/rbac/middleware.py +842 -0
- truthound/rbac/policy.py +954 -0
- truthound/rbac/storage.py +878 -0
- truthound/realtime/__init__.py +141 -0
- truthound/realtime/adapters/__init__.py +43 -0
- truthound/realtime/adapters/base.py +533 -0
- truthound/realtime/adapters/kafka.py +487 -0
- truthound/realtime/adapters/kinesis.py +479 -0
- truthound/realtime/adapters/mock.py +243 -0
- truthound/realtime/base.py +553 -0
- truthound/realtime/factory.py +382 -0
- truthound/realtime/incremental.py +660 -0
- truthound/realtime/processing/__init__.py +67 -0
- truthound/realtime/processing/exactly_once.py +575 -0
- truthound/realtime/processing/state.py +547 -0
- truthound/realtime/processing/windows.py +647 -0
- truthound/realtime/protocols.py +569 -0
- truthound/realtime/streaming.py +605 -0
- truthound/realtime/testing/__init__.py +32 -0
- truthound/realtime/testing/containers.py +615 -0
- truthound/realtime/testing/fixtures.py +484 -0
- truthound/report.py +280 -0
- truthound/reporters/__init__.py +46 -0
- truthound/reporters/_protocols.py +30 -0
- truthound/reporters/base.py +324 -0
- truthound/reporters/ci/__init__.py +66 -0
- truthound/reporters/ci/azure.py +436 -0
- truthound/reporters/ci/base.py +509 -0
- truthound/reporters/ci/bitbucket.py +567 -0
- truthound/reporters/ci/circleci.py +547 -0
- truthound/reporters/ci/detection.py +364 -0
- truthound/reporters/ci/factory.py +182 -0
- truthound/reporters/ci/github.py +388 -0
- truthound/reporters/ci/gitlab.py +471 -0
- truthound/reporters/ci/jenkins.py +525 -0
- truthound/reporters/console_reporter.py +299 -0
- truthound/reporters/factory.py +211 -0
- truthound/reporters/html_reporter.py +524 -0
- truthound/reporters/json_reporter.py +256 -0
- truthound/reporters/markdown_reporter.py +280 -0
- truthound/reporters/sdk/__init__.py +174 -0
- truthound/reporters/sdk/builder.py +558 -0
- truthound/reporters/sdk/mixins.py +1150 -0
- truthound/reporters/sdk/schema.py +1493 -0
- truthound/reporters/sdk/templates.py +666 -0
- truthound/reporters/sdk/testing.py +968 -0
- truthound/scanners.py +170 -0
- truthound/scheduling/__init__.py +122 -0
- truthound/scheduling/cron.py +1136 -0
- truthound/scheduling/presets.py +212 -0
- truthound/schema.py +275 -0
- truthound/secrets/__init__.py +173 -0
- truthound/secrets/base.py +618 -0
- truthound/secrets/cloud.py +682 -0
- truthound/secrets/integration.py +507 -0
- truthound/secrets/manager.py +633 -0
- truthound/secrets/oidc/__init__.py +172 -0
- truthound/secrets/oidc/base.py +902 -0
- truthound/secrets/oidc/credential_provider.py +623 -0
- truthound/secrets/oidc/exchangers.py +1001 -0
- truthound/secrets/oidc/github/__init__.py +110 -0
- truthound/secrets/oidc/github/claims.py +718 -0
- truthound/secrets/oidc/github/enhanced_provider.py +693 -0
- truthound/secrets/oidc/github/trust_policy.py +742 -0
- truthound/secrets/oidc/github/verification.py +723 -0
- truthound/secrets/oidc/github/workflow.py +691 -0
- truthound/secrets/oidc/providers.py +825 -0
- truthound/secrets/providers.py +506 -0
- truthound/secrets/resolver.py +495 -0
- truthound/stores/__init__.py +177 -0
- truthound/stores/backends/__init__.py +18 -0
- truthound/stores/backends/_protocols.py +340 -0
- truthound/stores/backends/azure_blob.py +530 -0
- truthound/stores/backends/concurrent_filesystem.py +915 -0
- truthound/stores/backends/connection_pool.py +1365 -0
- truthound/stores/backends/database.py +743 -0
- truthound/stores/backends/filesystem.py +538 -0
- truthound/stores/backends/gcs.py +399 -0
- truthound/stores/backends/memory.py +354 -0
- truthound/stores/backends/s3.py +434 -0
- truthound/stores/backpressure/__init__.py +84 -0
- truthound/stores/backpressure/base.py +375 -0
- truthound/stores/backpressure/circuit_breaker.py +434 -0
- truthound/stores/backpressure/monitor.py +376 -0
- truthound/stores/backpressure/strategies.py +677 -0
- truthound/stores/base.py +551 -0
- truthound/stores/batching/__init__.py +65 -0
- truthound/stores/batching/base.py +305 -0
- truthound/stores/batching/buffer.py +370 -0
- truthound/stores/batching/store.py +248 -0
- truthound/stores/batching/writer.py +521 -0
- truthound/stores/caching/__init__.py +60 -0
- truthound/stores/caching/backends.py +684 -0
- truthound/stores/caching/base.py +356 -0
- truthound/stores/caching/store.py +305 -0
- truthound/stores/compression/__init__.py +193 -0
- truthound/stores/compression/adaptive.py +694 -0
- truthound/stores/compression/base.py +514 -0
- truthound/stores/compression/pipeline.py +868 -0
- truthound/stores/compression/providers.py +672 -0
- truthound/stores/compression/streaming.py +832 -0
- truthound/stores/concurrency/__init__.py +81 -0
- truthound/stores/concurrency/atomic.py +556 -0
- truthound/stores/concurrency/index.py +775 -0
- truthound/stores/concurrency/locks.py +576 -0
- truthound/stores/concurrency/manager.py +482 -0
- truthound/stores/encryption/__init__.py +297 -0
- truthound/stores/encryption/base.py +952 -0
- truthound/stores/encryption/keys.py +1191 -0
- truthound/stores/encryption/pipeline.py +903 -0
- truthound/stores/encryption/providers.py +953 -0
- truthound/stores/encryption/streaming.py +950 -0
- truthound/stores/expectations.py +227 -0
- truthound/stores/factory.py +246 -0
- truthound/stores/migration/__init__.py +75 -0
- truthound/stores/migration/base.py +480 -0
- truthound/stores/migration/manager.py +347 -0
- truthound/stores/migration/registry.py +382 -0
- truthound/stores/migration/store.py +559 -0
- truthound/stores/observability/__init__.py +106 -0
- truthound/stores/observability/audit.py +718 -0
- truthound/stores/observability/config.py +270 -0
- truthound/stores/observability/factory.py +208 -0
- truthound/stores/observability/metrics.py +636 -0
- truthound/stores/observability/protocols.py +410 -0
- truthound/stores/observability/store.py +570 -0
- truthound/stores/observability/tracing.py +784 -0
- truthound/stores/replication/__init__.py +76 -0
- truthound/stores/replication/base.py +260 -0
- truthound/stores/replication/monitor.py +269 -0
- truthound/stores/replication/store.py +439 -0
- truthound/stores/replication/syncer.py +391 -0
- truthound/stores/results.py +359 -0
- truthound/stores/retention/__init__.py +77 -0
- truthound/stores/retention/base.py +378 -0
- truthound/stores/retention/policies.py +621 -0
- truthound/stores/retention/scheduler.py +279 -0
- truthound/stores/retention/store.py +526 -0
- truthound/stores/streaming/__init__.py +138 -0
- truthound/stores/streaming/base.py +801 -0
- truthound/stores/streaming/database.py +984 -0
- truthound/stores/streaming/filesystem.py +719 -0
- truthound/stores/streaming/reader.py +629 -0
- truthound/stores/streaming/s3.py +843 -0
- truthound/stores/streaming/writer.py +790 -0
- truthound/stores/tiering/__init__.py +108 -0
- truthound/stores/tiering/base.py +462 -0
- truthound/stores/tiering/manager.py +249 -0
- truthound/stores/tiering/policies.py +692 -0
- truthound/stores/tiering/store.py +526 -0
- truthound/stores/versioning/__init__.py +56 -0
- truthound/stores/versioning/base.py +376 -0
- truthound/stores/versioning/store.py +660 -0
- truthound/stores/versioning/strategies.py +353 -0
- truthound/types.py +56 -0
- truthound/validators/__init__.py +774 -0
- truthound/validators/aggregate/__init__.py +27 -0
- truthound/validators/aggregate/central.py +116 -0
- truthound/validators/aggregate/extremes.py +116 -0
- truthound/validators/aggregate/spread.py +118 -0
- truthound/validators/aggregate/sum.py +64 -0
- truthound/validators/aggregate/type.py +78 -0
- truthound/validators/anomaly/__init__.py +93 -0
- truthound/validators/anomaly/base.py +431 -0
- truthound/validators/anomaly/ml_based.py +1190 -0
- truthound/validators/anomaly/multivariate.py +647 -0
- truthound/validators/anomaly/statistical.py +599 -0
- truthound/validators/base.py +1089 -0
- truthound/validators/business_rule/__init__.py +46 -0
- truthound/validators/business_rule/base.py +147 -0
- truthound/validators/business_rule/checksum.py +509 -0
- truthound/validators/business_rule/financial.py +526 -0
- truthound/validators/cache.py +733 -0
- truthound/validators/completeness/__init__.py +39 -0
- truthound/validators/completeness/conditional.py +73 -0
- truthound/validators/completeness/default.py +98 -0
- truthound/validators/completeness/empty.py +103 -0
- truthound/validators/completeness/nan.py +337 -0
- truthound/validators/completeness/null.py +152 -0
- truthound/validators/cross_table/__init__.py +17 -0
- truthound/validators/cross_table/aggregate.py +333 -0
- truthound/validators/cross_table/row_count.py +122 -0
- truthound/validators/datetime/__init__.py +29 -0
- truthound/validators/datetime/format.py +78 -0
- truthound/validators/datetime/freshness.py +269 -0
- truthound/validators/datetime/order.py +73 -0
- truthound/validators/datetime/parseable.py +185 -0
- truthound/validators/datetime/range.py +202 -0
- truthound/validators/datetime/timezone.py +69 -0
- truthound/validators/distribution/__init__.py +49 -0
- truthound/validators/distribution/distribution.py +128 -0
- truthound/validators/distribution/monotonic.py +119 -0
- truthound/validators/distribution/outlier.py +178 -0
- truthound/validators/distribution/quantile.py +80 -0
- truthound/validators/distribution/range.py +254 -0
- truthound/validators/distribution/set.py +125 -0
- truthound/validators/distribution/statistical.py +459 -0
- truthound/validators/drift/__init__.py +79 -0
- truthound/validators/drift/base.py +427 -0
- truthound/validators/drift/multi_feature.py +401 -0
- truthound/validators/drift/numeric.py +395 -0
- truthound/validators/drift/psi.py +446 -0
- truthound/validators/drift/statistical.py +510 -0
- truthound/validators/enterprise.py +1658 -0
- truthound/validators/geospatial/__init__.py +80 -0
- truthound/validators/geospatial/base.py +97 -0
- truthound/validators/geospatial/boundary.py +238 -0
- truthound/validators/geospatial/coordinate.py +351 -0
- truthound/validators/geospatial/distance.py +399 -0
- truthound/validators/geospatial/polygon.py +665 -0
- truthound/validators/i18n/__init__.py +308 -0
- truthound/validators/i18n/bidi.py +571 -0
- truthound/validators/i18n/catalogs.py +570 -0
- truthound/validators/i18n/dialects.py +763 -0
- truthound/validators/i18n/extended_catalogs.py +549 -0
- truthound/validators/i18n/formatting.py +1434 -0
- truthound/validators/i18n/loader.py +1020 -0
- truthound/validators/i18n/messages.py +521 -0
- truthound/validators/i18n/plural.py +683 -0
- truthound/validators/i18n/protocols.py +855 -0
- truthound/validators/i18n/tms.py +1162 -0
- truthound/validators/localization/__init__.py +53 -0
- truthound/validators/localization/base.py +122 -0
- truthound/validators/localization/chinese.py +362 -0
- truthound/validators/localization/japanese.py +275 -0
- truthound/validators/localization/korean.py +524 -0
- truthound/validators/memory/__init__.py +94 -0
- truthound/validators/memory/approximate_knn.py +506 -0
- truthound/validators/memory/base.py +547 -0
- truthound/validators/memory/sgd_online.py +719 -0
- truthound/validators/memory/streaming_ecdf.py +753 -0
- truthound/validators/ml_feature/__init__.py +54 -0
- truthound/validators/ml_feature/base.py +249 -0
- truthound/validators/ml_feature/correlation.py +299 -0
- truthound/validators/ml_feature/leakage.py +344 -0
- truthound/validators/ml_feature/null_impact.py +270 -0
- truthound/validators/ml_feature/scale.py +264 -0
- truthound/validators/multi_column/__init__.py +89 -0
- truthound/validators/multi_column/arithmetic.py +284 -0
- truthound/validators/multi_column/base.py +231 -0
- truthound/validators/multi_column/comparison.py +273 -0
- truthound/validators/multi_column/consistency.py +312 -0
- truthound/validators/multi_column/statistical.py +299 -0
- truthound/validators/optimization/__init__.py +164 -0
- truthound/validators/optimization/aggregation.py +563 -0
- truthound/validators/optimization/covariance.py +556 -0
- truthound/validators/optimization/geo.py +626 -0
- truthound/validators/optimization/graph.py +587 -0
- truthound/validators/optimization/orchestrator.py +970 -0
- truthound/validators/optimization/profiling.py +1312 -0
- truthound/validators/privacy/__init__.py +223 -0
- truthound/validators/privacy/base.py +635 -0
- truthound/validators/privacy/ccpa.py +670 -0
- truthound/validators/privacy/gdpr.py +728 -0
- truthound/validators/privacy/global_patterns.py +604 -0
- truthound/validators/privacy/plugins.py +867 -0
- truthound/validators/profiling/__init__.py +52 -0
- truthound/validators/profiling/base.py +175 -0
- truthound/validators/profiling/cardinality.py +312 -0
- truthound/validators/profiling/entropy.py +391 -0
- truthound/validators/profiling/frequency.py +455 -0
- truthound/validators/pushdown_support.py +660 -0
- truthound/validators/query/__init__.py +91 -0
- truthound/validators/query/aggregate.py +346 -0
- truthound/validators/query/base.py +246 -0
- truthound/validators/query/column.py +249 -0
- truthound/validators/query/expression.py +274 -0
- truthound/validators/query/result.py +323 -0
- truthound/validators/query/row_count.py +264 -0
- truthound/validators/referential/__init__.py +80 -0
- truthound/validators/referential/base.py +395 -0
- truthound/validators/referential/cascade.py +391 -0
- truthound/validators/referential/circular.py +563 -0
- truthound/validators/referential/foreign_key.py +624 -0
- truthound/validators/referential/orphan.py +485 -0
- truthound/validators/registry.py +112 -0
- truthound/validators/schema/__init__.py +41 -0
- truthound/validators/schema/column_count.py +142 -0
- truthound/validators/schema/column_exists.py +80 -0
- truthound/validators/schema/column_order.py +82 -0
- truthound/validators/schema/column_pair.py +85 -0
- truthound/validators/schema/column_pair_set.py +195 -0
- truthound/validators/schema/column_type.py +94 -0
- truthound/validators/schema/multi_column.py +53 -0
- truthound/validators/schema/multi_column_aggregate.py +175 -0
- truthound/validators/schema/referential.py +274 -0
- truthound/validators/schema/table_schema.py +91 -0
- truthound/validators/schema_validator.py +219 -0
- truthound/validators/sdk/__init__.py +250 -0
- truthound/validators/sdk/builder.py +680 -0
- truthound/validators/sdk/decorators.py +474 -0
- truthound/validators/sdk/enterprise/__init__.py +211 -0
- truthound/validators/sdk/enterprise/docs.py +725 -0
- truthound/validators/sdk/enterprise/fuzzing.py +659 -0
- truthound/validators/sdk/enterprise/licensing.py +709 -0
- truthound/validators/sdk/enterprise/manager.py +543 -0
- truthound/validators/sdk/enterprise/resources.py +628 -0
- truthound/validators/sdk/enterprise/sandbox.py +766 -0
- truthound/validators/sdk/enterprise/signing.py +603 -0
- truthound/validators/sdk/enterprise/templates.py +865 -0
- truthound/validators/sdk/enterprise/versioning.py +659 -0
- truthound/validators/sdk/templates.py +757 -0
- truthound/validators/sdk/testing.py +807 -0
- truthound/validators/security/__init__.py +181 -0
- truthound/validators/security/redos/__init__.py +182 -0
- truthound/validators/security/redos/core.py +861 -0
- truthound/validators/security/redos/cpu_monitor.py +593 -0
- truthound/validators/security/redos/cve_database.py +791 -0
- truthound/validators/security/redos/ml/__init__.py +155 -0
- truthound/validators/security/redos/ml/base.py +785 -0
- truthound/validators/security/redos/ml/datasets.py +618 -0
- truthound/validators/security/redos/ml/features.py +359 -0
- truthound/validators/security/redos/ml/models.py +1000 -0
- truthound/validators/security/redos/ml/predictor.py +507 -0
- truthound/validators/security/redos/ml/storage.py +632 -0
- truthound/validators/security/redos/ml/training.py +571 -0
- truthound/validators/security/redos/ml_analyzer.py +937 -0
- truthound/validators/security/redos/optimizer.py +674 -0
- truthound/validators/security/redos/profiler.py +682 -0
- truthound/validators/security/redos/re2_engine.py +709 -0
- truthound/validators/security/redos.py +886 -0
- truthound/validators/security/sql_security.py +1247 -0
- truthound/validators/streaming/__init__.py +126 -0
- truthound/validators/streaming/base.py +292 -0
- truthound/validators/streaming/completeness.py +210 -0
- truthound/validators/streaming/mixin.py +575 -0
- truthound/validators/streaming/range.py +308 -0
- truthound/validators/streaming/sources.py +846 -0
- truthound/validators/string/__init__.py +57 -0
- truthound/validators/string/casing.py +158 -0
- truthound/validators/string/charset.py +96 -0
- truthound/validators/string/format.py +501 -0
- truthound/validators/string/json.py +77 -0
- truthound/validators/string/json_schema.py +184 -0
- truthound/validators/string/length.py +104 -0
- truthound/validators/string/like_pattern.py +237 -0
- truthound/validators/string/regex.py +202 -0
- truthound/validators/string/regex_extended.py +435 -0
- truthound/validators/table/__init__.py +88 -0
- truthound/validators/table/base.py +78 -0
- truthound/validators/table/column_count.py +198 -0
- truthound/validators/table/freshness.py +362 -0
- truthound/validators/table/row_count.py +251 -0
- truthound/validators/table/schema.py +333 -0
- truthound/validators/table/size.py +285 -0
- truthound/validators/timeout/__init__.py +102 -0
- truthound/validators/timeout/advanced/__init__.py +247 -0
- truthound/validators/timeout/advanced/circuit_breaker.py +675 -0
- truthound/validators/timeout/advanced/prediction.py +773 -0
- truthound/validators/timeout/advanced/priority.py +618 -0
- truthound/validators/timeout/advanced/redis_backend.py +770 -0
- truthound/validators/timeout/advanced/retry.py +721 -0
- truthound/validators/timeout/advanced/sampling.py +788 -0
- truthound/validators/timeout/advanced/sla.py +661 -0
- truthound/validators/timeout/advanced/telemetry.py +804 -0
- truthound/validators/timeout/cascade.py +477 -0
- truthound/validators/timeout/deadline.py +657 -0
- truthound/validators/timeout/degradation.py +525 -0
- truthound/validators/timeout/distributed.py +597 -0
- truthound/validators/timeseries/__init__.py +89 -0
- truthound/validators/timeseries/base.py +326 -0
- truthound/validators/timeseries/completeness.py +617 -0
- truthound/validators/timeseries/gap.py +485 -0
- truthound/validators/timeseries/monotonic.py +310 -0
- truthound/validators/timeseries/seasonality.py +422 -0
- truthound/validators/timeseries/trend.py +510 -0
- truthound/validators/uniqueness/__init__.py +59 -0
- truthound/validators/uniqueness/approximate.py +475 -0
- truthound/validators/uniqueness/distinct_values.py +253 -0
- truthound/validators/uniqueness/duplicate.py +118 -0
- truthound/validators/uniqueness/primary_key.py +140 -0
- truthound/validators/uniqueness/unique.py +191 -0
- truthound/validators/uniqueness/within_record.py +599 -0
- truthound/validators/utils.py +756 -0
- truthound-1.0.8.dist-info/METADATA +474 -0
- truthound-1.0.8.dist-info/RECORD +877 -0
- truthound-1.0.8.dist-info/WHEEL +4 -0
- truthound-1.0.8.dist-info/entry_points.txt +2 -0
- truthound-1.0.8.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
"""Cloud Token Exchanger Implementations.
|
|
2
|
+
|
|
3
|
+
This module provides token exchange implementations for various cloud providers:
|
|
4
|
+
- AWS STS (AssumeRoleWithWebIdentity)
|
|
5
|
+
- Google Cloud Workload Identity
|
|
6
|
+
- Azure Federated Credentials
|
|
7
|
+
- HashiCorp Vault JWT Auth
|
|
8
|
+
|
|
9
|
+
Each exchanger converts an OIDC token to cloud-specific credentials.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
from xml.etree import ElementTree
|
|
24
|
+
|
|
25
|
+
from truthound.secrets.oidc.base import (
|
|
26
|
+
AWSCredentials,
|
|
27
|
+
AzureCredentials,
|
|
28
|
+
BaseTokenExchanger,
|
|
29
|
+
CloudCredentials,
|
|
30
|
+
CloudProvider,
|
|
31
|
+
GCPCredentials,
|
|
32
|
+
OIDCExchangeError,
|
|
33
|
+
OIDCToken,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# =============================================================================
|
|
44
|
+
# AWS Token Exchanger
|
|
45
|
+
# =============================================================================
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AWSTokenExchangerConfig:
|
|
50
|
+
"""Configuration for AWS token exchange.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
role_arn: ARN of the IAM role to assume.
|
|
54
|
+
session_name: Session name for the assumed role.
|
|
55
|
+
session_duration_seconds: How long credentials should be valid.
|
|
56
|
+
region: AWS region for STS endpoint.
|
|
57
|
+
sts_endpoint: Custom STS endpoint (optional).
|
|
58
|
+
request_timeout: HTTP request timeout.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
role_arn: str = ""
|
|
62
|
+
session_name: str = "truthound-oidc"
|
|
63
|
+
session_duration_seconds: int = 3600
|
|
64
|
+
region: str = "us-east-1"
|
|
65
|
+
sts_endpoint: str | None = None
|
|
66
|
+
request_timeout: float = 30.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AWSTokenExchanger(BaseTokenExchanger):
|
|
70
|
+
"""AWS STS token exchanger using AssumeRoleWithWebIdentity.
|
|
71
|
+
|
|
72
|
+
Exchanges an OIDC token for temporary AWS credentials by calling
|
|
73
|
+
the AWS STS AssumeRoleWithWebIdentity API.
|
|
74
|
+
|
|
75
|
+
Requirements:
|
|
76
|
+
- IAM role must have a trust policy allowing the OIDC provider
|
|
77
|
+
- Role ARN must be configured
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
>>> exchanger = AWSTokenExchanger(
|
|
81
|
+
... role_arn="arn:aws:iam::123456789012:role/my-role",
|
|
82
|
+
... session_name="my-session",
|
|
83
|
+
... )
|
|
84
|
+
>>> credentials = exchanger.exchange(oidc_token)
|
|
85
|
+
>>> print(credentials.access_key_id)
|
|
86
|
+
|
|
87
|
+
Trust Policy Example:
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"Version": "2012-10-17",
|
|
91
|
+
"Statement": [{
|
|
92
|
+
"Effect": "Allow",
|
|
93
|
+
"Principal": {
|
|
94
|
+
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
|
|
95
|
+
},
|
|
96
|
+
"Action": "sts:AssumeRoleWithWebIdentity",
|
|
97
|
+
"Condition": {
|
|
98
|
+
"StringEquals": {
|
|
99
|
+
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
|
|
100
|
+
},
|
|
101
|
+
"StringLike": {
|
|
102
|
+
"token.actions.githubusercontent.com:sub": "repo:owner/repo:*"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# Default STS endpoint
|
|
111
|
+
STS_ENDPOINT_TEMPLATE = "https://sts.{region}.amazonaws.com/"
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
role_arn: str | None = None,
|
|
116
|
+
*,
|
|
117
|
+
session_name: str = "truthound-oidc",
|
|
118
|
+
session_duration_seconds: int = 3600,
|
|
119
|
+
region: str | None = None,
|
|
120
|
+
config: AWSTokenExchangerConfig | None = None,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Initialize AWS token exchanger.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
role_arn: IAM role ARN (or use AWS_ROLE_ARN env var).
|
|
127
|
+
session_name: Session name.
|
|
128
|
+
session_duration_seconds: Credential validity duration.
|
|
129
|
+
region: AWS region (or use AWS_REGION env var).
|
|
130
|
+
config: Full configuration object.
|
|
131
|
+
**kwargs: Additional base class arguments.
|
|
132
|
+
"""
|
|
133
|
+
self._config = config or AWSTokenExchangerConfig()
|
|
134
|
+
|
|
135
|
+
# Override with explicit parameters
|
|
136
|
+
if role_arn:
|
|
137
|
+
self._config.role_arn = role_arn
|
|
138
|
+
elif not self._config.role_arn:
|
|
139
|
+
self._config.role_arn = os.environ.get("AWS_ROLE_ARN", "")
|
|
140
|
+
|
|
141
|
+
self._config.session_name = session_name
|
|
142
|
+
self._config.session_duration_seconds = session_duration_seconds
|
|
143
|
+
|
|
144
|
+
if region:
|
|
145
|
+
self._config.region = region
|
|
146
|
+
elif not self._config.region or self._config.region == "us-east-1":
|
|
147
|
+
self._config.region = os.environ.get("AWS_REGION", "us-east-1")
|
|
148
|
+
|
|
149
|
+
super().__init__(**kwargs)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def cloud_provider(self) -> CloudProvider:
|
|
153
|
+
return CloudProvider.AWS
|
|
154
|
+
|
|
155
|
+
def _get_sts_endpoint(self) -> str:
|
|
156
|
+
"""Get the STS endpoint URL."""
|
|
157
|
+
if self._config.sts_endpoint:
|
|
158
|
+
return self._config.sts_endpoint
|
|
159
|
+
return self.STS_ENDPOINT_TEMPLATE.format(region=self._config.region)
|
|
160
|
+
|
|
161
|
+
def _exchange(self, token: OIDCToken) -> AWSCredentials:
|
|
162
|
+
"""Exchange OIDC token for AWS credentials.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
token: OIDC token to exchange.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
AWS credentials.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
OIDCExchangeError: If exchange fails.
|
|
172
|
+
"""
|
|
173
|
+
if not self._config.role_arn:
|
|
174
|
+
raise OIDCExchangeError(
|
|
175
|
+
"role_arn is required",
|
|
176
|
+
cloud_provider="aws",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Build STS request parameters
|
|
180
|
+
params = {
|
|
181
|
+
"Action": "AssumeRoleWithWebIdentity",
|
|
182
|
+
"Version": "2011-06-15",
|
|
183
|
+
"RoleArn": self._config.role_arn,
|
|
184
|
+
"RoleSessionName": self._config.session_name,
|
|
185
|
+
"WebIdentityToken": token.get_token(),
|
|
186
|
+
"DurationSeconds": str(self._config.session_duration_seconds),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
endpoint = self._get_sts_endpoint()
|
|
190
|
+
url = f"{endpoint}?{urllib.parse.urlencode(params)}"
|
|
191
|
+
|
|
192
|
+
request = urllib.request.Request(
|
|
193
|
+
url,
|
|
194
|
+
method="POST",
|
|
195
|
+
headers={"Accept": "application/xml"},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
with urllib.request.urlopen(
|
|
200
|
+
request, timeout=self._config.request_timeout
|
|
201
|
+
) as response:
|
|
202
|
+
return self._parse_sts_response(response.read())
|
|
203
|
+
|
|
204
|
+
except urllib.error.HTTPError as e:
|
|
205
|
+
error_body = ""
|
|
206
|
+
try:
|
|
207
|
+
error_body = e.read().decode()
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
raise OIDCExchangeError(
|
|
211
|
+
f"STS API error: {error_body or e.reason}",
|
|
212
|
+
cloud_provider="aws",
|
|
213
|
+
status_code=e.code,
|
|
214
|
+
response=error_body,
|
|
215
|
+
) from e
|
|
216
|
+
except urllib.error.URLError as e:
|
|
217
|
+
raise OIDCExchangeError(
|
|
218
|
+
f"Network error: {e.reason}",
|
|
219
|
+
cloud_provider="aws",
|
|
220
|
+
) from e
|
|
221
|
+
|
|
222
|
+
def _parse_sts_response(self, response_xml: bytes) -> AWSCredentials:
|
|
223
|
+
"""Parse STS AssumeRoleWithWebIdentity response.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
response_xml: XML response from STS.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
AWS credentials.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
# Parse XML response
|
|
233
|
+
root = ElementTree.fromstring(response_xml)
|
|
234
|
+
|
|
235
|
+
# Find credentials in response (namespace handling)
|
|
236
|
+
ns = {"sts": "https://sts.amazonaws.com/doc/2011-06-15/"}
|
|
237
|
+
|
|
238
|
+
# Try with namespace first, then without
|
|
239
|
+
creds = root.find(".//sts:Credentials", ns)
|
|
240
|
+
if creds is None:
|
|
241
|
+
creds = root.find(".//Credentials")
|
|
242
|
+
|
|
243
|
+
if creds is None:
|
|
244
|
+
raise OIDCExchangeError(
|
|
245
|
+
"Credentials not found in STS response",
|
|
246
|
+
cloud_provider="aws",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Extract credential values
|
|
250
|
+
def get_text(elem: ElementTree.Element | None, tag: str) -> str:
|
|
251
|
+
child = elem.find(f"sts:{tag}", ns) if elem else None
|
|
252
|
+
if child is None and elem is not None:
|
|
253
|
+
child = elem.find(tag)
|
|
254
|
+
return child.text if child is not None and child.text else ""
|
|
255
|
+
|
|
256
|
+
access_key_id = get_text(creds, "AccessKeyId")
|
|
257
|
+
secret_access_key = get_text(creds, "SecretAccessKey")
|
|
258
|
+
session_token = get_text(creds, "SessionToken")
|
|
259
|
+
expiration_str = get_text(creds, "Expiration")
|
|
260
|
+
|
|
261
|
+
# Parse expiration time
|
|
262
|
+
expires_at = None
|
|
263
|
+
if expiration_str:
|
|
264
|
+
try:
|
|
265
|
+
# Handle ISO format with Z suffix
|
|
266
|
+
if expiration_str.endswith("Z"):
|
|
267
|
+
expiration_str = expiration_str[:-1] + "+00:00"
|
|
268
|
+
expires_at = datetime.fromisoformat(expiration_str)
|
|
269
|
+
except ValueError:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
# Get assumed role ARN
|
|
273
|
+
assumed_role = root.find(".//sts:AssumedRoleUser", ns)
|
|
274
|
+
if assumed_role is None:
|
|
275
|
+
assumed_role = root.find(".//AssumedRoleUser")
|
|
276
|
+
|
|
277
|
+
assumed_role_arn = ""
|
|
278
|
+
if assumed_role is not None:
|
|
279
|
+
arn_elem = assumed_role.find("sts:Arn", ns)
|
|
280
|
+
if arn_elem is None:
|
|
281
|
+
arn_elem = assumed_role.find("Arn")
|
|
282
|
+
if arn_elem is not None and arn_elem.text:
|
|
283
|
+
assumed_role_arn = arn_elem.text
|
|
284
|
+
|
|
285
|
+
return AWSCredentials(
|
|
286
|
+
access_key_id=access_key_id,
|
|
287
|
+
secret_access_key=secret_access_key,
|
|
288
|
+
session_token=session_token,
|
|
289
|
+
assumed_role_arn=assumed_role_arn,
|
|
290
|
+
expires_at=expires_at,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
except ElementTree.ParseError as e:
|
|
294
|
+
raise OIDCExchangeError(
|
|
295
|
+
f"Invalid STS response: {e}",
|
|
296
|
+
cloud_provider="aws",
|
|
297
|
+
) from e
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# =============================================================================
|
|
301
|
+
# GCP Token Exchanger
|
|
302
|
+
# =============================================================================
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass
|
|
306
|
+
class GCPTokenExchangerConfig:
|
|
307
|
+
"""Configuration for GCP token exchange.
|
|
308
|
+
|
|
309
|
+
Attributes:
|
|
310
|
+
project_number: GCP project number.
|
|
311
|
+
pool_id: Workload Identity Pool ID.
|
|
312
|
+
provider_id: Workload Identity Provider ID.
|
|
313
|
+
service_account_email: Service account to impersonate.
|
|
314
|
+
token_lifetime_seconds: Access token lifetime.
|
|
315
|
+
request_timeout: HTTP request timeout.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
project_number: str = ""
|
|
319
|
+
pool_id: str = ""
|
|
320
|
+
provider_id: str = ""
|
|
321
|
+
service_account_email: str = ""
|
|
322
|
+
token_lifetime_seconds: int = 3600
|
|
323
|
+
request_timeout: float = 30.0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class GCPTokenExchanger(BaseTokenExchanger):
|
|
327
|
+
"""GCP Workload Identity token exchanger.
|
|
328
|
+
|
|
329
|
+
Exchanges an OIDC token for GCP access token through:
|
|
330
|
+
1. STS Token Exchange - exchange OIDC for federated token
|
|
331
|
+
2. Service Account Impersonation - get access token for SA
|
|
332
|
+
|
|
333
|
+
Requirements:
|
|
334
|
+
- Workload Identity Pool configured
|
|
335
|
+
- OIDC provider registered in the pool
|
|
336
|
+
- Service account with appropriate permissions
|
|
337
|
+
|
|
338
|
+
Example:
|
|
339
|
+
>>> exchanger = GCPTokenExchanger(
|
|
340
|
+
... project_number="123456789",
|
|
341
|
+
... pool_id="my-pool",
|
|
342
|
+
... provider_id="github",
|
|
343
|
+
... service_account_email="sa@project.iam.gserviceaccount.com",
|
|
344
|
+
... )
|
|
345
|
+
>>> credentials = exchanger.exchange(oidc_token)
|
|
346
|
+
>>> print(credentials.access_token)
|
|
347
|
+
|
|
348
|
+
Configuration in GCP:
|
|
349
|
+
1. Create Workload Identity Pool
|
|
350
|
+
2. Add OIDC provider (e.g., GitHub Actions)
|
|
351
|
+
3. Grant service account impersonation permission
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
# GCP endpoints
|
|
355
|
+
STS_EXCHANGE_URL = "https://sts.googleapis.com/v1/token"
|
|
356
|
+
SA_IMPERSONATE_URL = (
|
|
357
|
+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/"
|
|
358
|
+
"{service_account}:generateAccessToken"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def __init__(
|
|
362
|
+
self,
|
|
363
|
+
project_number: str | None = None,
|
|
364
|
+
pool_id: str | None = None,
|
|
365
|
+
provider_id: str | None = None,
|
|
366
|
+
service_account_email: str | None = None,
|
|
367
|
+
*,
|
|
368
|
+
config: GCPTokenExchangerConfig | None = None,
|
|
369
|
+
**kwargs: Any,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Initialize GCP token exchanger.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
project_number: GCP project number.
|
|
375
|
+
pool_id: Workload Identity Pool ID.
|
|
376
|
+
provider_id: Workload Identity Provider ID.
|
|
377
|
+
service_account_email: Service account to impersonate.
|
|
378
|
+
config: Full configuration object.
|
|
379
|
+
**kwargs: Additional base class arguments.
|
|
380
|
+
"""
|
|
381
|
+
self._config = config or GCPTokenExchangerConfig()
|
|
382
|
+
|
|
383
|
+
# Override with explicit parameters
|
|
384
|
+
if project_number:
|
|
385
|
+
self._config.project_number = project_number
|
|
386
|
+
if pool_id:
|
|
387
|
+
self._config.pool_id = pool_id
|
|
388
|
+
if provider_id:
|
|
389
|
+
self._config.provider_id = provider_id
|
|
390
|
+
if service_account_email:
|
|
391
|
+
self._config.service_account_email = service_account_email
|
|
392
|
+
|
|
393
|
+
super().__init__(**kwargs)
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def cloud_provider(self) -> CloudProvider:
|
|
397
|
+
return CloudProvider.GCP
|
|
398
|
+
|
|
399
|
+
def _get_audience(self) -> str:
|
|
400
|
+
"""Get the Workload Identity audience URL."""
|
|
401
|
+
return (
|
|
402
|
+
f"//iam.googleapis.com/projects/{self._config.project_number}/"
|
|
403
|
+
f"locations/global/workloadIdentityPools/{self._config.pool_id}/"
|
|
404
|
+
f"providers/{self._config.provider_id}"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def _exchange(self, token: OIDCToken) -> GCPCredentials:
|
|
408
|
+
"""Exchange OIDC token for GCP credentials.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
token: OIDC token to exchange.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
GCP credentials.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
OIDCExchangeError: If exchange fails.
|
|
418
|
+
"""
|
|
419
|
+
if not all([
|
|
420
|
+
self._config.project_number,
|
|
421
|
+
self._config.pool_id,
|
|
422
|
+
self._config.provider_id,
|
|
423
|
+
self._config.service_account_email,
|
|
424
|
+
]):
|
|
425
|
+
raise OIDCExchangeError(
|
|
426
|
+
"project_number, pool_id, provider_id, and "
|
|
427
|
+
"service_account_email are required",
|
|
428
|
+
cloud_provider="gcp",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Step 1: Exchange OIDC token for federated token
|
|
432
|
+
federated_token = self._exchange_for_federated_token(token)
|
|
433
|
+
|
|
434
|
+
# Step 2: Impersonate service account
|
|
435
|
+
return self._impersonate_service_account(federated_token)
|
|
436
|
+
|
|
437
|
+
def _exchange_for_federated_token(self, token: OIDCToken) -> str:
|
|
438
|
+
"""Exchange OIDC token for GCP federated token.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
token: OIDC token.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Federated access token.
|
|
445
|
+
"""
|
|
446
|
+
audience = self._get_audience()
|
|
447
|
+
|
|
448
|
+
request_body = {
|
|
449
|
+
"grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
450
|
+
"audience": audience,
|
|
451
|
+
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
|
452
|
+
"requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
|
|
453
|
+
"subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
|
|
454
|
+
"subjectToken": token.get_token(),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
request = urllib.request.Request(
|
|
458
|
+
self.STS_EXCHANGE_URL,
|
|
459
|
+
data=json.dumps(request_body).encode(),
|
|
460
|
+
headers={
|
|
461
|
+
"Content-Type": "application/json",
|
|
462
|
+
},
|
|
463
|
+
method="POST",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
with urllib.request.urlopen(
|
|
468
|
+
request, timeout=self._config.request_timeout
|
|
469
|
+
) as response:
|
|
470
|
+
data = json.loads(response.read())
|
|
471
|
+
return data["access_token"]
|
|
472
|
+
|
|
473
|
+
except urllib.error.HTTPError as e:
|
|
474
|
+
error_body = ""
|
|
475
|
+
try:
|
|
476
|
+
error_body = e.read().decode()
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
raise OIDCExchangeError(
|
|
480
|
+
f"GCP STS exchange failed: {error_body or e.reason}",
|
|
481
|
+
cloud_provider="gcp",
|
|
482
|
+
status_code=e.code,
|
|
483
|
+
response=error_body,
|
|
484
|
+
) from e
|
|
485
|
+
except KeyError:
|
|
486
|
+
raise OIDCExchangeError(
|
|
487
|
+
"access_token not found in STS response",
|
|
488
|
+
cloud_provider="gcp",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def _impersonate_service_account(self, federated_token: str) -> GCPCredentials:
|
|
492
|
+
"""Impersonate service account to get access token.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
federated_token: Federated token from STS exchange.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
GCP credentials with access token.
|
|
499
|
+
"""
|
|
500
|
+
url = self.SA_IMPERSONATE_URL.format(
|
|
501
|
+
service_account=urllib.parse.quote(
|
|
502
|
+
self._config.service_account_email, safe=""
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
request_body = {
|
|
507
|
+
"scope": ["https://www.googleapis.com/auth/cloud-platform"],
|
|
508
|
+
"lifetime": f"{self._config.token_lifetime_seconds}s",
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
request = urllib.request.Request(
|
|
512
|
+
url,
|
|
513
|
+
data=json.dumps(request_body).encode(),
|
|
514
|
+
headers={
|
|
515
|
+
"Authorization": f"Bearer {federated_token}",
|
|
516
|
+
"Content-Type": "application/json",
|
|
517
|
+
},
|
|
518
|
+
method="POST",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
with urllib.request.urlopen(
|
|
523
|
+
request, timeout=self._config.request_timeout
|
|
524
|
+
) as response:
|
|
525
|
+
data = json.loads(response.read())
|
|
526
|
+
|
|
527
|
+
# Parse expiration time
|
|
528
|
+
expires_at = None
|
|
529
|
+
expire_time = data.get("expireTime")
|
|
530
|
+
if expire_time:
|
|
531
|
+
try:
|
|
532
|
+
if expire_time.endswith("Z"):
|
|
533
|
+
expire_time = expire_time[:-1] + "+00:00"
|
|
534
|
+
expires_at = datetime.fromisoformat(expire_time)
|
|
535
|
+
except ValueError:
|
|
536
|
+
pass
|
|
537
|
+
|
|
538
|
+
# Extract project ID from service account email
|
|
539
|
+
parts = self._config.service_account_email.split("@")
|
|
540
|
+
project_id = ""
|
|
541
|
+
if len(parts) == 2:
|
|
542
|
+
project_parts = parts[1].split(".")
|
|
543
|
+
if project_parts:
|
|
544
|
+
project_id = project_parts[0]
|
|
545
|
+
|
|
546
|
+
return GCPCredentials(
|
|
547
|
+
access_token=data["accessToken"],
|
|
548
|
+
service_account=self._config.service_account_email,
|
|
549
|
+
project_id=project_id,
|
|
550
|
+
expires_at=expires_at,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
except urllib.error.HTTPError as e:
|
|
554
|
+
error_body = ""
|
|
555
|
+
try:
|
|
556
|
+
error_body = e.read().decode()
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
raise OIDCExchangeError(
|
|
560
|
+
f"Service account impersonation failed: {error_body or e.reason}",
|
|
561
|
+
cloud_provider="gcp",
|
|
562
|
+
status_code=e.code,
|
|
563
|
+
response=error_body,
|
|
564
|
+
) from e
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# =============================================================================
|
|
568
|
+
# Azure Token Exchanger
|
|
569
|
+
# =============================================================================
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@dataclass
|
|
573
|
+
class AzureTokenExchangerConfig:
|
|
574
|
+
"""Configuration for Azure token exchange.
|
|
575
|
+
|
|
576
|
+
Attributes:
|
|
577
|
+
tenant_id: Azure tenant ID.
|
|
578
|
+
client_id: Azure client/app ID.
|
|
579
|
+
subscription_id: Azure subscription ID.
|
|
580
|
+
scope: Token scope (default: Azure management).
|
|
581
|
+
request_timeout: HTTP request timeout.
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
tenant_id: str = ""
|
|
585
|
+
client_id: str = ""
|
|
586
|
+
subscription_id: str = ""
|
|
587
|
+
scope: str = "https://management.azure.com/.default"
|
|
588
|
+
request_timeout: float = 30.0
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class AzureTokenExchanger(BaseTokenExchanger):
|
|
592
|
+
"""Azure federated credentials token exchanger.
|
|
593
|
+
|
|
594
|
+
Exchanges an OIDC token for Azure access token using the
|
|
595
|
+
client credentials flow with federated identity.
|
|
596
|
+
|
|
597
|
+
Requirements:
|
|
598
|
+
- App registration with federated credential configured
|
|
599
|
+
- OIDC issuer and subject claim mapping
|
|
600
|
+
|
|
601
|
+
Example:
|
|
602
|
+
>>> exchanger = AzureTokenExchanger(
|
|
603
|
+
... tenant_id="12345678-...",
|
|
604
|
+
... client_id="87654321-...",
|
|
605
|
+
... )
|
|
606
|
+
>>> credentials = exchanger.exchange(oidc_token)
|
|
607
|
+
>>> print(credentials.access_token)
|
|
608
|
+
|
|
609
|
+
Configuration in Azure:
|
|
610
|
+
1. Create App Registration
|
|
611
|
+
2. Add Federated Credential with OIDC issuer
|
|
612
|
+
3. Configure subject claim matching
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
# Azure OAuth2 endpoint template
|
|
616
|
+
TOKEN_URL_TEMPLATE = (
|
|
617
|
+
"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def __init__(
|
|
621
|
+
self,
|
|
622
|
+
tenant_id: str | None = None,
|
|
623
|
+
client_id: str | None = None,
|
|
624
|
+
*,
|
|
625
|
+
subscription_id: str | None = None,
|
|
626
|
+
scope: str | None = None,
|
|
627
|
+
config: AzureTokenExchangerConfig | None = None,
|
|
628
|
+
**kwargs: Any,
|
|
629
|
+
) -> None:
|
|
630
|
+
"""Initialize Azure token exchanger.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
tenant_id: Azure tenant ID (or AZURE_TENANT_ID env var).
|
|
634
|
+
client_id: Azure client ID (or AZURE_CLIENT_ID env var).
|
|
635
|
+
subscription_id: Azure subscription ID.
|
|
636
|
+
scope: Token scope.
|
|
637
|
+
config: Full configuration object.
|
|
638
|
+
**kwargs: Additional base class arguments.
|
|
639
|
+
"""
|
|
640
|
+
self._config = config or AzureTokenExchangerConfig()
|
|
641
|
+
|
|
642
|
+
# Override with explicit parameters or env vars
|
|
643
|
+
if tenant_id:
|
|
644
|
+
self._config.tenant_id = tenant_id
|
|
645
|
+
elif not self._config.tenant_id:
|
|
646
|
+
self._config.tenant_id = os.environ.get("AZURE_TENANT_ID", "")
|
|
647
|
+
|
|
648
|
+
if client_id:
|
|
649
|
+
self._config.client_id = client_id
|
|
650
|
+
elif not self._config.client_id:
|
|
651
|
+
self._config.client_id = os.environ.get("AZURE_CLIENT_ID", "")
|
|
652
|
+
|
|
653
|
+
if subscription_id:
|
|
654
|
+
self._config.subscription_id = subscription_id
|
|
655
|
+
elif not self._config.subscription_id:
|
|
656
|
+
self._config.subscription_id = os.environ.get("AZURE_SUBSCRIPTION_ID", "")
|
|
657
|
+
|
|
658
|
+
if scope:
|
|
659
|
+
self._config.scope = scope
|
|
660
|
+
|
|
661
|
+
super().__init__(**kwargs)
|
|
662
|
+
|
|
663
|
+
@property
|
|
664
|
+
def cloud_provider(self) -> CloudProvider:
|
|
665
|
+
return CloudProvider.AZURE
|
|
666
|
+
|
|
667
|
+
def _exchange(self, token: OIDCToken) -> AzureCredentials:
|
|
668
|
+
"""Exchange OIDC token for Azure credentials.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
token: OIDC token to exchange.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Azure credentials.
|
|
675
|
+
|
|
676
|
+
Raises:
|
|
677
|
+
OIDCExchangeError: If exchange fails.
|
|
678
|
+
"""
|
|
679
|
+
if not self._config.tenant_id or not self._config.client_id:
|
|
680
|
+
raise OIDCExchangeError(
|
|
681
|
+
"tenant_id and client_id are required",
|
|
682
|
+
cloud_provider="azure",
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
url = self.TOKEN_URL_TEMPLATE.format(tenant_id=self._config.tenant_id)
|
|
686
|
+
|
|
687
|
+
# Build form data for token request
|
|
688
|
+
form_data = {
|
|
689
|
+
"client_id": self._config.client_id,
|
|
690
|
+
"client_assertion_type": (
|
|
691
|
+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
692
|
+
),
|
|
693
|
+
"client_assertion": token.get_token(),
|
|
694
|
+
"grant_type": "client_credentials",
|
|
695
|
+
"scope": self._config.scope,
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
request = urllib.request.Request(
|
|
699
|
+
url,
|
|
700
|
+
data=urllib.parse.urlencode(form_data).encode(),
|
|
701
|
+
headers={
|
|
702
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
703
|
+
},
|
|
704
|
+
method="POST",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
with urllib.request.urlopen(
|
|
709
|
+
request, timeout=self._config.request_timeout
|
|
710
|
+
) as response:
|
|
711
|
+
data = json.loads(response.read())
|
|
712
|
+
|
|
713
|
+
# Calculate expiration time
|
|
714
|
+
expires_at = None
|
|
715
|
+
expires_in = data.get("expires_in")
|
|
716
|
+
if expires_in:
|
|
717
|
+
expires_at = datetime.now() + timedelta(seconds=int(expires_in))
|
|
718
|
+
|
|
719
|
+
return AzureCredentials(
|
|
720
|
+
access_token=data["access_token"],
|
|
721
|
+
token_type=data.get("token_type", "Bearer"),
|
|
722
|
+
tenant_id=self._config.tenant_id,
|
|
723
|
+
client_id=self._config.client_id,
|
|
724
|
+
subscription_id=self._config.subscription_id,
|
|
725
|
+
expires_at=expires_at,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
except urllib.error.HTTPError as e:
|
|
729
|
+
error_body = ""
|
|
730
|
+
try:
|
|
731
|
+
error_body = e.read().decode()
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
734
|
+
raise OIDCExchangeError(
|
|
735
|
+
f"Azure token exchange failed: {error_body or e.reason}",
|
|
736
|
+
cloud_provider="azure",
|
|
737
|
+
status_code=e.code,
|
|
738
|
+
response=error_body,
|
|
739
|
+
) from e
|
|
740
|
+
except KeyError:
|
|
741
|
+
raise OIDCExchangeError(
|
|
742
|
+
"access_token not found in Azure response",
|
|
743
|
+
cloud_provider="azure",
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# =============================================================================
|
|
748
|
+
# Vault Token Exchanger
|
|
749
|
+
# =============================================================================
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@dataclass
|
|
753
|
+
class VaultTokenExchangerConfig:
|
|
754
|
+
"""Configuration for HashiCorp Vault token exchange.
|
|
755
|
+
|
|
756
|
+
Attributes:
|
|
757
|
+
vault_url: Vault server URL.
|
|
758
|
+
jwt_auth_path: Path to JWT auth backend.
|
|
759
|
+
role: Vault role to use.
|
|
760
|
+
namespace: Vault namespace (Enterprise only).
|
|
761
|
+
request_timeout: HTTP request timeout.
|
|
762
|
+
"""
|
|
763
|
+
|
|
764
|
+
vault_url: str = ""
|
|
765
|
+
jwt_auth_path: str = "jwt"
|
|
766
|
+
role: str = ""
|
|
767
|
+
namespace: str = ""
|
|
768
|
+
request_timeout: float = 30.0
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@dataclass
|
|
772
|
+
class VaultCredentials(CloudCredentials):
|
|
773
|
+
"""HashiCorp Vault credentials.
|
|
774
|
+
|
|
775
|
+
Attributes:
|
|
776
|
+
client_token: Vault client token.
|
|
777
|
+
accessor: Token accessor.
|
|
778
|
+
policies: List of applied policies.
|
|
779
|
+
renewable: Whether token is renewable.
|
|
780
|
+
lease_duration: Token lease duration in seconds.
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
client_token: str = ""
|
|
784
|
+
accessor: str = ""
|
|
785
|
+
policies: list[str] = field(default_factory=list)
|
|
786
|
+
renewable: bool = False
|
|
787
|
+
lease_duration: int = 0
|
|
788
|
+
|
|
789
|
+
def __post_init__(self) -> None:
|
|
790
|
+
self.provider = CloudProvider.VAULT
|
|
791
|
+
|
|
792
|
+
def get_authorization_header(self) -> dict[str, str]:
|
|
793
|
+
"""Get HTTP authorization header for Vault."""
|
|
794
|
+
return {"X-Vault-Token": self.client_token}
|
|
795
|
+
|
|
796
|
+
def __repr__(self) -> str:
|
|
797
|
+
"""Safe representation."""
|
|
798
|
+
return (
|
|
799
|
+
f"VaultCredentials(accessor={self.accessor}, "
|
|
800
|
+
f"policies={self.policies})"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
class VaultTokenExchanger(BaseTokenExchanger):
|
|
805
|
+
"""HashiCorp Vault JWT auth token exchanger.
|
|
806
|
+
|
|
807
|
+
Exchanges an OIDC token for Vault client token using the
|
|
808
|
+
JWT authentication method.
|
|
809
|
+
|
|
810
|
+
Requirements:
|
|
811
|
+
- JWT auth backend enabled and configured
|
|
812
|
+
- Role configured with appropriate policies
|
|
813
|
+
|
|
814
|
+
Example:
|
|
815
|
+
>>> exchanger = VaultTokenExchanger(
|
|
816
|
+
... vault_url="https://vault.example.com",
|
|
817
|
+
... role="my-github-role",
|
|
818
|
+
... )
|
|
819
|
+
>>> credentials = exchanger.exchange(oidc_token)
|
|
820
|
+
>>> print(credentials.client_token)
|
|
821
|
+
|
|
822
|
+
Vault Configuration:
|
|
823
|
+
```bash
|
|
824
|
+
vault auth enable jwt
|
|
825
|
+
|
|
826
|
+
vault write auth/jwt/config \\
|
|
827
|
+
oidc_discovery_url="https://token.actions.githubusercontent.com" \\
|
|
828
|
+
bound_issuer="https://token.actions.githubusercontent.com"
|
|
829
|
+
|
|
830
|
+
vault write auth/jwt/role/my-github-role \\
|
|
831
|
+
role_type="jwt" \\
|
|
832
|
+
bound_audiences="https://vault.example.com" \\
|
|
833
|
+
bound_claims_type="glob" \\
|
|
834
|
+
bound_claims='{"repository":"owner/repo"}' \\
|
|
835
|
+
user_claim="repository" \\
|
|
836
|
+
policies="my-policy" \\
|
|
837
|
+
ttl="1h"
|
|
838
|
+
```
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
def __init__(
|
|
842
|
+
self,
|
|
843
|
+
vault_url: str | None = None,
|
|
844
|
+
role: str | None = None,
|
|
845
|
+
*,
|
|
846
|
+
jwt_auth_path: str = "jwt",
|
|
847
|
+
namespace: str | None = None,
|
|
848
|
+
config: VaultTokenExchangerConfig | None = None,
|
|
849
|
+
**kwargs: Any,
|
|
850
|
+
) -> None:
|
|
851
|
+
"""Initialize Vault token exchanger.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
vault_url: Vault server URL (or VAULT_ADDR env var).
|
|
855
|
+
role: Vault role name.
|
|
856
|
+
jwt_auth_path: Path to JWT auth backend.
|
|
857
|
+
namespace: Vault namespace.
|
|
858
|
+
config: Full configuration object.
|
|
859
|
+
**kwargs: Additional base class arguments.
|
|
860
|
+
"""
|
|
861
|
+
self._config = config or VaultTokenExchangerConfig()
|
|
862
|
+
|
|
863
|
+
# Override with explicit parameters or env vars
|
|
864
|
+
if vault_url:
|
|
865
|
+
self._config.vault_url = vault_url
|
|
866
|
+
elif not self._config.vault_url:
|
|
867
|
+
self._config.vault_url = os.environ.get("VAULT_ADDR", "")
|
|
868
|
+
|
|
869
|
+
if role:
|
|
870
|
+
self._config.role = role
|
|
871
|
+
|
|
872
|
+
self._config.jwt_auth_path = jwt_auth_path
|
|
873
|
+
|
|
874
|
+
if namespace:
|
|
875
|
+
self._config.namespace = namespace
|
|
876
|
+
elif not self._config.namespace:
|
|
877
|
+
self._config.namespace = os.environ.get("VAULT_NAMESPACE", "")
|
|
878
|
+
|
|
879
|
+
super().__init__(**kwargs)
|
|
880
|
+
|
|
881
|
+
@property
|
|
882
|
+
def cloud_provider(self) -> CloudProvider:
|
|
883
|
+
return CloudProvider.VAULT
|
|
884
|
+
|
|
885
|
+
def _exchange(self, token: OIDCToken) -> VaultCredentials:
|
|
886
|
+
"""Exchange OIDC token for Vault credentials.
|
|
887
|
+
|
|
888
|
+
Args:
|
|
889
|
+
token: OIDC token to exchange.
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
Vault credentials.
|
|
893
|
+
|
|
894
|
+
Raises:
|
|
895
|
+
OIDCExchangeError: If exchange fails.
|
|
896
|
+
"""
|
|
897
|
+
if not self._config.vault_url or not self._config.role:
|
|
898
|
+
raise OIDCExchangeError(
|
|
899
|
+
"vault_url and role are required",
|
|
900
|
+
cloud_provider="vault",
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
url = (
|
|
904
|
+
f"{self._config.vault_url.rstrip('/')}/v1/auth/"
|
|
905
|
+
f"{self._config.jwt_auth_path}/login"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
request_body = {
|
|
909
|
+
"jwt": token.get_token(),
|
|
910
|
+
"role": self._config.role,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
headers = {"Content-Type": "application/json"}
|
|
914
|
+
if self._config.namespace:
|
|
915
|
+
headers["X-Vault-Namespace"] = self._config.namespace
|
|
916
|
+
|
|
917
|
+
request = urllib.request.Request(
|
|
918
|
+
url,
|
|
919
|
+
data=json.dumps(request_body).encode(),
|
|
920
|
+
headers=headers,
|
|
921
|
+
method="POST",
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
try:
|
|
925
|
+
with urllib.request.urlopen(
|
|
926
|
+
request, timeout=self._config.request_timeout
|
|
927
|
+
) as response:
|
|
928
|
+
data = json.loads(response.read())
|
|
929
|
+
auth = data.get("auth", {})
|
|
930
|
+
|
|
931
|
+
# Calculate expiration time
|
|
932
|
+
expires_at = None
|
|
933
|
+
lease_duration = auth.get("lease_duration", 0)
|
|
934
|
+
if lease_duration:
|
|
935
|
+
expires_at = datetime.now() + timedelta(seconds=lease_duration)
|
|
936
|
+
|
|
937
|
+
return VaultCredentials(
|
|
938
|
+
client_token=auth.get("client_token", ""),
|
|
939
|
+
accessor=auth.get("accessor", ""),
|
|
940
|
+
policies=auth.get("policies", []),
|
|
941
|
+
renewable=auth.get("renewable", False),
|
|
942
|
+
lease_duration=lease_duration,
|
|
943
|
+
expires_at=expires_at,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
except urllib.error.HTTPError as e:
|
|
947
|
+
error_body = ""
|
|
948
|
+
try:
|
|
949
|
+
error_body = e.read().decode()
|
|
950
|
+
except Exception:
|
|
951
|
+
pass
|
|
952
|
+
raise OIDCExchangeError(
|
|
953
|
+
f"Vault JWT login failed: {error_body or e.reason}",
|
|
954
|
+
cloud_provider="vault",
|
|
955
|
+
status_code=e.code,
|
|
956
|
+
response=error_body,
|
|
957
|
+
) from e
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
# =============================================================================
|
|
961
|
+
# Factory Function
|
|
962
|
+
# =============================================================================
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def create_token_exchanger(
|
|
966
|
+
cloud_provider: CloudProvider | str,
|
|
967
|
+
**kwargs: Any,
|
|
968
|
+
) -> BaseTokenExchanger:
|
|
969
|
+
"""Create a token exchanger for the specified cloud provider.
|
|
970
|
+
|
|
971
|
+
Args:
|
|
972
|
+
cloud_provider: Target cloud provider.
|
|
973
|
+
**kwargs: Provider-specific configuration.
|
|
974
|
+
|
|
975
|
+
Returns:
|
|
976
|
+
Token exchanger instance.
|
|
977
|
+
|
|
978
|
+
Raises:
|
|
979
|
+
ValueError: If cloud provider is not supported.
|
|
980
|
+
|
|
981
|
+
Example:
|
|
982
|
+
>>> exchanger = create_token_exchanger(
|
|
983
|
+
... "aws",
|
|
984
|
+
... role_arn="arn:aws:iam::123456789012:role/my-role",
|
|
985
|
+
... )
|
|
986
|
+
"""
|
|
987
|
+
if isinstance(cloud_provider, str):
|
|
988
|
+
cloud_provider = CloudProvider(cloud_provider.lower())
|
|
989
|
+
|
|
990
|
+
exchangers = {
|
|
991
|
+
CloudProvider.AWS: AWSTokenExchanger,
|
|
992
|
+
CloudProvider.GCP: GCPTokenExchanger,
|
|
993
|
+
CloudProvider.AZURE: AzureTokenExchanger,
|
|
994
|
+
CloudProvider.VAULT: VaultTokenExchanger,
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
exchanger_cls = exchangers.get(cloud_provider)
|
|
998
|
+
if exchanger_cls is None:
|
|
999
|
+
raise ValueError(f"Unsupported cloud provider: {cloud_provider}")
|
|
1000
|
+
|
|
1001
|
+
return exchanger_cls(**kwargs)
|