classifyre-cli 0.4.45__tar.gz → 0.4.47__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.45 → classifyre_cli-0.4.47}/.turbo/turbo-build.log +1 -1
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/PKG-INFO +1 -1
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/package.json +1 -1
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/pyproject.toml +1 -1
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/models/generated_input.py +84 -3
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/azure_blob_storage/source.py +18 -2
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/google_cloud_storage/source.py +20 -2
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/kafka/source.py +61 -3
- classifyre_cli-0.4.47/src/sources/local_folder/__init__.py +3 -0
- classifyre_cli-0.4.47/src/sources/local_folder/source.py +146 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/object_storage/base.py +14 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/source.py +14 -1
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/slack/source.py +2 -3
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/youtube/source.py +2 -2
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_azure_blob_storage_source.py +71 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_google_cloud_storage_source.py +65 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_kafka_source.py +108 -10
- classifyre_cli-0.4.47/tests/test_local_folder_source.py +335 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_s3_compatible_storage_source.py +65 -0
- classifyre_cli-0.4.47/tests/test_source_strategy_conformance.py +235 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/uv.lock +5 -5
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/.gitignore +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/.python-version +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/README.md +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/main.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/scripts/generate_models.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/config.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/broken_links/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/broken_links/detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/config.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/extractor.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_factory.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_feature_extraction.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_gliner2.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_image_classification.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_llm.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_object_detection.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_regex.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/runners/_text_classification.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/custom/trainer.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/dependencies.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/pii/detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/secrets/detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/threat/code_security_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/detectors/threat/yara_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/main.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/models/generated_detectors.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/models/generated_single_asset_scan_results.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/console.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/factory.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/file.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/outputs/rest.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/content_provider.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/detector_pipeline.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/parsed_content_provider.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/pipeline/worker_pool.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sandbox/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sandbox/runner.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/asset_metadata.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/atlassian_common.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/azure_blob_storage/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/confluence/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/confluence/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/databricks/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/databricks/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/delta_lake/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/delta_lake/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/dependencies.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/elasticsearch/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/elasticsearch/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/email/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/email/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/google_cloud_storage/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/hive/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/hive/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/iceberg/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/iceberg/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/jira/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/jira/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/kafka/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/lakehouse_base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/meilisearch/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/meilisearch/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mongodb/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mongodb/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mssql/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mssql/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mysql/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/mysql/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/neo4j/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/neo4j/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/notion/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/notion/client.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/notion/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/opensearch/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/opensearch/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/oracle/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/oracle/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/postgresql/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/postgresql/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/powerbi/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/powerbi/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_client.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/README.md +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/s3_compatible_storage/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/search_engine_base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/servicedesk/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/servicedesk/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/slack/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/snowflake/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/snowflake/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/sqlite/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/sqlite/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tableau/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tableau/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tabular_base.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/tabular_utils.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/wordpress/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/wordpress/source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/sources/youtube/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/telemetry.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/content_extraction.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/dependency_groups.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/embedded_images.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/file_metadata.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/file_parser.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/file_to_images.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/hashing.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/resources.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/transcription.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/uv_sync.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/src/utils/validation.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/_lakehouse_fakes.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/conftest.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/conftest.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/conftest.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_invoice_extraction.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_llm_runner.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_pipeline_integration.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_regex_runner.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/custom/test_transformer_runners.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/conftest.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/sample_invoice.pdf +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/test_pii_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/secrets/test_secrets_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_base_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_catalog_commercial.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_pipeline_types.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_schema_examples.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_detector_types.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_phase2_detectors.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/test_registry.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/threat/test_code_security_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/detectors/threat/test_yara_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/integration/test_wordpress_links_assets.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/pipeline/test_detector_pipeline.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/pipeline/test_worker_pool.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_assets_metadata_catalog.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_base_source_attachment.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_base_source_sampling.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_config.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_confluence_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_custom_extractor.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_databricks_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_delta_lake_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_dependency_groups.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_elasticsearch_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_email_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_hashing.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_hive_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_iceberg_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_jira_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_meilisearch_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_mongodb_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_mssql_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_mysql_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_neo4j_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_notion_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_opensearch_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_oracle_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_outputs.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_postgresql_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_powerbi_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_sampling_automatic.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_sandbox_runner.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_servicedesk_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_slack_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_snowflake_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_source_dependency_groups.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_sqlite_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_tableau_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_tabular_automatic_sampling.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_tabular_utils.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_uv_sync.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_wordpress_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_youtube_source.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/test_youtube_source_integration.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_content_extraction.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_embedded_images.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_file_metadata.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_file_parser.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_file_to_images.py +0 -0
- {classifyre_cli-0.4.45 → classifyre_cli-0.4.47}/tests/utils/test_transcription.py +0 -0
|
@@ -51,6 +51,7 @@ class AssetType(StrEnum):
|
|
|
51
51
|
ELASTICSEARCH = 'ELASTICSEARCH'
|
|
52
52
|
OPENSEARCH = 'OPENSEARCH'
|
|
53
53
|
MEILISEARCH = 'MEILISEARCH'
|
|
54
|
+
LOCAL_FOLDER = 'LOCAL_FOLDER'
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class DetectorType(StrEnum):
|
|
@@ -355,6 +356,7 @@ class Type(StrEnum):
|
|
|
355
356
|
ELASTICSEARCH = 'ELASTICSEARCH'
|
|
356
357
|
OPENSEARCH = 'OPENSEARCH'
|
|
357
358
|
MEILISEARCH = 'MEILISEARCH'
|
|
359
|
+
LOCAL_FOLDER = 'LOCAL_FOLDER'
|
|
358
360
|
|
|
359
361
|
|
|
360
362
|
class YouTubeRequired(BaseModel):
|
|
@@ -644,6 +646,64 @@ class S3CompatibleStorageOptional(BaseModel):
|
|
|
644
646
|
scope: ObjectStorageOptionalScope | None = None
|
|
645
647
|
|
|
646
648
|
|
|
649
|
+
class LocalFolderRequired(BaseModel):
|
|
650
|
+
model_config = ConfigDict(
|
|
651
|
+
extra='forbid',
|
|
652
|
+
)
|
|
653
|
+
path: str = Field(
|
|
654
|
+
...,
|
|
655
|
+
description='Absolute path of the local folder to scan (for example, /Users/jane/Documents)',
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class LocalFolderMasked(BaseModel):
|
|
660
|
+
"""
|
|
661
|
+
Local folder scans run with the desktop application's filesystem permissions; no credentials are required.
|
|
662
|
+
"""
|
|
663
|
+
|
|
664
|
+
model_config = ConfigDict(
|
|
665
|
+
extra='forbid',
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class LocalFolderOptionalTraversal(BaseModel):
|
|
670
|
+
"""
|
|
671
|
+
Directory traversal controls.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
model_config = ConfigDict(
|
|
675
|
+
extra='forbid',
|
|
676
|
+
)
|
|
677
|
+
follow_symlinks: bool | None = Field(
|
|
678
|
+
False,
|
|
679
|
+
description='Follow symbolic links while traversing (loops are not detected; leave off unless needed)',
|
|
680
|
+
)
|
|
681
|
+
include_hidden: bool | None = Field(
|
|
682
|
+
False,
|
|
683
|
+
description='Include hidden files and dot-directories (names starting with a dot)',
|
|
684
|
+
)
|
|
685
|
+
max_depth: int | None = Field(
|
|
686
|
+
None,
|
|
687
|
+
description='Maximum directory depth below the selected folder (unset scans all levels)',
|
|
688
|
+
ge=1,
|
|
689
|
+
le=64,
|
|
690
|
+
)
|
|
691
|
+
max_file_bytes: int | None = Field(
|
|
692
|
+
10485760,
|
|
693
|
+
description='Maximum bytes read per file for MIME detection and text extraction',
|
|
694
|
+
ge=1024,
|
|
695
|
+
le=104857600,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class LocalFolderOptional(BaseModel):
|
|
700
|
+
model_config = ConfigDict(
|
|
701
|
+
extra='forbid',
|
|
702
|
+
)
|
|
703
|
+
traversal: LocalFolderOptionalTraversal | None = None
|
|
704
|
+
scope: ObjectStorageOptionalScope | None = None
|
|
705
|
+
|
|
706
|
+
|
|
647
707
|
class AzureBlobStorageRequired(BaseModel):
|
|
648
708
|
model_config = ConfigDict(
|
|
649
709
|
extra='forbid',
|
|
@@ -2100,6 +2160,24 @@ class S3CompatibleStorageInput(CoreInput):
|
|
|
2100
2160
|
resources: ResourceOverrides | None = None
|
|
2101
2161
|
|
|
2102
2162
|
|
|
2163
|
+
class LocalFolderInput(CoreInput):
|
|
2164
|
+
type: Literal['LOCAL_FOLDER'] | None = Field(
|
|
2165
|
+
None, description='Type of the asset or source'
|
|
2166
|
+
)
|
|
2167
|
+
required: LocalFolderRequired
|
|
2168
|
+
masked: LocalFolderMasked | None = None
|
|
2169
|
+
optional: LocalFolderOptional | None = None
|
|
2170
|
+
detectors: list[Detector] | None = Field(
|
|
2171
|
+
None, description='Detectors to run on ingested content'
|
|
2172
|
+
)
|
|
2173
|
+
custom_detectors: list[CustomDetectorSelection] | None = Field(
|
|
2174
|
+
None,
|
|
2175
|
+
description='Reusable custom detector IDs selected from the custom detector catalog.',
|
|
2176
|
+
)
|
|
2177
|
+
sampling: SamplingConfig
|
|
2178
|
+
resources: ResourceOverrides | None = None
|
|
2179
|
+
|
|
2180
|
+
|
|
2103
2181
|
class AzureBlobStorageInput(CoreInput):
|
|
2104
2182
|
type: Literal['AZURE_BLOB_STORAGE'] | None = Field(
|
|
2105
2183
|
None, description='Type of the asset or source'
|
|
@@ -2531,7 +2609,7 @@ class ConfluenceOptionalConnection(BaseModel):
|
|
|
2531
2609
|
)
|
|
2532
2610
|
|
|
2533
2611
|
|
|
2534
|
-
class
|
|
2612
|
+
class Type19(StrEnum):
|
|
2535
2613
|
"""
|
|
2536
2614
|
Filter spaces by space type
|
|
2537
2615
|
"""
|
|
@@ -2568,7 +2646,7 @@ class ConfluenceOptionalScopeSpaces(BaseModel):
|
|
|
2568
2646
|
keys: list[str] | None = Field(
|
|
2569
2647
|
None, description='Filter spaces by keys (up to 250)', max_length=250
|
|
2570
2648
|
)
|
|
2571
|
-
type:
|
|
2649
|
+
type: Type19 | None = Field(None, description='Filter spaces by space type')
|
|
2572
2650
|
status: Status | None = Field(None, description='Filter spaces by status')
|
|
2573
2651
|
labels: list[str] | None = Field(
|
|
2574
2652
|
None,
|
|
@@ -2834,7 +2912,7 @@ class ServiceDeskOptional(BaseModel):
|
|
|
2834
2912
|
content: ServiceDeskOptionalContent | None = None
|
|
2835
2913
|
|
|
2836
2914
|
|
|
2837
|
-
class
|
|
2915
|
+
class Type20(StrEnum):
|
|
2838
2916
|
"""
|
|
2839
2917
|
Type of the asset or source
|
|
2840
2918
|
"""
|
|
@@ -2868,6 +2946,7 @@ class Type19(StrEnum):
|
|
|
2868
2946
|
ELASTICSEARCH = 'ELASTICSEARCH'
|
|
2869
2947
|
OPENSEARCH = 'OPENSEARCH'
|
|
2870
2948
|
MEILISEARCH = 'MEILISEARCH'
|
|
2949
|
+
LOCAL_FOLDER = 'LOCAL_FOLDER'
|
|
2871
2950
|
|
|
2872
2951
|
|
|
2873
2952
|
class ConfluenceInput(CoreInput):
|
|
@@ -3682,6 +3761,7 @@ class SourceInput(
|
|
|
3682
3761
|
RootModel[
|
|
3683
3762
|
SlackInput
|
|
3684
3763
|
| S3CompatibleStorageInput
|
|
3764
|
+
| LocalFolderInput
|
|
3685
3765
|
| AzureBlobStorageInput
|
|
3686
3766
|
| GoogleCloudStorageInput
|
|
3687
3767
|
| PostgreSQLInput
|
|
@@ -3714,6 +3794,7 @@ class SourceInput(
|
|
|
3714
3794
|
root: (
|
|
3715
3795
|
SlackInput
|
|
3716
3796
|
| S3CompatibleStorageInput
|
|
3797
|
+
| LocalFolderInput
|
|
3717
3798
|
| AzureBlobStorageInput
|
|
3718
3799
|
| GoogleCloudStorageInput
|
|
3719
3800
|
| PostgreSQLInput
|
|
@@ -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(
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from ...models.generated_input import LocalFolderInput
|
|
12
|
+
from ..object_storage.base import ObjectRef, ObjectStorageSourceBase
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalFolderSource(ObjectStorageSourceBase):
|
|
18
|
+
"""Scans a folder on the local filesystem (desktop application deployments).
|
|
19
|
+
|
|
20
|
+
Reuses the object-storage pipeline: each file becomes an ObjectRef whose key
|
|
21
|
+
is the path relative to the scanned root, so sampling, MIME resolution, text
|
|
22
|
+
extraction, file metadata, and embedded-image child assets all come from
|
|
23
|
+
ObjectStorageSourceBase.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
source_type = "local_folder"
|
|
27
|
+
provider_label = "LOCAL_FOLDER"
|
|
28
|
+
input_model = LocalFolderInput
|
|
29
|
+
|
|
30
|
+
DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
_resolved_root: Path | None = None
|
|
33
|
+
|
|
34
|
+
def _root(self) -> Path:
|
|
35
|
+
if self._resolved_root is not None:
|
|
36
|
+
return self._resolved_root
|
|
37
|
+
raw = str(self.config.required.path).strip()
|
|
38
|
+
if not raw:
|
|
39
|
+
raise ValueError("required.path must be set")
|
|
40
|
+
root = Path(raw).expanduser()
|
|
41
|
+
if not root.is_absolute():
|
|
42
|
+
raise ValueError(f"required.path must be an absolute path, got: {raw}")
|
|
43
|
+
if not root.exists():
|
|
44
|
+
raise ValueError(f"Folder does not exist: {root}")
|
|
45
|
+
if not root.is_dir():
|
|
46
|
+
raise ValueError(f"Path is not a folder: {root}")
|
|
47
|
+
self._resolved_root = root.resolve()
|
|
48
|
+
return self._resolved_root
|
|
49
|
+
|
|
50
|
+
def _traversal_option(self, key: str, default: Any = None) -> Any:
|
|
51
|
+
optional = self.config.optional
|
|
52
|
+
traversal = getattr(optional, "traversal", None) if optional else None
|
|
53
|
+
if traversal is not None:
|
|
54
|
+
value = getattr(traversal, key, None)
|
|
55
|
+
if value is not None:
|
|
56
|
+
return value
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
def _follow_symlinks(self) -> bool:
|
|
60
|
+
return bool(self._traversal_option("follow_symlinks", False))
|
|
61
|
+
|
|
62
|
+
def _include_hidden(self) -> bool:
|
|
63
|
+
return bool(self._traversal_option("include_hidden", False))
|
|
64
|
+
|
|
65
|
+
def _max_depth(self) -> int | None:
|
|
66
|
+
value = self._traversal_option("max_depth")
|
|
67
|
+
try:
|
|
68
|
+
return int(value) if value is not None else None
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def _max_file_bytes(self) -> int:
|
|
73
|
+
value = self._traversal_option("max_file_bytes", self.DEFAULT_MAX_FILE_BYTES)
|
|
74
|
+
try:
|
|
75
|
+
parsed = int(value)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return self.DEFAULT_MAX_FILE_BYTES
|
|
78
|
+
return max(parsed, 1024)
|
|
79
|
+
|
|
80
|
+
def _is_hidden(self, name: str) -> bool:
|
|
81
|
+
return name.startswith(".")
|
|
82
|
+
|
|
83
|
+
def _walk(self, directory: Path, depth: int) -> Iterator[ObjectRef]:
|
|
84
|
+
max_depth = self._max_depth()
|
|
85
|
+
follow_symlinks = self._follow_symlinks()
|
|
86
|
+
include_hidden = self._include_hidden()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
logger.warning("Skipping unreadable directory %s: %s", directory, exc)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
for entry in entries:
|
|
95
|
+
if not include_hidden and self._is_hidden(entry.name):
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
if entry.is_dir(follow_symlinks=follow_symlinks):
|
|
99
|
+
if max_depth is None or depth < max_depth:
|
|
100
|
+
yield from self._walk(Path(entry.path), depth + 1)
|
|
101
|
+
continue
|
|
102
|
+
if not entry.is_file(follow_symlinks=follow_symlinks):
|
|
103
|
+
continue
|
|
104
|
+
stat = entry.stat(follow_symlinks=follow_symlinks)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
logger.warning("Skipping unreadable entry %s: %s", entry.path, exc)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
key = Path(entry.path).relative_to(self._root()).as_posix()
|
|
110
|
+
if not self._object_matches_extension_filters(key):
|
|
111
|
+
continue
|
|
112
|
+
prefix = self._prefix()
|
|
113
|
+
if prefix and not key.startswith(prefix):
|
|
114
|
+
continue
|
|
115
|
+
if stat.st_size == 0 and not self._include_empty_objects():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
yield ObjectRef(
|
|
119
|
+
key=key,
|
|
120
|
+
size=stat.st_size,
|
|
121
|
+
last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
|
122
|
+
# Change signature: mtime+size, so edits produce a new checksum
|
|
123
|
+
# without hashing file bytes during discovery.
|
|
124
|
+
etag=f"{stat.st_mtime_ns:x}-{stat.st_size:x}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _list_objects(self) -> Iterator[ObjectRef]:
|
|
128
|
+
yield from self._walk(self._root(), depth=0)
|
|
129
|
+
|
|
130
|
+
def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
|
|
131
|
+
file_path = self._root() / ref.key
|
|
132
|
+
max_bytes = self._max_file_bytes()
|
|
133
|
+
with open(file_path, "rb") as handle:
|
|
134
|
+
file_bytes = handle.read(max_bytes)
|
|
135
|
+
if ref.size > max_bytes:
|
|
136
|
+
logger.info(
|
|
137
|
+
"Truncated %s to %d of %d bytes for content extraction",
|
|
138
|
+
file_path,
|
|
139
|
+
max_bytes,
|
|
140
|
+
ref.size,
|
|
141
|
+
)
|
|
142
|
+
return file_bytes, None
|
|
143
|
+
|
|
144
|
+
def _external_url(self, key: str) -> str:
|
|
145
|
+
absolute = (self._root() / key).as_posix()
|
|
146
|
+
return f"file://{quote(absolute)}"
|
|
@@ -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)
|