classifyre-cli 0.4.46__tar.gz → 0.4.48__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.
- classifyre_cli-0.4.48/.turbo/turbo-build.log +3 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/PKG-INFO +1 -1
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/package.json +1 -1
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/pyproject.toml +1 -1
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/azure_blob_storage/source.py +18 -2
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/google_cloud_storage/source.py +20 -2
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/kafka/source.py +61 -3
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/object_storage/base.py +14 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/s3_compatible_storage/source.py +14 -1
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/slack/source.py +2 -3
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/youtube/source.py +2 -2
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_azure_blob_storage_source.py +71 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_google_cloud_storage_source.py +65 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_kafka_source.py +108 -10
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_s3_compatible_storage_source.py +65 -0
- classifyre_cli-0.4.48/tests/test_source_strategy_conformance.py +235 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/uv.lock +11 -8
- classifyre_cli-0.4.46/.turbo/turbo-build.log +0 -3
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/.gitignore +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/.python-version +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/README.md +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/main.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/scripts/generate_models.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/config.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/broken_links/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/broken_links/detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/config.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/extractor.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_factory.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_feature_extraction.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_gliner2.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_image_classification.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_llm.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_object_detection.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_regex.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/runners/_text_classification.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/custom/trainer.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/dependencies.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/pii/detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/secrets/detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/threat/code_security_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/detectors/threat/yara_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/main.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/models/generated_detectors.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/models/generated_input.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/models/generated_single_asset_scan_results.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/outputs/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/outputs/base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/outputs/console.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/outputs/factory.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/outputs/file.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/outputs/rest.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/pipeline/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/pipeline/content_provider.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/pipeline/detector_pipeline.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/pipeline/parsed_content_provider.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/pipeline/worker_pool.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sandbox/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sandbox/runner.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/asset_metadata.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/atlassian_common.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/azure_blob_storage/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/confluence/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/confluence/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/databricks/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/databricks/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/delta_lake/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/delta_lake/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/dependencies.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/elasticsearch/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/elasticsearch/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/email/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/email/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/google_cloud_storage/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/hive/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/hive/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/iceberg/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/iceberg/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/jira/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/jira/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/kafka/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/lakehouse_base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/local_folder/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/local_folder/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/meilisearch/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/meilisearch/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/mongodb/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/mongodb/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/mssql/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/mssql/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/mysql/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/mysql/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/neo4j/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/neo4j/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/notion/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/notion/client.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/notion/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/opensearch/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/opensearch/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/oracle/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/oracle/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/postgresql/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/postgresql/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/powerbi/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/powerbi/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/s3_client.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/s3_compatible_storage/README.md +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/s3_compatible_storage/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/search_engine_base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/servicedesk/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/servicedesk/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/slack/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/snowflake/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/snowflake/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/sqlite/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/sqlite/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/tableau/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/tableau/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/tabular_base.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/tabular_utils.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/wordpress/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/wordpress/source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/sources/youtube/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/telemetry.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/content_extraction.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/dependency_groups.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/embedded_images.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/file_metadata.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/file_parser.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/file_to_images.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/hashing.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/resources.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/transcription.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/uv_sync.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/src/utils/validation.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/_lakehouse_fakes.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/conftest.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/conftest.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/conftest.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/test_invoice_extraction.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/test_llm_runner.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/test_pipeline_integration.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/test_regex_runner.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/custom/test_transformer_runners.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/pii/conftest.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/pii/sample_invoice.pdf +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/pii/test_pii_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/secrets/test_secrets_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_base_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_detector_catalog_commercial.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_detector_pipeline_types.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_detector_schema_examples.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_detector_types.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_phase2_detectors.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/test_registry.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/threat/test_code_security_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/detectors/threat/test_yara_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/integration/test_wordpress_links_assets.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/pipeline/test_detector_pipeline.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/pipeline/test_worker_pool.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_assets_metadata_catalog.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_base_source_attachment.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_base_source_sampling.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_config.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_confluence_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_custom_extractor.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_databricks_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_delta_lake_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_dependency_groups.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_elasticsearch_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_email_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_hashing.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_hive_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_iceberg_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_jira_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_local_folder_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_meilisearch_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_mongodb_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_mssql_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_mysql_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_neo4j_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_notion_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_opensearch_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_oracle_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_outputs.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_postgresql_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_powerbi_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_sampling_automatic.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_sandbox_runner.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_servicedesk_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_slack_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_snowflake_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_source_dependency_groups.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_sqlite_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_tableau_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_tabular_automatic_sampling.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_tabular_utils.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_uv_sync.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_wordpress_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_youtube_source.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/test_youtube_source_integration.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/utils/test_content_extraction.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/utils/test_embedded_images.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/utils/test_file_metadata.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/utils/test_file_parser.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/utils/test_file_to_images.py +0 -0
- {classifyre_cli-0.4.46 → classifyre_cli-0.4.48}/tests/utils/test_transcription.py +0 -0
|
@@ -111,12 +111,28 @@ class AzureBlobStorageSource(ObjectStorageSourceBase):
|
|
|
111
111
|
|
|
112
112
|
def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
|
|
113
113
|
blob_service_client = self._client()
|
|
114
|
-
|
|
114
|
+
container = self._required_container()
|
|
115
|
+
container_client = blob_service_client.get_container_client(container)
|
|
115
116
|
blob_client = container_client.get_blob_client(ref.key)
|
|
116
117
|
|
|
117
118
|
timeout = self._request_timeout_seconds()
|
|
118
|
-
|
|
119
|
+
max_bytes = self._max_object_bytes()
|
|
120
|
+
# Ranged download: fetch only the capped prefix instead of the whole blob.
|
|
121
|
+
downloader = blob_client.download_blob(offset=0, length=max_bytes, timeout=timeout)
|
|
119
122
|
file_bytes = downloader.readall()
|
|
123
|
+
|
|
124
|
+
if len(file_bytes) > max_bytes:
|
|
125
|
+
file_bytes = file_bytes[:max_bytes]
|
|
126
|
+
if ref.size > max_bytes:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Truncated %s/%s/%s to %d of %d bytes for content extraction",
|
|
129
|
+
self._required_account_url(),
|
|
130
|
+
container,
|
|
131
|
+
ref.key,
|
|
132
|
+
max_bytes,
|
|
133
|
+
ref.size,
|
|
134
|
+
)
|
|
135
|
+
|
|
120
136
|
return file_bytes, ref.content_type_hint
|
|
121
137
|
|
|
122
138
|
def _external_url(self, key: str) -> str:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import logging
|
|
4
5
|
from collections.abc import Iterator
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -8,6 +9,8 @@ from ...models.generated_input import GoogleCloudStorageInput
|
|
|
8
9
|
from ..dependencies import require_module
|
|
9
10
|
from ..object_storage.base import ObjectRef, ObjectStorageSourceBase
|
|
10
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
class GoogleCloudStorageSource(ObjectStorageSourceBase):
|
|
13
16
|
source_type = "google_cloud_storage"
|
|
@@ -97,11 +100,26 @@ class GoogleCloudStorageSource(ObjectStorageSourceBase):
|
|
|
97
100
|
|
|
98
101
|
def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
|
|
99
102
|
client = self._client()
|
|
100
|
-
|
|
103
|
+
bucket_name = self._required_bucket()
|
|
104
|
+
bucket = client.bucket(bucket_name)
|
|
101
105
|
blob = bucket.blob(ref.key)
|
|
102
106
|
|
|
103
107
|
timeout = self._request_timeout_seconds()
|
|
104
|
-
|
|
108
|
+
max_bytes = self._max_object_bytes()
|
|
109
|
+
# Ranged download: fetch only the capped prefix instead of the whole blob.
|
|
110
|
+
file_bytes = blob.download_as_bytes(start=0, end=max_bytes - 1, timeout=timeout)
|
|
111
|
+
|
|
112
|
+
if len(file_bytes) > max_bytes:
|
|
113
|
+
file_bytes = file_bytes[:max_bytes]
|
|
114
|
+
if ref.size > max_bytes:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"Truncated gs://%s/%s to %d of %d bytes for content extraction",
|
|
117
|
+
bucket_name,
|
|
118
|
+
ref.key,
|
|
119
|
+
max_bytes,
|
|
120
|
+
ref.size,
|
|
121
|
+
)
|
|
122
|
+
|
|
105
123
|
return file_bytes, ref.content_type_hint
|
|
106
124
|
|
|
107
125
|
def _external_url(self, key: str) -> str:
|
|
@@ -8,7 +8,11 @@ Sampling strategies map to consumer positioning:
|
|
|
8
8
|
|
|
9
9
|
* ``LATEST`` — start near the tail of each partition (newest messages).
|
|
10
10
|
* ``RANDOM`` — start at a random offset within each partition.
|
|
11
|
-
* ``
|
|
11
|
+
* ``ALL`` — start at the earliest retained offset.
|
|
12
|
+
* ``AUTOMATIC`` — resume each partition from a saved per-partition cursor
|
|
13
|
+
(keyed ``"{topic}:{partition}"``), advancing it each run. Once a
|
|
14
|
+
partition's cursor catches up to the high watermark it wraps back to the
|
|
15
|
+
low watermark so the next run re-ingests from the start.
|
|
12
16
|
|
|
13
17
|
All strategies read up to ``sampling.rows_per_page`` messages per topic.
|
|
14
18
|
"""
|
|
@@ -302,14 +306,56 @@ class KafkaSource(BaseSource):
|
|
|
302
306
|
return max(low, high - per)
|
|
303
307
|
if strategy == SamplingStrategy.RANDOM:
|
|
304
308
|
return random.randint(low, max(low, high - per))
|
|
305
|
-
#
|
|
309
|
+
# ALL: read from the earliest retained offset.
|
|
306
310
|
return low
|
|
307
311
|
|
|
312
|
+
def _automatic_start_offset(self, key: str, low: int, high: int) -> int:
|
|
313
|
+
"""Resume offset for AUTOMATIC sampling, clamped to the retained range.
|
|
314
|
+
|
|
315
|
+
Retention may have deleted the previously saved offset (or this may be
|
|
316
|
+
the first run), in which case we fall back to the earliest retained
|
|
317
|
+
offset.
|
|
318
|
+
"""
|
|
319
|
+
saved = self.automatic_offset(key)
|
|
320
|
+
if saved < low or saved > high:
|
|
321
|
+
return low
|
|
322
|
+
return saved
|
|
323
|
+
|
|
324
|
+
def _record_automatic_cursors(
|
|
325
|
+
self,
|
|
326
|
+
topic: str,
|
|
327
|
+
messages: list[dict[str, Any]],
|
|
328
|
+
starts: dict[int, int],
|
|
329
|
+
watermarks: dict[int, tuple[int, int]],
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Advance the per-partition AUTOMATIC cursor after a consume pass.
|
|
332
|
+
|
|
333
|
+
Partitions that yielded messages resume from one past the highest
|
|
334
|
+
offset consumed; partitions that were assigned but yielded nothing
|
|
335
|
+
keep their (already-clamped) start offset. A partition that has
|
|
336
|
+
caught up to its high watermark wraps back to the low watermark so
|
|
337
|
+
the next run re-ingests from the start instead of stalling forever.
|
|
338
|
+
"""
|
|
339
|
+
consumed_next: dict[int, int] = {}
|
|
340
|
+
for message in messages:
|
|
341
|
+
partition_id = int(message["partition"])
|
|
342
|
+
next_offset = int(message["offset"]) + 1
|
|
343
|
+
if next_offset > consumed_next.get(partition_id, -1):
|
|
344
|
+
consumed_next[partition_id] = next_offset
|
|
345
|
+
|
|
346
|
+
for partition_id, (low, high) in watermarks.items():
|
|
347
|
+
next_offset = consumed_next.get(partition_id, starts[partition_id])
|
|
348
|
+
if next_offset >= high:
|
|
349
|
+
next_offset = low # fully caught up: wrap for the next run
|
|
350
|
+
self._record_cursor_key(f"{topic}:{partition_id}", next_offset)
|
|
351
|
+
|
|
308
352
|
def _consume(self, topic: str, max_count: int) -> list[dict[str, Any]]:
|
|
309
353
|
strategy = self._sampling().strategy
|
|
310
354
|
consumer = self._make_consumer()
|
|
311
355
|
timeout = self._request_timeout_seconds()
|
|
312
356
|
out: list[dict[str, Any]] = []
|
|
357
|
+
starts: dict[int, int] = {}
|
|
358
|
+
watermarks: dict[int, tuple[int, int]] = {}
|
|
313
359
|
try:
|
|
314
360
|
metadata = self._cluster_metadata(consumer, topic)
|
|
315
361
|
topic_meta = metadata.topics.get(topic)
|
|
@@ -325,9 +371,17 @@ class KafkaSource(BaseSource):
|
|
|
325
371
|
except Exception as exc:
|
|
326
372
|
logger.debug("Watermark lookup failed for %s[%s]: %s", topic, partition_id, exc)
|
|
327
373
|
continue
|
|
374
|
+
low, high = int(low), int(high)
|
|
328
375
|
if high <= low:
|
|
329
376
|
continue # empty partition
|
|
330
|
-
|
|
377
|
+
if strategy == SamplingStrategy.AUTOMATIC:
|
|
378
|
+
key = f"{topic}:{partition_id}"
|
|
379
|
+
start = self._automatic_start_offset(key, low, high)
|
|
380
|
+
starts[partition_id] = start
|
|
381
|
+
watermarks[partition_id] = (low, high)
|
|
382
|
+
else:
|
|
383
|
+
start = self._start_offset(strategy, low, high, per)
|
|
384
|
+
tp.offset = start
|
|
331
385
|
assignments.append(tp)
|
|
332
386
|
if not assignments:
|
|
333
387
|
return out
|
|
@@ -357,6 +411,10 @@ class KafkaSource(BaseSource):
|
|
|
357
411
|
break
|
|
358
412
|
finally:
|
|
359
413
|
consumer.close()
|
|
414
|
+
|
|
415
|
+
if strategy == SamplingStrategy.AUTOMATIC and watermarks:
|
|
416
|
+
self._record_automatic_cursors(topic, out, starts, watermarks)
|
|
417
|
+
|
|
360
418
|
return out
|
|
361
419
|
|
|
362
420
|
def _format_messages(
|
|
@@ -211,6 +211,20 @@ class ObjectStorageSourceBase(BaseSource, ABC):
|
|
|
211
211
|
def _include_content_preview(self) -> bool:
|
|
212
212
|
return bool(self._scope_option("include_content_preview", True))
|
|
213
213
|
|
|
214
|
+
# Shared cap across the object-storage family (S3-compatible, GCS, Azure Blob)
|
|
215
|
+
# so a single huge object can't OOM the scan pod. Mirrors local_folder's
|
|
216
|
+
# DEFAULT_MAX_FILE_BYTES guard, but sourced from each provider's own
|
|
217
|
+
# optional.connection.max_object_bytes field (schema default 5MB) when set.
|
|
218
|
+
DEFAULT_MAX_OBJECT_BYTES = 5 * 1024 * 1024
|
|
219
|
+
|
|
220
|
+
def _max_object_bytes(self) -> int:
|
|
221
|
+
value = self._connection_option("max_object_bytes", self.DEFAULT_MAX_OBJECT_BYTES)
|
|
222
|
+
try:
|
|
223
|
+
parsed = int(value)
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
return self.DEFAULT_MAX_OBJECT_BYTES
|
|
226
|
+
return max(parsed, 1024)
|
|
227
|
+
|
|
214
228
|
def _normalized_extension_filters(self, key: str) -> list[str]:
|
|
215
229
|
values = self._scope_option(key, [])
|
|
216
230
|
if not isinstance(values, list):
|
|
@@ -87,17 +87,30 @@ class S3CompatibleStorageSource(ObjectStorageSourceBase):
|
|
|
87
87
|
def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
|
|
88
88
|
client = self._client()
|
|
89
89
|
bucket = self._required_bucket()
|
|
90
|
+
max_bytes = self._max_object_bytes()
|
|
90
91
|
|
|
91
92
|
response = client.get_object(Bucket=bucket, Key=ref.key)
|
|
92
93
|
body = response["Body"]
|
|
93
94
|
try:
|
|
94
|
-
|
|
95
|
+
# Read one byte past the cap so we can detect truncation without
|
|
96
|
+
# ever materializing the full (potentially huge) object body.
|
|
97
|
+
file_bytes = body.read(max_bytes + 1)
|
|
95
98
|
finally:
|
|
96
99
|
try:
|
|
97
100
|
body.close()
|
|
98
101
|
except Exception:
|
|
99
102
|
logger.debug("Failed to close S3 response body")
|
|
100
103
|
|
|
104
|
+
if len(file_bytes) > max_bytes:
|
|
105
|
+
file_bytes = file_bytes[:max_bytes]
|
|
106
|
+
logger.warning(
|
|
107
|
+
"Truncated s3://%s/%s to %d of %d bytes for content extraction",
|
|
108
|
+
bucket,
|
|
109
|
+
ref.key,
|
|
110
|
+
max_bytes,
|
|
111
|
+
ref.size,
|
|
112
|
+
)
|
|
113
|
+
|
|
101
114
|
content_type = response.get("ContentType")
|
|
102
115
|
return file_bytes, str(content_type) if content_type else None
|
|
103
116
|
|
|
@@ -322,10 +322,9 @@ class SlackSource(BaseSource):
|
|
|
322
322
|
is_automatic = strategy == SamplingStrategy.AUTOMATIC
|
|
323
323
|
if strategy == SamplingStrategy.ALL:
|
|
324
324
|
max_total: int | None = None
|
|
325
|
-
elif is_automatic:
|
|
326
|
-
max_total = int(sampling.rows_per_page or 100)
|
|
327
325
|
else:
|
|
328
|
-
|
|
326
|
+
# AUTOMATIC / LATEST / RANDOM all honor the configured window size.
|
|
327
|
+
max_total = int(sampling.rows_per_page or 100)
|
|
329
328
|
oldest = self._normalize_ts(time_range_options.oldest)
|
|
330
329
|
latest = self._normalize_ts(time_range_options.latest)
|
|
331
330
|
batch_size = min(int(ingestion_options.batch_size or 200), 200)
|
|
@@ -461,11 +461,11 @@ class YouTubeSource(BaseSource):
|
|
|
461
461
|
if transcript.available_languages:
|
|
462
462
|
asset_metadata["caption_tracks"] = transcript.available_languages
|
|
463
463
|
|
|
464
|
+
# Checksum must only reflect content changes: view/like counters move on
|
|
465
|
+
# every re-scan and would flip UNCHANGED assets to UPDATED spuriously.
|
|
464
466
|
checksum_data = {
|
|
465
467
|
"video_id": video_id,
|
|
466
468
|
"title": title,
|
|
467
|
-
"view_count": asset_metadata.get("view_count"),
|
|
468
|
-
"like_count": asset_metadata.get("like_count"),
|
|
469
469
|
"transcript_available": asset_metadata["transcript_available"],
|
|
470
470
|
}
|
|
471
471
|
|
|
@@ -80,3 +80,74 @@ def test_azure_blob_storage_external_url():
|
|
|
80
80
|
source._external_url("folder/report.csv")
|
|
81
81
|
== "https://acme.blob.core.windows.net/documents/folder/report.csv"
|
|
82
82
|
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _FakeDownloader:
|
|
86
|
+
def __init__(self, data: bytes) -> None:
|
|
87
|
+
self._data = data
|
|
88
|
+
|
|
89
|
+
def readall(self) -> bytes:
|
|
90
|
+
return self._data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _FakeBlobClient:
|
|
94
|
+
def __init__(self, data: bytes) -> None:
|
|
95
|
+
self._data = data
|
|
96
|
+
|
|
97
|
+
def download_blob(
|
|
98
|
+
self, offset: int = 0, length: int | None = None, timeout: float | None = None
|
|
99
|
+
) -> _FakeDownloader:
|
|
100
|
+
start = offset or 0
|
|
101
|
+
end = len(self._data) if length is None else start + length
|
|
102
|
+
return _FakeDownloader(self._data[start:end])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _FakeContainerClient:
|
|
106
|
+
def __init__(self, data: bytes) -> None:
|
|
107
|
+
self._data = data
|
|
108
|
+
|
|
109
|
+
def get_blob_client(self, key: str) -> _FakeBlobClient:
|
|
110
|
+
return _FakeBlobClient(self._data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _FakeAzureClient:
|
|
114
|
+
def __init__(self, data: bytes) -> None:
|
|
115
|
+
self._data = data
|
|
116
|
+
|
|
117
|
+
def get_container_client(self, container: str) -> _FakeContainerClient:
|
|
118
|
+
return _FakeContainerClient(self._data)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_azure_blob_storage_download_object_truncates_oversized_blob(monkeypatch, caplog):
|
|
122
|
+
source = AzureBlobStorageSource(
|
|
123
|
+
{
|
|
124
|
+
**_recipe(),
|
|
125
|
+
"optional": {
|
|
126
|
+
"connection": {"max_object_bytes": 1024},
|
|
127
|
+
"scope": {"include_content_preview": True},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
big_bytes = b"z" * 3000
|
|
132
|
+
monkeypatch.setattr(source, "_client", lambda: _FakeAzureClient(big_bytes))
|
|
133
|
+
ref = _ref("exports/big.bin", size=len(big_bytes))
|
|
134
|
+
|
|
135
|
+
with caplog.at_level("WARNING"):
|
|
136
|
+
file_bytes, _content_type = source._download_object(ref)
|
|
137
|
+
|
|
138
|
+
assert len(file_bytes) == 1024
|
|
139
|
+
assert any("Truncated" in record.message for record in caplog.records)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_azure_blob_storage_download_object_leaves_normal_blob_unchanged(monkeypatch, caplog):
|
|
143
|
+
source = AzureBlobStorageSource(_recipe())
|
|
144
|
+
small_bytes = b"hello azure"
|
|
145
|
+
monkeypatch.setattr(source, "_client", lambda: _FakeAzureClient(small_bytes))
|
|
146
|
+
ref = _ref("exports/small.txt", size=len(small_bytes))
|
|
147
|
+
|
|
148
|
+
with caplog.at_level("WARNING"):
|
|
149
|
+
file_bytes, content_type = source._download_object(ref)
|
|
150
|
+
|
|
151
|
+
assert file_bytes == small_bytes
|
|
152
|
+
assert content_type == "text/plain"
|
|
153
|
+
assert not any("Truncated" in record.message for record in caplog.records)
|
|
@@ -71,3 +71,68 @@ async def test_google_cloud_storage_extract_uses_all_sampling(monkeypatch):
|
|
|
71
71
|
assert [asset.name for asset in assets] == ["new.txt", "data.parquet"]
|
|
72
72
|
assert assets[0].asset_type == OutputAssetType.TXT
|
|
73
73
|
assert assets[1].asset_type == OutputAssetType.TABLE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _FakeBlob:
|
|
77
|
+
def __init__(self, data: bytes) -> None:
|
|
78
|
+
self._data = data
|
|
79
|
+
|
|
80
|
+
def download_as_bytes(
|
|
81
|
+
self, start: int | None = None, end: int | None = None, timeout: float | None = None
|
|
82
|
+
) -> bytes:
|
|
83
|
+
if start is None and end is None:
|
|
84
|
+
return self._data
|
|
85
|
+
range_start = start or 0
|
|
86
|
+
range_end = len(self._data) - 1 if end is None else end
|
|
87
|
+
return self._data[range_start : range_end + 1]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _FakeBucket:
|
|
91
|
+
def __init__(self, data: bytes) -> None:
|
|
92
|
+
self._data = data
|
|
93
|
+
|
|
94
|
+
def blob(self, key: str) -> _FakeBlob:
|
|
95
|
+
return _FakeBlob(self._data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _FakeGcsClient:
|
|
99
|
+
def __init__(self, data: bytes) -> None:
|
|
100
|
+
self._data = data
|
|
101
|
+
|
|
102
|
+
def bucket(self, name: str) -> _FakeBucket:
|
|
103
|
+
return _FakeBucket(self._data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_google_cloud_storage_download_object_truncates_oversized_blob(monkeypatch, caplog):
|
|
107
|
+
source = GoogleCloudStorageSource(
|
|
108
|
+
{
|
|
109
|
+
**_recipe(),
|
|
110
|
+
"optional": {
|
|
111
|
+
"connection": {"max_object_bytes": 1024},
|
|
112
|
+
"scope": {"include_content_preview": True},
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
big_bytes = b"y" * 3000
|
|
117
|
+
monkeypatch.setattr(source, "_client", lambda: _FakeGcsClient(big_bytes))
|
|
118
|
+
ref = _ref("exports/big.bin", days_ago=0, size=len(big_bytes))
|
|
119
|
+
|
|
120
|
+
with caplog.at_level("WARNING"):
|
|
121
|
+
file_bytes, _content_type = source._download_object(ref)
|
|
122
|
+
|
|
123
|
+
assert len(file_bytes) == 1024
|
|
124
|
+
assert any("Truncated" in record.message for record in caplog.records)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_google_cloud_storage_download_object_leaves_normal_blob_unchanged(monkeypatch, caplog):
|
|
128
|
+
source = GoogleCloudStorageSource(_recipe())
|
|
129
|
+
small_bytes = b"hello gcs"
|
|
130
|
+
monkeypatch.setattr(source, "_client", lambda: _FakeGcsClient(small_bytes))
|
|
131
|
+
ref = _ref("exports/small.txt", days_ago=0, size=len(small_bytes))
|
|
132
|
+
|
|
133
|
+
with caplog.at_level("WARNING"):
|
|
134
|
+
file_bytes, content_type = source._download_object(ref)
|
|
135
|
+
|
|
136
|
+
assert file_bytes == small_bytes
|
|
137
|
+
assert content_type == "text/plain"
|
|
138
|
+
assert not any("Truncated" in record.message for record in caplog.records)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
3
5
|
from typing import Any
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
@@ -7,6 +9,12 @@ import pytest
|
|
|
7
9
|
from src.models.generated_input import SamplingStrategy
|
|
8
10
|
from src.sources.kafka.source import KafkaSource
|
|
9
11
|
|
|
12
|
+
CURSOR_ENV = "CLASSIFYRE_SAMPLING_CURSOR"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _encode_cursor(cursor: dict[str, Any]) -> str:
|
|
16
|
+
return base64.b64encode(json.dumps(cursor).encode()).decode()
|
|
17
|
+
|
|
10
18
|
# ── confluent-kafka fakes ────────────────────────────────────────────────
|
|
11
19
|
|
|
12
20
|
|
|
@@ -64,25 +72,33 @@ class _FakeConsumer:
|
|
|
64
72
|
self._module = module
|
|
65
73
|
self.conf = conf
|
|
66
74
|
self.assigned: list[_TopicPartition] = []
|
|
67
|
-
self.
|
|
75
|
+
self._assigned_offsets: dict[int, int] = {}
|
|
76
|
+
self._pointer = 0
|
|
68
77
|
|
|
69
78
|
def list_topics(self, topic: str | None = None, timeout: float = 0) -> _ClusterMetadata:
|
|
70
79
|
if topic is not None:
|
|
71
80
|
return _ClusterMetadata([topic] if topic in self._module.topic_names else [])
|
|
72
81
|
return _ClusterMetadata(self._module.topic_names)
|
|
73
82
|
|
|
74
|
-
def get_watermark_offsets(self,
|
|
75
|
-
return (0, 100)
|
|
83
|
+
def get_watermark_offsets(self, tp: _TopicPartition, timeout: float = 0) -> tuple[int, int]:
|
|
84
|
+
return self._module.watermarks.get((tp.topic, tp.partition), (0, 100))
|
|
76
85
|
|
|
77
86
|
def assign(self, tps: list[_TopicPartition]) -> None:
|
|
78
87
|
self.assigned = tps
|
|
79
88
|
self._module.last_assigned = tps
|
|
89
|
+
self._assigned_offsets = {tp.partition: tp.offset for tp in tps}
|
|
80
90
|
|
|
81
91
|
def consume(self, num_messages: int, timeout: float = 0) -> list[_FakeMessage]:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
assigned_partitions = set(self._assigned_offsets)
|
|
93
|
+
available = [
|
|
94
|
+
m
|
|
95
|
+
for m in self._module.messages
|
|
96
|
+
if m.partition() in assigned_partitions
|
|
97
|
+
and m.offset() >= self._assigned_offsets[m.partition()]
|
|
98
|
+
]
|
|
99
|
+
batch = available[self._pointer : self._pointer + num_messages]
|
|
100
|
+
self._pointer += len(batch)
|
|
101
|
+
return batch
|
|
86
102
|
|
|
87
103
|
def close(self) -> None:
|
|
88
104
|
pass
|
|
@@ -126,6 +142,8 @@ class _FakeKafkaModule:
|
|
|
126
142
|
self.ConfigResource = _ConfigResource
|
|
127
143
|
self.last_consumer_conf: dict[str, Any] | None = None
|
|
128
144
|
self.last_assigned: list[_TopicPartition] = []
|
|
145
|
+
# Optional per-(topic, partition) watermark override; defaults to (0, 100).
|
|
146
|
+
self.watermarks: dict[tuple[str, int], tuple[int, int]] = {}
|
|
129
147
|
|
|
130
148
|
def Consumer(self, conf: dict[str, Any]) -> _FakeConsumer: # noqa: N802
|
|
131
149
|
self.last_consumer_conf = conf
|
|
@@ -146,8 +164,11 @@ def _recipe(**overrides: Any) -> dict[str, Any]:
|
|
|
146
164
|
return base
|
|
147
165
|
|
|
148
166
|
|
|
149
|
-
def _messages(count: int) -> list[_FakeMessage]:
|
|
150
|
-
return [
|
|
167
|
+
def _messages(count: int, *, start: int = 0, partition: int = 0) -> list[_FakeMessage]:
|
|
168
|
+
return [
|
|
169
|
+
_FakeMessage(partition, start + i, f"k{start + i}".encode(), f"value-{start + i}".encode())
|
|
170
|
+
for i in range(count)
|
|
171
|
+
]
|
|
151
172
|
|
|
152
173
|
|
|
153
174
|
@pytest.fixture
|
|
@@ -186,7 +207,13 @@ async def test_kafka_extract_emits_topic_assets(_patch_kafka: _FakeKafkaModule)
|
|
|
186
207
|
assert meta["cleanup_policy"] == "delete"
|
|
187
208
|
|
|
188
209
|
|
|
189
|
-
async def test_kafka_fetch_content_samples_messages(
|
|
210
|
+
async def test_kafka_fetch_content_samples_messages(
|
|
211
|
+
_patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
|
|
212
|
+
) -> None:
|
|
213
|
+
# Keep the random start offset within the range of fake messages available (0-11),
|
|
214
|
+
# and pin it deterministically so the sampled window is known.
|
|
215
|
+
_patch_kafka.watermarks[("payments", 0)] = (0, 12)
|
|
216
|
+
monkeypatch.setattr("src.sources.kafka.source.random.randint", lambda _a, _b: 0)
|
|
190
217
|
src = KafkaSource(_recipe(sampling={"strategy": "RANDOM", "rows_per_page": 10}))
|
|
191
218
|
assets = [a async for batch in src.extract_raw() for a in batch]
|
|
192
219
|
result = await src.fetch_content(assets[0].hash)
|
|
@@ -249,3 +276,74 @@ def test_kafka_latest_strategy_assigns_tail_offsets(_patch_kafka: _FakeKafkaModu
|
|
|
249
276
|
src._consume("payments", 10)
|
|
250
277
|
assert len(module.last_assigned) == 1
|
|
251
278
|
assert module.last_assigned[0].offset == 90
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── AUTOMATIC sampling ───────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_kafka_automatic_first_run_starts_at_low_and_records_cursor(
|
|
285
|
+
_patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
|
|
286
|
+
) -> None:
|
|
287
|
+
monkeypatch.delenv(CURSOR_ENV, raising=False)
|
|
288
|
+
module = _patch_kafka
|
|
289
|
+
module.watermarks[("payments", 0)] = (0, 100)
|
|
290
|
+
module.messages = _messages(12, start=0)
|
|
291
|
+
|
|
292
|
+
src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
|
|
293
|
+
out = src._consume("payments", 5)
|
|
294
|
+
|
|
295
|
+
assert module.last_assigned[0].offset == 0 # first run: start at low
|
|
296
|
+
assert [m["offset"] for m in out] == [0, 1, 2, 3, 4]
|
|
297
|
+
# advanced past the highest offset consumed
|
|
298
|
+
assert src.current_sampling_cursor() == {"payments:0": 5}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_kafka_automatic_second_run_resumes_from_saved_cursor(
|
|
302
|
+
_patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
|
|
303
|
+
) -> None:
|
|
304
|
+
module = _patch_kafka
|
|
305
|
+
module.watermarks[("payments", 0)] = (0, 100)
|
|
306
|
+
module.messages = _messages(12, start=0)
|
|
307
|
+
monkeypatch.setenv(CURSOR_ENV, _encode_cursor({"payments:0": 5}))
|
|
308
|
+
|
|
309
|
+
src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
|
|
310
|
+
out = src._consume("payments", 5)
|
|
311
|
+
|
|
312
|
+
assert module.last_assigned[0].offset == 5
|
|
313
|
+
assert [m["offset"] for m in out] == [5, 6, 7, 8, 9]
|
|
314
|
+
assert src.current_sampling_cursor() == {"payments:0": 10}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_kafka_automatic_wraps_to_low_when_caught_up(
|
|
318
|
+
_patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
|
|
319
|
+
) -> None:
|
|
320
|
+
module = _patch_kafka
|
|
321
|
+
module.watermarks[("payments", 0)] = (0, 10)
|
|
322
|
+
# Only two messages left before the high watermark.
|
|
323
|
+
module.messages = _messages(2, start=8)
|
|
324
|
+
monkeypatch.setenv(CURSOR_ENV, _encode_cursor({"payments:0": 8}))
|
|
325
|
+
|
|
326
|
+
src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
|
|
327
|
+
out = src._consume("payments", 5)
|
|
328
|
+
|
|
329
|
+
assert module.last_assigned[0].offset == 8
|
|
330
|
+
assert [m["offset"] for m in out] == [8, 9]
|
|
331
|
+
# 9 + 1 == 10 == high watermark → wraps back to low (0) for the next run
|
|
332
|
+
assert src.current_sampling_cursor() == {"payments:0": 0}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_kafka_automatic_stale_cursor_below_low_clamps_to_low(
|
|
336
|
+
_patch_kafka: _FakeKafkaModule, monkeypatch: pytest.MonkeyPatch
|
|
337
|
+
) -> None:
|
|
338
|
+
module = _patch_kafka
|
|
339
|
+
# Retention has advanced: earliest retained offset is now 20.
|
|
340
|
+
module.watermarks[("payments", 0)] = (20, 100)
|
|
341
|
+
module.messages = _messages(5, start=20)
|
|
342
|
+
monkeypatch.setenv(CURSOR_ENV, _encode_cursor({"payments:0": 5}))
|
|
343
|
+
|
|
344
|
+
src = KafkaSource(_recipe(sampling={"strategy": "AUTOMATIC", "rows_per_page": 10}))
|
|
345
|
+
out = src._consume("payments", 5)
|
|
346
|
+
|
|
347
|
+
assert module.last_assigned[0].offset == 20 # clamped up to low, not the stale 5
|
|
348
|
+
assert [m["offset"] for m in out] == [20, 21, 22, 23, 24]
|
|
349
|
+
assert src.current_sampling_cursor() == {"payments:0": 25}
|