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.
Files changed (263) hide show
  1. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/.turbo/turbo-build.log +1 -1
  2. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/PKG-INFO +30 -1
  3. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/README.md +29 -0
  4. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/package.json +1 -1
  5. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/pyproject.toml +1 -1
  6. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/rest.py +14 -0
  7. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/atlassian_common.py +3 -2
  8. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/email/source.py +2 -2
  9. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_workspace/source.py +2 -2
  10. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/microsoft_365/source.py +2 -2
  11. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/notion/client.py +3 -1
  12. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/object_storage/base.py +8 -4
  13. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/file_parser.py +52 -10
  14. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/legacy_office.py +90 -19
  15. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_file_parser.py +73 -0
  16. classifyre_cli-0.4.75/tests/utils/test_legacy_office.py +281 -0
  17. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/uv.lock +8 -8
  18. classifyre_cli-0.4.73/tests/utils/test_legacy_office.py +0 -66
  19. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/.gitignore +0 -0
  20. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/.python-version +0 -0
  21. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/main.py +0 -0
  22. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/scripts/generate_models.py +0 -0
  23. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/__init__.py +0 -0
  24. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/config.py +0 -0
  25. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/__init__.py +0 -0
  26. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/base.py +0 -0
  27. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/broken_links/__init__.py +0 -0
  28. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/broken_links/detector.py +0 -0
  29. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/config.py +0 -0
  30. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/content/__init__.py +0 -0
  31. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/__init__.py +0 -0
  32. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/detector.py +0 -0
  33. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/extractor.py +0 -0
  34. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/__init__.py +0 -0
  35. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_base.py +0 -0
  36. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_factory.py +0 -0
  37. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_gliner2.py +0 -0
  38. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_image_classification.py +0 -0
  39. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_llm.py +0 -0
  40. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_object_detection.py +0 -0
  41. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_regex.py +0 -0
  42. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/runners/_text_classification.py +0 -0
  43. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/custom/trainer.py +0 -0
  44. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/dependencies.py +0 -0
  45. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/pii/__init__.py +0 -0
  46. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/pii/detector.py +0 -0
  47. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/secrets/__init__.py +0 -0
  48. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/secrets/detector.py +0 -0
  49. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/threat/__init__.py +0 -0
  50. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/threat/code_security_detector.py +0 -0
  51. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/detectors/threat/yara_detector.py +0 -0
  52. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/file_evaluation/__init__.py +0 -0
  53. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/file_evaluation/runner.py +0 -0
  54. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/main.py +0 -0
  55. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/models/generated_detectors.py +0 -0
  56. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/models/generated_input.py +0 -0
  57. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/models/generated_single_asset_scan_results.py +0 -0
  58. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/__init__.py +0 -0
  59. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/base.py +0 -0
  60. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/console.py +0 -0
  61. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/factory.py +0 -0
  62. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/outputs/file.py +0 -0
  63. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/__init__.py +0 -0
  64. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/content_provider.py +0 -0
  65. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/detector_pipeline.py +0 -0
  66. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/parsed_content_provider.py +0 -0
  67. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/text_artifact.py +0 -0
  68. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/pipeline/worker_pool.py +0 -0
  69. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/__init__.py +0 -0
  70. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/asset_metadata.py +0 -0
  71. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/azure_blob_storage/__init__.py +0 -0
  72. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/azure_blob_storage/source.py +0 -0
  73. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/base.py +0 -0
  74. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/confluence/__init__.py +0 -0
  75. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/confluence/source.py +0 -0
  76. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/databricks/__init__.py +0 -0
  77. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/databricks/source.py +0 -0
  78. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/delta_lake/__init__.py +0 -0
  79. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/delta_lake/source.py +0 -0
  80. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dependencies.py +0 -0
  81. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dropbox/__init__.py +0 -0
  82. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dropbox/auth.py +0 -0
  83. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/dropbox/source.py +0 -0
  84. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/elasticsearch/__init__.py +0 -0
  85. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/elasticsearch/source.py +0 -0
  86. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/email/__init__.py +0 -0
  87. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_cloud_storage/__init__.py +0 -0
  88. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_cloud_storage/source.py +0 -0
  89. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/google_workspace/__init__.py +0 -0
  90. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/hive/__init__.py +0 -0
  91. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/hive/source.py +0 -0
  92. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/iceberg/__init__.py +0 -0
  93. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/iceberg/source.py +0 -0
  94. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/jira/__init__.py +0 -0
  95. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/jira/source.py +0 -0
  96. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/kafka/__init__.py +0 -0
  97. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/kafka/rest.py +0 -0
  98. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/kafka/source.py +0 -0
  99. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/lakehouse_base.py +0 -0
  100. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/local_folder/__init__.py +0 -0
  101. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/local_folder/source.py +0 -0
  102. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/meilisearch/__init__.py +0 -0
  103. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/meilisearch/source.py +0 -0
  104. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/microsoft_365/__init__.py +0 -0
  105. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mongodb/__init__.py +0 -0
  106. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mongodb/source.py +0 -0
  107. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mssql/__init__.py +0 -0
  108. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mssql/source.py +0 -0
  109. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mysql/__init__.py +0 -0
  110. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/mysql/source.py +0 -0
  111. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/neo4j/__init__.py +0 -0
  112. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/neo4j/source.py +0 -0
  113. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/notion/__init__.py +0 -0
  114. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/notion/source.py +0 -0
  115. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/opensearch/__init__.py +0 -0
  116. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/opensearch/source.py +0 -0
  117. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/oracle/__init__.py +0 -0
  118. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/oracle/source.py +0 -0
  119. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/postgresql/__init__.py +0 -0
  120. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/postgresql/source.py +0 -0
  121. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/powerbi/__init__.py +0 -0
  122. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/powerbi/source.py +0 -0
  123. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/recipe_normalizer.py +0 -0
  124. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_client.py +0 -0
  125. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_compatible_storage/README.md +0 -0
  126. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_compatible_storage/__init__.py +0 -0
  127. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/s3_compatible_storage/source.py +0 -0
  128. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sandbox/__init__.py +0 -0
  129. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sandbox/source.py +0 -0
  130. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/search_engine_base.py +0 -0
  131. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/servicedesk/__init__.py +0 -0
  132. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/servicedesk/source.py +0 -0
  133. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/slack/__init__.py +0 -0
  134. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/slack/source.py +0 -0
  135. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/snowflake/__init__.py +0 -0
  136. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/snowflake/source.py +0 -0
  137. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sqlite/__init__.py +0 -0
  138. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/sqlite/source.py +0 -0
  139. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tableau/__init__.py +0 -0
  140. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tableau/source.py +0 -0
  141. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tabular_base.py +0 -0
  142. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/tabular_utils.py +0 -0
  143. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/wordpress/__init__.py +0 -0
  144. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/wordpress/source.py +0 -0
  145. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/youtube/__init__.py +0 -0
  146. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/sources/youtube/source.py +0 -0
  147. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/telemetry.py +0 -0
  148. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/__init__.py +0 -0
  149. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/archive_extraction.py +0 -0
  150. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/content_extraction.py +0 -0
  151. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/dependency_groups.py +0 -0
  152. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/embedded_images.py +0 -0
  153. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/file_metadata.py +0 -0
  154. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/file_to_images.py +0 -0
  155. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/hashing.py +0 -0
  156. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/resources.py +0 -0
  157. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/transcription.py +0 -0
  158. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/uv_sync.py +0 -0
  159. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/validation.py +0 -0
  160. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/src/utils/video_processing.py +0 -0
  161. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/__init__.py +0 -0
  162. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/_lakehouse_fakes.py +0 -0
  163. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/conftest.py +0 -0
  164. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/__init__.py +0 -0
  165. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
  166. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/conftest.py +0 -0
  167. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/content/__init__.py +0 -0
  168. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/__init__.py +0 -0
  169. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/conftest.py +0 -0
  170. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_gliner2_classification.py +0 -0
  171. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_invoice_extraction.py +0 -0
  172. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_llm_runner.py +0 -0
  173. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_pipeline_integration.py +0 -0
  174. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_regex_runner.py +0 -0
  175. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_runner_hardening.py +0 -0
  176. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/custom/test_transformer_runners.py +0 -0
  177. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/__init__.py +0 -0
  178. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/conftest.py +0 -0
  179. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/sample_invoice.pdf +0 -0
  180. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_chunking.py +0 -0
  181. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_detector.py +0 -0
  182. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
  183. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_regional_entities.py +0 -0
  184. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/pii/test_pii_severity.py +0 -0
  185. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/secrets/__init__.py +0 -0
  186. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/secrets/test_secrets_detector.py +0 -0
  187. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
  188. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_base_detector.py +0 -0
  189. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
  190. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_catalog_commercial.py +0 -0
  191. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_pipeline_types.py +0 -0
  192. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_schema_examples.py +0 -0
  193. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_detector_types.py +0 -0
  194. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_phase2_detectors.py +0 -0
  195. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/test_registry.py +0 -0
  196. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/threat/__init__.py +0 -0
  197. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/threat/test_code_security_detector.py +0 -0
  198. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/detectors/threat/test_yara_detector.py +0 -0
  199. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
  200. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/integration/test_wordpress_links_assets.py +0 -0
  201. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/pipeline/test_detector_outcomes.py +0 -0
  202. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/pipeline/test_detector_pipeline.py +0 -0
  203. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/pipeline/test_worker_pool.py +0 -0
  204. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_assets_metadata_catalog.py +0 -0
  205. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_azure_blob_storage_source.py +0 -0
  206. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_base_source_attachment.py +0 -0
  207. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_base_source_sampling.py +0 -0
  208. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_config.py +0 -0
  209. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_confluence_source.py +0 -0
  210. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_custom_extractor.py +0 -0
  211. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_databricks_source.py +0 -0
  212. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_delta_lake_source.py +0 -0
  213. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_dependency_groups.py +0 -0
  214. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_dropbox_auth_command.py +0 -0
  215. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_dropbox_source.py +0 -0
  216. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_elasticsearch_source.py +0 -0
  217. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_email_source.py +0 -0
  218. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_file_evaluation_runner.py +0 -0
  219. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_google_cloud_storage_source.py +0 -0
  220. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_google_workspace_source.py +0 -0
  221. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_hashing.py +0 -0
  222. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_hive_source.py +0 -0
  223. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_iceberg_source.py +0 -0
  224. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_jira_source.py +0 -0
  225. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_kafka_source.py +0 -0
  226. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_local_folder_source.py +0 -0
  227. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_main.py +0 -0
  228. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_meilisearch_source.py +0 -0
  229. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_microsoft_365_source.py +0 -0
  230. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_mongodb_source.py +0 -0
  231. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_mssql_source.py +0 -0
  232. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_mysql_source.py +0 -0
  233. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_neo4j_source.py +0 -0
  234. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_notion_source.py +0 -0
  235. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_opensearch_source.py +0 -0
  236. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_oracle_source.py +0 -0
  237. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_outputs.py +0 -0
  238. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_postgresql_source.py +0 -0
  239. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_powerbi_source.py +0 -0
  240. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_recipe_normalizer.py +0 -0
  241. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_s3_compatible_storage_source.py +0 -0
  242. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_sampling_automatic.py +0 -0
  243. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_sandbox_source.py +0 -0
  244. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_servicedesk_source.py +0 -0
  245. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_slack_source.py +0 -0
  246. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_snowflake_source.py +0 -0
  247. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_source_dependency_groups.py +0 -0
  248. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_source_strategy_conformance.py +0 -0
  249. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_sqlite_source.py +0 -0
  250. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_tableau_source.py +0 -0
  251. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_tabular_automatic_sampling.py +0 -0
  252. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_tabular_utils.py +0 -0
  253. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_uv_sync.py +0 -0
  254. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_wordpress_source.py +0 -0
  255. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_youtube_source.py +0 -0
  256. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/test_youtube_source_integration.py +0 -0
  257. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_archive_extraction.py +0 -0
  258. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_content_extraction.py +0 -0
  259. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_embedded_images.py +0 -0
  260. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_file_metadata.py +0 -0
  261. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_file_to_images.py +0 -0
  262. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_transcription.py +0 -0
  263. {classifyre_cli-0.4.73 → classifyre_cli-0.4.75}/tests/utils/test_video_processing.py +0 -0
