classifyre-cli 0.4.73__tar.gz → 0.4.75__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.73 → classifyre_cli-0.4.75}/.turbo/turbo-build.log +1 -1
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/PKG-INFO +30 -1
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/README.md +29 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/package.json +1 -1
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/pyproject.toml +1 -1
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/rest.py +14 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/atlassian_common.py +3 -2
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/email/source.py +2 -2
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_workspace/source.py +2 -2
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/microsoft_365/source.py +2 -2
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/notion/client.py +3 -1
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/object_storage/base.py +8 -4
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/file_parser.py +52 -10
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/legacy_office.py +90 -19
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_file_parser.py +73 -0
- classifyre_cli-0.4.75/tests/utils/test_legacy_office.py +281 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/uv.lock +8 -8
- classifyre_cli-0.4.73/tests/utils/test_legacy_office.py +0 -66
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/.gitignore +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/.python-version +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/main.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/scripts/generate_models.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/config.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/broken_links/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/broken_links/detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/config.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/extractor.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_factory.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_gliner2.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_image_classification.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_llm.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_object_detection.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_regex.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_text_classification.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/trainer.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/dependencies.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/pii/detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/secrets/detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/threat/code_security_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/threat/yara_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/file_evaluation/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/file_evaluation/runner.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/main.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/models/generated_detectors.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/models/generated_input.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/models/generated_single_asset_scan_results.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/console.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/factory.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/file.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/content_provider.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/detector_pipeline.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/parsed_content_provider.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/text_artifact.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/worker_pool.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/asset_metadata.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/azure_blob_storage/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/azure_blob_storage/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/confluence/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/confluence/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/databricks/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/databricks/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/delta_lake/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/delta_lake/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dependencies.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dropbox/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dropbox/auth.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dropbox/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/elasticsearch/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/elasticsearch/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/email/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_cloud_storage/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_cloud_storage/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_workspace/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/hive/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/hive/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/iceberg/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/iceberg/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/jira/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/jira/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/kafka/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/kafka/rest.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/kafka/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/lakehouse_base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/local_folder/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/local_folder/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/meilisearch/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/meilisearch/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/microsoft_365/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mongodb/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mongodb/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mssql/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mssql/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mysql/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mysql/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/neo4j/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/neo4j/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/notion/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/notion/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/opensearch/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/opensearch/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/oracle/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/oracle/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/postgresql/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/postgresql/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/powerbi/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/powerbi/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_client.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_compatible_storage/README.md +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_compatible_storage/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_compatible_storage/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sandbox/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sandbox/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/search_engine_base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/servicedesk/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/servicedesk/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/slack/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/slack/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/snowflake/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/snowflake/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sqlite/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sqlite/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tableau/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tableau/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tabular_base.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tabular_utils.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/wordpress/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/wordpress/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/youtube/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/youtube/source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/telemetry.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/archive_extraction.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/content_extraction.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/dependency_groups.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/embedded_images.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/file_metadata.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/file_to_images.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/hashing.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/resources.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/transcription.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/uv_sync.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/validation.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/video_processing.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/_lakehouse_fakes.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/conftest.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/conftest.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/conftest.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_gliner2_classification.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_invoice_extraction.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_llm_runner.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_pipeline_integration.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_regex_runner.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_runner_hardening.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_transformer_runners.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/conftest.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/sample_invoice.pdf +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_chunking.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_regional_entities.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_severity.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/secrets/test_secrets_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_base_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_catalog_commercial.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_pipeline_types.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_schema_examples.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_types.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_phase2_detectors.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_registry.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/threat/test_code_security_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/threat/test_yara_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/integration/test_wordpress_links_assets.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/pipeline/test_detector_outcomes.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/pipeline/test_detector_pipeline.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/pipeline/test_worker_pool.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_assets_metadata_catalog.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_azure_blob_storage_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_base_source_attachment.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_base_source_sampling.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_config.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_confluence_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_custom_extractor.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_databricks_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_delta_lake_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_dependency_groups.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_dropbox_auth_command.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_dropbox_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_elasticsearch_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_email_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_file_evaluation_runner.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_google_cloud_storage_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_google_workspace_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_hashing.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_hive_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_iceberg_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_jira_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_kafka_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_local_folder_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_main.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_meilisearch_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_microsoft_365_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_mongodb_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_mssql_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_mysql_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_neo4j_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_notion_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_opensearch_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_oracle_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_outputs.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_postgresql_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_powerbi_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_s3_compatible_storage_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_sampling_automatic.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_sandbox_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_servicedesk_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_slack_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_snowflake_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_source_dependency_groups.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_source_strategy_conformance.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_sqlite_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_tableau_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_tabular_automatic_sampling.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_tabular_utils.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_uv_sync.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_wordpress_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_youtube_source.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_youtube_source_integration.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_archive_extraction.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_content_extraction.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_embedded_images.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_file_metadata.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_file_to_images.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_transcription.py +0 -0
- {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_video_processing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: classifyre-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.75
|
|
4
4
|
Summary: Classifyre CLI — scan and classify unstructured data sources
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: data,ingestion,metadata,pii,secrets,unstructured
|
|
@@ -35,6 +35,35 @@ uv sync --group detectors
|
|
|
35
35
|
# or specific groups: --group secrets --group pii --group threat ...
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
### System dependency: LibreOffice
|
|
39
|
+
|
|
40
|
+
Legacy Office formats (`.doc`, `.xls`, `.ppt`) have no usable pure-Python parser,
|
|
41
|
+
so `src/utils/legacy_office.py` shells out to `soffice --headless --convert-to
|
|
42
|
+
docx|xlsx|pptx` and feeds the result through the normal OOXML extractors. Without
|
|
43
|
+
it those files scan as *"no content available"* — a silent coverage gap, not an
|
|
44
|
+
error.
|
|
45
|
+
|
|
46
|
+
- **Kubernetes**: installed in the CLI image (`libreoffice-*-nogui`), so this
|
|
47
|
+
works out of the box.
|
|
48
|
+
- **Desktop and local dev**: install it yourself — `brew install --cask libreoffice`
|
|
49
|
+
(macOS), `apt install libreoffice-writer libreoffice-calc libreoffice-impress`
|
|
50
|
+
(Debian/Ubuntu), or libreoffice.org on Windows. The desktop app deliberately
|
|
51
|
+
does not bundle LibreOffice: it is ~550 MB even stripped, and slimming the
|
|
52
|
+
upstream bundle breaks its code signature, which Apple Silicon punishes by
|
|
53
|
+
killing the process on launch.
|
|
54
|
+
- **Non-standard install**: set `CLASSIFYRE_SOFFICE_PATH` to the binary. The CLI
|
|
55
|
+
checks that first, then `PATH`, then the standard macOS, Windows, and
|
|
56
|
+
Linux-tarball install locations.
|
|
57
|
+
|
|
58
|
+
When no binary is found the file is **not** silently reported as empty. The scan
|
|
59
|
+
raises an `ENGINE_UNAVAILABLE` text-extraction coverage error that fails that
|
|
60
|
+
asset's text scan, lands in `scan_stats.errors` and the run's text-coverage
|
|
61
|
+
counters, and carries a platform-specific message telling the user how to install
|
|
62
|
+
LibreOffice.
|
|
63
|
+
|
|
64
|
+
`tests/utils/test_legacy_office.py` skips its round-trip tests when no LibreOffice
|
|
65
|
+
is found, so a green local run does not by itself prove this path works.
|
|
66
|
+
|
|
38
67
|
## Command Syntax
|
|
39
68
|
|
|
40
69
|
Use the thin wrapper:
|
|
@@ -18,6 +18,35 @@ uv sync --group detectors
|
|
|
18
18
|
# or specific groups: --group secrets --group pii --group threat ...
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
### System dependency: LibreOffice
|
|
22
|
+
|
|
23
|
+
Legacy Office formats (`.doc`, `.xls`, `.ppt`) have no usable pure-Python parser,
|
|
24
|
+
so `src/utils/legacy_office.py` shells out to `soffice --headless --convert-to
|
|
25
|
+
docx|xlsx|pptx` and feeds the result through the normal OOXML extractors. Without
|
|
26
|
+
it those files scan as *"no content available"* — a silent coverage gap, not an
|
|
27
|
+
error.
|
|
28
|
+
|
|
29
|
+
- **Kubernetes**: installed in the CLI image (`libreoffice-*-nogui`), so this
|
|
30
|
+
works out of the box.
|
|
31
|
+
- **Desktop and local dev**: install it yourself — `brew install --cask libreoffice`
|
|
32
|
+
(macOS), `apt install libreoffice-writer libreoffice-calc libreoffice-impress`
|
|
33
|
+
(Debian/Ubuntu), or libreoffice.org on Windows. The desktop app deliberately
|
|
34
|
+
does not bundle LibreOffice: it is ~550 MB even stripped, and slimming the
|
|
35
|
+
upstream bundle breaks its code signature, which Apple Silicon punishes by
|
|
36
|
+
killing the process on launch.
|
|
37
|
+
- **Non-standard install**: set `CLASSIFYRE_SOFFICE_PATH` to the binary. The CLI
|
|
38
|
+
checks that first, then `PATH`, then the standard macOS, Windows, and
|
|
39
|
+
Linux-tarball install locations.
|
|
40
|
+
|
|
41
|
+
When no binary is found the file is **not** silently reported as empty. The scan
|
|
42
|
+
raises an `ENGINE_UNAVAILABLE` text-extraction coverage error that fails that
|
|
43
|
+
asset's text scan, lands in `scan_stats.errors` and the run's text-coverage
|
|
44
|
+
counters, and carries a platform-specific message telling the user how to install
|
|
45
|
+
LibreOffice.
|
|
46
|
+
|
|
47
|
+
`tests/utils/test_legacy_office.py` skips its round-trip tests when no LibreOffice
|
|
48
|
+
is found, so a green local run does not by itself prove this path works.
|
|
49
|
+
|
|
21
50
|
## Command Syntax
|
|
22
51
|
|
|
23
52
|
Use the thin wrapper:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
import random
|
|
5
6
|
from typing import Any, Literal, cast
|
|
6
7
|
from urllib.parse import urljoin
|
|
@@ -15,6 +16,18 @@ from .base import OutputRuntimeContext, OutputType
|
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
19
|
+
# Proves to the API that these write-backs come from a scan job it launched.
|
|
20
|
+
# The API has no user authentication, so the ingest endpoints are restricted to
|
|
21
|
+
# callers holding this shared secret; the API injects it into every CLI job's
|
|
22
|
+
# environment (Kubernetes job env, or inherited from the parent process on
|
|
23
|
+
# desktop). Absent locally, where the API leaves the restriction disabled.
|
|
24
|
+
INTERNAL_KEY_HEADER = "X-Classifyre-Internal-Key"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _internal_key_headers() -> dict[str, str]:
|
|
28
|
+
key = os.environ.get("CLASSIFYRE_INTERNAL_KEY", "").strip()
|
|
29
|
+
return {INTERNAL_KEY_HEADER: key} if key else {}
|
|
30
|
+
|
|
18
31
|
|
|
19
32
|
class _JitteredRetry(Retry):
|
|
20
33
|
"""urllib3 Retry subclass that adds ±25 % multiplicative jitter to the
|
|
@@ -164,6 +177,7 @@ class RestOutputSink:
|
|
|
164
177
|
# a pod restart or server-side keep-alive timeout. Each request opens
|
|
165
178
|
# a fresh TCP connection, which is cheap enough for our batch cadence.
|
|
166
179
|
self.session.headers.update({"Connection": "close"})
|
|
180
|
+
self.session.headers.update(_internal_key_headers())
|
|
167
181
|
adapter = HTTPAdapter(max_retries=_RETRY_POLICY)
|
|
168
182
|
self.session.mount("http://", adapter)
|
|
169
183
|
self.session.mount("https://", adapter)
|
|
@@ -11,6 +11,7 @@ from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
13
|
|
|
14
|
+
from ..utils.file_parser import normalize_mime_type
|
|
14
15
|
from ..utils.hashing import normalize_http_url
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
@@ -86,7 +87,7 @@ def extract_urls_from_text(text: str) -> list[str]:
|
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
def is_tabular_mime_type(mime_type: str) -> bool:
|
|
89
|
-
normalized = mime_type
|
|
90
|
+
normalized = normalize_mime_type(mime_type)
|
|
90
91
|
return normalized in TABULAR_MIME_TYPES
|
|
91
92
|
|
|
92
93
|
|
|
@@ -206,7 +207,7 @@ class AtlassianCloudClient:
|
|
|
206
207
|
for chunk in response.iter_content(chunk_size=8192):
|
|
207
208
|
if chunk:
|
|
208
209
|
chunks.append(chunk)
|
|
209
|
-
mime = response.headers.get("Content-Type", "")
|
|
210
|
+
mime = normalize_mime_type(response.headers.get("Content-Type", ""))
|
|
210
211
|
return b"".join(chunks), mime
|
|
211
212
|
|
|
212
213
|
def iter_confluence_results(
|
|
@@ -25,7 +25,7 @@ from ...models.generated_single_asset_scan_results import (
|
|
|
25
25
|
)
|
|
26
26
|
from ...utils.content_extraction import html_to_text
|
|
27
27
|
from ...utils.file_metadata import extract_file_metadata
|
|
28
|
-
from ...utils.file_parser import resolve_mime_type
|
|
28
|
+
from ...utils.file_parser import normalize_mime_type, resolve_mime_type
|
|
29
29
|
from ...utils.hashing import hash_id
|
|
30
30
|
from ..base import BaseSource
|
|
31
31
|
from ..dependencies import require_module
|
|
@@ -488,7 +488,7 @@ class EmailSource(BaseSource):
|
|
|
488
488
|
|
|
489
489
|
@staticmethod
|
|
490
490
|
def _asset_type_from_mime(mime: str) -> OutputAssetType:
|
|
491
|
-
normalized = (mime
|
|
491
|
+
normalized = normalize_mime_type(mime)
|
|
492
492
|
if normalized.startswith("image/"):
|
|
493
493
|
return OutputAssetType.IMAGE
|
|
494
494
|
if normalized.startswith("audio/"):
|
|
@@ -28,7 +28,7 @@ from ...models.generated_single_asset_scan_results import (
|
|
|
28
28
|
SingleAssetScanResults,
|
|
29
29
|
)
|
|
30
30
|
from ...utils.file_metadata import extract_file_metadata
|
|
31
|
-
from ...utils.file_parser import resolve_mime_type
|
|
31
|
+
from ...utils.file_parser import normalize_mime_type, resolve_mime_type
|
|
32
32
|
from ...utils.hashing import hash_id
|
|
33
33
|
from ..base import BaseSource
|
|
34
34
|
from ..dependencies import require_module
|
|
@@ -572,7 +572,7 @@ class GoogleWorkspaceSource(BaseSource):
|
|
|
572
572
|
# -- Asset construction --
|
|
573
573
|
|
|
574
574
|
def _asset_type_from_mime_or_key(self, mime_type: str | None, key: str) -> OutputAssetType:
|
|
575
|
-
normalized_mime = (mime_type
|
|
575
|
+
normalized_mime = normalize_mime_type(mime_type)
|
|
576
576
|
extension = PurePosixPath(key).suffix.lower()
|
|
577
577
|
|
|
578
578
|
if normalized_mime in _TABULAR_MIME_TYPES:
|
|
@@ -32,7 +32,7 @@ from ...models.generated_single_asset_scan_results import (
|
|
|
32
32
|
SingleAssetScanResults,
|
|
33
33
|
)
|
|
34
34
|
from ...utils.file_metadata import extract_file_metadata
|
|
35
|
-
from ...utils.file_parser import resolve_mime_type
|
|
35
|
+
from ...utils.file_parser import normalize_mime_type, resolve_mime_type
|
|
36
36
|
from ...utils.hashing import hash_id
|
|
37
37
|
from ..base import BaseSource
|
|
38
38
|
from ..dependencies import require_module
|
|
@@ -679,7 +679,7 @@ class Microsoft365Source(BaseSource):
|
|
|
679
679
|
# -- Asset construction --
|
|
680
680
|
|
|
681
681
|
def _asset_type_from_mime_or_key(self, mime_type: str | None, key: str) -> OutputAssetType:
|
|
682
|
-
normalized_mime = (mime_type
|
|
682
|
+
normalized_mime = normalize_mime_type(mime_type)
|
|
683
683
|
extension = PurePosixPath(key).suffix.lower()
|
|
684
684
|
|
|
685
685
|
if normalized_mime in _TABULAR_MIME_TYPES:
|
|
@@ -6,6 +6,8 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
import requests
|
|
8
8
|
|
|
9
|
+
from ...utils.file_parser import normalize_mime_type
|
|
10
|
+
|
|
9
11
|
logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
11
13
|
NOTION_API_BASE = "https://api.notion.com/v1"
|
|
@@ -154,7 +156,7 @@ class NotionClient:
|
|
|
154
156
|
for chunk in response.iter_content(chunk_size=8192):
|
|
155
157
|
if chunk:
|
|
156
158
|
chunks.append(chunk)
|
|
157
|
-
mime = response.headers.get("Content-Type", "")
|
|
159
|
+
mime = normalize_mime_type(response.headers.get("Content-Type", ""))
|
|
158
160
|
return b"".join(chunks), mime
|
|
159
161
|
|
|
160
162
|
def get_page(self, page_id: str) -> dict[str, Any]:
|
|
@@ -25,7 +25,11 @@ from ...models.generated_single_asset_scan_results import (
|
|
|
25
25
|
from ...utils.archive_extraction import ArchiveMember, is_archive_mime, iter_archive_members
|
|
26
26
|
from ...utils.embedded_images import EmbeddedImage, has_embedded_images, iter_embedded_images
|
|
27
27
|
from ...utils.file_metadata import extract_file_metadata
|
|
28
|
-
from ...utils.file_parser import
|
|
28
|
+
from ...utils.file_parser import (
|
|
29
|
+
infer_mime_type_from_file_name,
|
|
30
|
+
normalize_mime_type,
|
|
31
|
+
resolve_mime_type,
|
|
32
|
+
)
|
|
29
33
|
from ...utils.hashing import hash_id, unhash_id
|
|
30
34
|
from ..base import BaseSource
|
|
31
35
|
from ..dependencies import require_module
|
|
@@ -325,7 +329,7 @@ class ObjectStorageSourceBase(BaseSource, ABC):
|
|
|
325
329
|
return ref.key
|
|
326
330
|
|
|
327
331
|
def _asset_type_from_mime_or_key(self, mime_type: str | None, key: str) -> OutputAssetType:
|
|
328
|
-
normalized_mime = (mime_type
|
|
332
|
+
normalized_mime = normalize_mime_type(mime_type)
|
|
329
333
|
extension = self._file_extension(key)
|
|
330
334
|
|
|
331
335
|
if normalized_mime in _TABULAR_MIME_TYPES:
|
|
@@ -397,7 +401,7 @@ class ObjectStorageSourceBase(BaseSource, ABC):
|
|
|
397
401
|
|
|
398
402
|
def _build_snapshot(self, ref: ObjectRef) -> ContentSnapshot:
|
|
399
403
|
if self._discovery_only or not self._include_content_preview():
|
|
400
|
-
mime = (ref.content_type_hint
|
|
404
|
+
mime = normalize_mime_type(ref.content_type_hint)
|
|
401
405
|
if not mime:
|
|
402
406
|
mime = infer_mime_type_from_file_name(self._object_file_name(ref))
|
|
403
407
|
return ContentSnapshot(
|
|
@@ -426,7 +430,7 @@ class ObjectStorageSourceBase(BaseSource, ABC):
|
|
|
426
430
|
declared_mime_type=content_type_hint or ref.content_type_hint or "",
|
|
427
431
|
file_name=self._object_file_name(ref),
|
|
428
432
|
)
|
|
429
|
-
normalized_mime = mime_type
|
|
433
|
+
normalized_mime = normalize_mime_type(mime_type)
|
|
430
434
|
|
|
431
435
|
# Images and audio/video are extractable automatically. Opaque binaries
|
|
432
436
|
# remain available to binary detectors but have no text page stream.
|
|
@@ -61,6 +61,37 @@ class ParsedBytes:
|
|
|
61
61
|
parse_error: str | None = None
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
OCTET_STREAM = "application/octet-stream"
|
|
65
|
+
|
|
66
|
+
# Spellings of "I don't know what these bytes are". Only the first is the
|
|
67
|
+
# registered type, but object stores are inconsistent: S3-compatible services
|
|
68
|
+
# (Backblaze B2 among them) label every upload `binary/octet-stream`. Because
|
|
69
|
+
# that is not the IANA spelling, treating it as a *real* content type made it
|
|
70
|
+
# win over magic-byte detection and filename inference — so every object served
|
|
71
|
+
# that way skipped MIME resolution entirely, matched no branch in extract_text,
|
|
72
|
+
# and returned no text AND no error. Assets were then recorded as scanned and
|
|
73
|
+
# empty, which is indistinguishable from a genuinely empty file.
|
|
74
|
+
_GENERIC_BINARY_MIME_TYPES = frozenset(
|
|
75
|
+
{
|
|
76
|
+
OCTET_STREAM,
|
|
77
|
+
"binary/octet-stream",
|
|
78
|
+
"application/binary",
|
|
79
|
+
"application/unknown",
|
|
80
|
+
"unknown/unknown",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _canonical_mime_type(mime_type: str) -> str:
|
|
86
|
+
"""Collapse every "unknown bytes" spelling onto the registered one.
|
|
87
|
+
|
|
88
|
+
Downstream code compares against ``application/octet-stream`` in a dozen
|
|
89
|
+
places (is_binary, archive handling, detector content-type matching); mapping
|
|
90
|
+
aliases here means none of those have to know the alias list.
|
|
91
|
+
"""
|
|
92
|
+
return OCTET_STREAM if mime_type in _GENERIC_BINARY_MIME_TYPES else mime_type
|
|
93
|
+
|
|
94
|
+
|
|
64
95
|
_TEXT_RAW_MIME_TYPES = {
|
|
65
96
|
"application/json",
|
|
66
97
|
"application/xml",
|
|
@@ -327,10 +358,21 @@ def _require_file_processing(module_name: str) -> object:
|
|
|
327
358
|
return require_module(module_name, "file parser", ["file-processing"])
|
|
328
359
|
|
|
329
360
|
|
|
330
|
-
def
|
|
361
|
+
def normalize_mime_type(mime_type: str | None) -> str:
|
|
362
|
+
"""Strip parameters, lower-case, and collapse "unknown bytes" aliases.
|
|
363
|
+
|
|
364
|
+
The single place MIME strings are normalized. Sources must use this rather
|
|
365
|
+
than an inline ``split(";")[0].strip().lower()``: that idiom looks equivalent
|
|
366
|
+
but skips the alias collapsing, which is exactly how `binary/octet-stream`
|
|
367
|
+
from S3-compatible stores slipped through as a real content type.
|
|
368
|
+
"""
|
|
331
369
|
if not mime_type:
|
|
332
370
|
return ""
|
|
333
|
-
return str(mime_type).split(";", 1)[0].strip().lower()
|
|
371
|
+
return _canonical_mime_type(str(mime_type).split(";", 1)[0].strip().lower())
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# Internal alias kept so the many call sites in this module stay unchanged.
|
|
375
|
+
_normalize_mime_type = normalize_mime_type
|
|
334
376
|
|
|
335
377
|
|
|
336
378
|
def _file_extension(file_name: str) -> str:
|
|
@@ -357,7 +399,7 @@ def normalize_detected_mime_type(detected_mime_type: str, file_name: str) -> str
|
|
|
357
399
|
mime = _normalize_mime_type(detected_mime_type)
|
|
358
400
|
inferred_mime = infer_mime_type_from_file_name(file_name)
|
|
359
401
|
|
|
360
|
-
if not mime or mime ==
|
|
402
|
+
if not mime or mime == OCTET_STREAM:
|
|
361
403
|
return inferred_mime
|
|
362
404
|
|
|
363
405
|
if mime == "text/plain" and inferred_mime in _TABULAR_MIME_TYPES:
|
|
@@ -1015,17 +1057,17 @@ def resolve_mime_type(
|
|
|
1015
1057
|
detected_mime = _normalize_mime_type(detect_mime_type(file_bytes))
|
|
1016
1058
|
inferred_mime = infer_mime_type_from_file_name(file_name)
|
|
1017
1059
|
|
|
1018
|
-
if declared_mime and declared_mime !=
|
|
1060
|
+
if declared_mime and declared_mime != OCTET_STREAM:
|
|
1019
1061
|
mime_type = declared_mime
|
|
1020
|
-
elif detected_mime and detected_mime !=
|
|
1062
|
+
elif detected_mime and detected_mime != OCTET_STREAM:
|
|
1021
1063
|
mime_type = detected_mime
|
|
1022
|
-
elif inferred_mime and inferred_mime !=
|
|
1064
|
+
elif inferred_mime and inferred_mime != OCTET_STREAM:
|
|
1023
1065
|
mime_type = inferred_mime
|
|
1024
1066
|
else:
|
|
1025
|
-
mime_type = declared_mime or detected_mime or inferred_mime or
|
|
1067
|
+
mime_type = declared_mime or detected_mime or inferred_mime or OCTET_STREAM
|
|
1026
1068
|
|
|
1027
1069
|
mime_type = normalize_detected_mime_type(mime_type, file_name)
|
|
1028
|
-
if mime_type ==
|
|
1070
|
+
if mime_type == OCTET_STREAM and inferred_mime != OCTET_STREAM:
|
|
1029
1071
|
mime_type = inferred_mime
|
|
1030
1072
|
|
|
1031
1073
|
# A text-like claim (declared upload header or file extension) must survive
|
|
@@ -1033,10 +1075,10 @@ def resolve_mime_type(
|
|
|
1033
1075
|
# control-character garbage that poisons every text consumer downstream.
|
|
1034
1076
|
if (
|
|
1035
1077
|
file_bytes
|
|
1036
|
-
and detected_mime ==
|
|
1078
|
+
and detected_mime == OCTET_STREAM
|
|
1037
1079
|
and (mime_type.startswith("text/") or mime_type in ("application/json", "application/xml"))
|
|
1038
1080
|
):
|
|
1039
|
-
return
|
|
1081
|
+
return OCTET_STREAM
|
|
1040
1082
|
|
|
1041
1083
|
return mime_type
|
|
1042
1084
|
|
|
@@ -6,16 +6,25 @@ is fed back through the existing docx/xlsx/pptx extraction paths:
|
|
|
6
6
|
|
|
7
7
|
.doc / .xls / .ppt → soffice --headless --convert-to → .docx / .xlsx / .pptx
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
The CLI container image ships LibreOffice (the ``libreoffice-*-nogui`` Debian
|
|
10
|
+
packages, which install ``/usr/bin/soffice``), so conversion works out of the box
|
|
11
|
+
under Kubernetes. The desktop app deliberately does NOT bundle it — ~550 MB even
|
|
12
|
+
stripped, and slimming the upstream bundle breaks its code signature — so there
|
|
13
|
+
it is a system dependency the user installs.
|
|
14
|
+
|
|
15
|
+
When no binary is found the file is **not** quietly reported as empty: the error
|
|
16
|
+
from :func:`soffice_missing_error` propagates as an ENGINE_UNAVAILABLE text
|
|
17
|
+
extraction coverage failure, which fails that asset's text scan and tells the
|
|
18
|
+
user, per platform, how to install LibreOffice.
|
|
12
19
|
"""
|
|
13
20
|
|
|
14
21
|
from __future__ import annotations
|
|
15
22
|
|
|
16
23
|
import logging
|
|
24
|
+
import os
|
|
17
25
|
import shutil
|
|
18
26
|
import subprocess
|
|
27
|
+
import sys
|
|
19
28
|
import tempfile
|
|
20
29
|
import threading
|
|
21
30
|
from functools import cache
|
|
@@ -46,13 +55,58 @@ LEGACY_OFFICE_MIME_TYPES = frozenset(_CONVERSION_TARGETS)
|
|
|
46
55
|
|
|
47
56
|
_SOFFICE_TIMEOUT_SECONDS = 120
|
|
48
57
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
|
|
58
|
+
# How to actually get LibreOffice, per platform. The desktop app does not bundle
|
|
59
|
+
# it, so this message is the entire remediation path a user gets — a generic
|
|
60
|
+
# "install LibreOffice" would leave them guessing which package to install.
|
|
61
|
+
_INSTALL_HINTS: dict[str, str] = {
|
|
62
|
+
"darwin": "install it with `brew install --cask libreoffice`, or from libreoffice.org",
|
|
63
|
+
"win32": "install it from https://www.libreoffice.org/download/",
|
|
64
|
+
"linux": (
|
|
65
|
+
"install it with "
|
|
66
|
+
"`apt install libreoffice-writer libreoffice-calc libreoffice-impress` "
|
|
67
|
+
"(or the equivalent for your distribution)"
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def soffice_missing_error() -> str:
|
|
73
|
+
"""Actionable message for a deployment with no LibreOffice.
|
|
74
|
+
|
|
75
|
+
The word "unavailable" is load-bearing: it is what makes iter_file_pages
|
|
76
|
+
classify this as ENGINE_UNAVAILABLE rather than a per-document FAILED — the
|
|
77
|
+
difference between "this install cannot read .doc at all" and "this one file
|
|
78
|
+
is broken". test_legacy_office.py pins that mapping so the two cannot drift.
|
|
79
|
+
"""
|
|
80
|
+
hint = _INSTALL_HINTS.get(sys.platform, "install LibreOffice")
|
|
81
|
+
return (
|
|
82
|
+
"LibreOffice is unavailable, so this .doc/.xls/.ppt file was NOT scanned. "
|
|
83
|
+
f"To scan legacy Office documents, {hint} — "
|
|
84
|
+
"or set CLASSIFYRE_SOFFICE_PATH to an existing soffice binary."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Explicit override, checked before anything else. The desktop app rebuilds a
|
|
89
|
+
# minimal PATH for the processes it spawns, so an installation it discovered
|
|
90
|
+
# itself is handed down through this variable rather than through PATH.
|
|
91
|
+
SOFFICE_PATH_ENV = "CLASSIFYRE_SOFFICE_PATH"
|
|
92
|
+
|
|
93
|
+
# Install locations to check when PATH comes up empty, keyed by sys.platform.
|
|
94
|
+
# macOS and Windows always need these: their installers drop the binary inside an
|
|
95
|
+
# app bundle / Program Files without touching PATH, and the packaged desktop app
|
|
96
|
+
# rebuilds a minimal PATH that cannot see either. Linux only needs them for an
|
|
97
|
+
# upstream tarball install — the distro packages (including the
|
|
98
|
+
# libreoffice-*-nogui ones in the CLI image) all land on /usr/bin/soffice.
|
|
99
|
+
_SOFFICE_FALLBACK_PATHS: dict[str, tuple[str, ...]] = {
|
|
100
|
+
"darwin": ("/Applications/LibreOffice.app/Contents/MacOS/soffice",),
|
|
101
|
+
"linux": (
|
|
102
|
+
"/opt/libreoffice/program/soffice",
|
|
103
|
+
"/usr/lib/libreoffice/program/soffice",
|
|
104
|
+
),
|
|
105
|
+
"win32": (
|
|
106
|
+
"C:\\Program Files\\LibreOffice\\program\\soffice.exe",
|
|
107
|
+
"C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe",
|
|
108
|
+
),
|
|
109
|
+
}
|
|
56
110
|
|
|
57
111
|
# One conversion at a time: each soffice launch is a full process spinning up
|
|
58
112
|
# ~200 MB; serializing keeps peak memory bounded alongside detector workloads.
|
|
@@ -61,14 +115,36 @@ _conversion_lock = threading.Lock()
|
|
|
61
115
|
|
|
62
116
|
@cache
|
|
63
117
|
def find_soffice() -> str | None:
|
|
64
|
-
"""Locate the LibreOffice binary, or None when it is not installed.
|
|
118
|
+
"""Locate the LibreOffice binary, or None when it is not installed.
|
|
119
|
+
|
|
120
|
+
Resolution order, most explicit first:
|
|
121
|
+
|
|
122
|
+
1. ``CLASSIFYRE_SOFFICE_PATH`` — an operator/desktop-supplied absolute path.
|
|
123
|
+
2. ``PATH`` — the CLI container, Linux hosts, and dev shells.
|
|
124
|
+
3. Platform install locations — macOS/Windows only (see the table above).
|
|
125
|
+
|
|
126
|
+
Cached: the answer cannot change within a process. Tests that manipulate the
|
|
127
|
+
environment must call ``find_soffice.cache_clear()``.
|
|
128
|
+
"""
|
|
129
|
+
override = os.environ.get(SOFFICE_PATH_ENV, "").strip()
|
|
130
|
+
if override:
|
|
131
|
+
if Path(override).is_file():
|
|
132
|
+
return override
|
|
133
|
+
logger.warning(
|
|
134
|
+
"%s=%s does not point at an existing file; falling back to PATH lookup",
|
|
135
|
+
SOFFICE_PATH_ENV,
|
|
136
|
+
override,
|
|
137
|
+
)
|
|
138
|
+
|
|
65
139
|
for name in ("soffice", "libreoffice"):
|
|
66
140
|
path = shutil.which(name)
|
|
67
141
|
if path:
|
|
68
142
|
return path
|
|
69
|
-
|
|
70
|
-
|
|
143
|
+
|
|
144
|
+
for candidate in _SOFFICE_FALLBACK_PATHS.get(sys.platform, ()):
|
|
145
|
+
if Path(candidate).is_file():
|
|
71
146
|
return candidate
|
|
147
|
+
|
|
72
148
|
return None
|
|
73
149
|
|
|
74
150
|
|
|
@@ -90,12 +166,7 @@ def convert_legacy_office(
|
|
|
90
166
|
|
|
91
167
|
soffice = find_soffice()
|
|
92
168
|
if soffice is None:
|
|
93
|
-
return (
|
|
94
|
-
None,
|
|
95
|
-
target_mime,
|
|
96
|
-
"LibreOffice (soffice) not found; install LibreOffice to enable "
|
|
97
|
-
".doc/.xls/.ppt extraction",
|
|
98
|
-
)
|
|
169
|
+
return None, target_mime, soffice_missing_error()
|
|
99
170
|
|
|
100
171
|
try:
|
|
101
172
|
with tempfile.TemporaryDirectory(prefix="classifyre-soffice-") as temp_dir:
|
|
@@ -665,6 +665,79 @@ class TestOleMimeResolution:
|
|
|
665
665
|
assert resolve_mime_type(ole, file_name="blob") == "application/x-ole-storage"
|
|
666
666
|
|
|
667
667
|
|
|
668
|
+
class TestGenericBinaryMimeAliases:
|
|
669
|
+
"""S3-compatible stores declare `binary/octet-stream`, not the IANA spelling.
|
|
670
|
+
|
|
671
|
+
Treating that as a real content type let it outrank magic bytes and the file
|
|
672
|
+
extension, so every object from Backblaze B2 resolved to a type no extractor
|
|
673
|
+
handles — producing no text AND no error, which recorded the asset as
|
|
674
|
+
scanned-and-empty. Covers all formats, not just legacy Office.
|
|
675
|
+
"""
|
|
676
|
+
|
|
677
|
+
OLE = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 512
|
|
678
|
+
|
|
679
|
+
@pytest.mark.parametrize(
|
|
680
|
+
"declared",
|
|
681
|
+
[
|
|
682
|
+
"binary/octet-stream",
|
|
683
|
+
"application/octet-stream",
|
|
684
|
+
"application/binary",
|
|
685
|
+
"application/unknown",
|
|
686
|
+
"unknown/unknown",
|
|
687
|
+
"BINARY/OCTET-STREAM",
|
|
688
|
+
"binary/octet-stream; charset=binary",
|
|
689
|
+
"",
|
|
690
|
+
],
|
|
691
|
+
)
|
|
692
|
+
def test_generic_declaration_never_outranks_content(self, declared: str) -> None:
|
|
693
|
+
from src.utils.file_parser import resolve_mime_type
|
|
694
|
+
|
|
695
|
+
assert (
|
|
696
|
+
resolve_mime_type(self.OLE, declared_mime_type=declared, file_name="Cert.doc")
|
|
697
|
+
== "application/msword"
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
@pytest.mark.parametrize(
|
|
701
|
+
("file_bytes", "file_name", "expected"),
|
|
702
|
+
[
|
|
703
|
+
(b"%PDF-1.4\n" + b"x" * 200, "a.pdf", "application/pdf"),
|
|
704
|
+
(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64, "c.png", "image/png"),
|
|
705
|
+
(OLE, "b.xls", "application/vnd.ms-excel"),
|
|
706
|
+
(OLE, "d.ppt", "application/vnd.ms-powerpoint"),
|
|
707
|
+
],
|
|
708
|
+
)
|
|
709
|
+
def test_every_format_survives_a_generic_declaration(
|
|
710
|
+
self, file_bytes: bytes, file_name: str, expected: str
|
|
711
|
+
) -> None:
|
|
712
|
+
from src.utils.file_parser import resolve_mime_type
|
|
713
|
+
|
|
714
|
+
resolved = resolve_mime_type(
|
|
715
|
+
file_bytes, declared_mime_type="binary/octet-stream", file_name=file_name
|
|
716
|
+
)
|
|
717
|
+
assert resolved == expected
|
|
718
|
+
|
|
719
|
+
def test_normalize_mime_type_collapses_aliases(self) -> None:
|
|
720
|
+
from src.utils.file_parser import normalize_mime_type
|
|
721
|
+
|
|
722
|
+
for alias in ("binary/octet-stream", "application/binary", "unknown/unknown"):
|
|
723
|
+
assert normalize_mime_type(alias) == "application/octet-stream"
|
|
724
|
+
# A real type is left alone.
|
|
725
|
+
assert normalize_mime_type("application/pdf; charset=utf-8") == "application/pdf"
|
|
726
|
+
|
|
727
|
+
def test_real_declared_type_still_wins(self) -> None:
|
|
728
|
+
"""The alias collapsing must not make us ignore genuine declarations."""
|
|
729
|
+
from src.utils.file_parser import resolve_mime_type
|
|
730
|
+
|
|
731
|
+
assert (
|
|
732
|
+
resolve_mime_type(
|
|
733
|
+
b"nothing recognisable here",
|
|
734
|
+
declared_mime_type="text/csv",
|
|
735
|
+
file_name="rows.bin",
|
|
736
|
+
)
|
|
737
|
+
== "text/csv"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
668
741
|
class TestRtfExtraction:
|
|
669
742
|
def test_text_extracted(self) -> None:
|
|
670
743
|
pytest.importorskip("striprtf")
|