@@ -1,3 +1,3 @@
1
1
  $ uv sync
2
- Resolved 303 packages in 168ms
2
+ Resolved 303 packages in 208ms
3
3
  Checked 51 packages in 1ms
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: classifyre-cli
3
- Version: 0.4.73
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,6 @@
1
1
  {
2
2
  "name": "@classifyre/cli",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "build": "uv sync",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "classifyre-cli"
3
- version = "0.4.73"
3
+ version = "0.4.75"
4
4
  description = "Classifyre CLI — scan and classify unstructured data sources"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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.split(";", 1)[0].strip().lower()
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", "").split(";")[0].strip().lower()
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 or "").split(";", 1)[0].strip().lower()
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 or "").split(";", maxsplit=1)[0].strip().lower()
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 or "").split(";", maxsplit=1)[0].strip().lower()
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", "").split(";")[0].strip().lower()
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 infer_mime_type_from_file_name, resolve_mime_type
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 or "").split(";", maxsplit=1)[0].strip().lower()
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 or "").split(";", maxsplit=1)[0].strip().lower()
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.split(";", 1)[0].strip().lower()
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 _normalize_mime_type(mime_type: str | None) -> str:
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 == "application/octet-stream":
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 != "application/octet-stream":
1060
+ if declared_mime and declared_mime != OCTET_STREAM:
1019
1061
  mime_type = declared_mime
1020
- elif detected_mime and detected_mime != "application/octet-stream":
1062
+ elif detected_mime and detected_mime != OCTET_STREAM:
1021
1063
  mime_type = detected_mime
1022
- elif inferred_mime and inferred_mime != "application/octet-stream":
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 "application/octet-stream"
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 == "application/octet-stream" and inferred_mime != "application/octet-stream":
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 == "application/octet-stream"
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 "application/octet-stream"
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
- LibreOffice is an *optional system dependency*: when no ``soffice`` binary is
10
- found the conversion returns a structured error and callers degrade gracefully
11
- (the file stays a binary asset with a ``parse_error``).
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
- # Non-PATH install locations checked after shutil.which().
50
- _SOFFICE_FALLBACK_PATHS = (
51
- "/Applications/LibreOffice.app/Contents/MacOS/soffice",
52
- "/usr/lib/libreoffice/program/soffice",
53
- "/opt/libreoffice/program/soffice",
54
- "C:\\Program Files\\LibreOffice\\program\\soffice.exe",
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
- for candidate in _SOFFICE_FALLBACK_PATHS:
70
- if Path(candidate).exists():
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")