classifyre-cli 0.4.57__tar.gz → 0.4.58__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 (256) hide show
  1. classifyre_cli-0.4.58/.turbo/turbo-build.log +3 -0
  2. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/PKG-INFO +1 -1
  3. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/package.json +1 -1
  4. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/pyproject.toml +16 -3
  5. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/file_evaluation/runner.py +5 -0
  6. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/main.py +70 -8
  7. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/object_storage/base.py +141 -6
  8. classifyre_cli-0.4.58/src/utils/archive_extraction.py +281 -0
  9. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/file_metadata.py +19 -1
  10. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/file_parser.py +346 -21
  11. classifyre_cli-0.4.58/src/utils/legacy_office.py +152 -0
  12. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_local_folder_source.py +109 -0
  13. classifyre_cli-0.4.58/tests/test_main.py +36 -0
  14. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_s3_compatible_storage_source.py +45 -0
  15. classifyre_cli-0.4.58/tests/utils/test_archive_extraction.py +153 -0
  16. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_file_parser.py +287 -0
  17. classifyre_cli-0.4.58/tests/utils/test_legacy_office.py +66 -0
  18. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/uv.lock +616 -426
  19. classifyre_cli-0.4.57/.turbo/turbo-build.log +0 -3
  20. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/.gitignore +0 -0
  21. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/.python-version +0 -0
  22. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/README.md +0 -0
  23. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/main.py +0 -0
  24. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/scripts/generate_models.py +0 -0
  25. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/__init__.py +0 -0
  26. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/config.py +0 -0
  27. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/__init__.py +0 -0
  28. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/base.py +0 -0
  29. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/broken_links/__init__.py +0 -0
  30. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/broken_links/detector.py +0 -0
  31. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/config.py +0 -0
  32. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/content/__init__.py +0 -0
  33. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/__init__.py +0 -0
  34. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/detector.py +0 -0
  35. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/extractor.py +0 -0
  36. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/__init__.py +0 -0
  37. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_base.py +0 -0
  38. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_factory.py +0 -0
  39. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_gliner2.py +0 -0
  40. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_image_classification.py +0 -0
  41. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_llm.py +0 -0
  42. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_object_detection.py +0 -0
  43. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_regex.py +0 -0
  44. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/runners/_text_classification.py +0 -0
  45. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/custom/trainer.py +0 -0
  46. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/dependencies.py +0 -0
  47. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/pii/__init__.py +0 -0
  48. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/pii/detector.py +0 -0
  49. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/secrets/__init__.py +0 -0
  50. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/secrets/detector.py +0 -0
  51. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/threat/__init__.py +0 -0
  52. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/threat/code_security_detector.py +0 -0
  53. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/detectors/threat/yara_detector.py +0 -0
  54. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/file_evaluation/__init__.py +0 -0
  55. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/models/generated_detectors.py +0 -0
  56. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/models/generated_input.py +0 -0
  57. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/models/generated_single_asset_scan_results.py +0 -0
  58. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/outputs/__init__.py +0 -0
  59. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/outputs/base.py +0 -0
  60. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/outputs/console.py +0 -0
  61. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/outputs/factory.py +0 -0
  62. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/outputs/file.py +0 -0
  63. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/outputs/rest.py +0 -0
  64. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/pipeline/__init__.py +0 -0
  65. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/pipeline/content_provider.py +0 -0
  66. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/pipeline/detector_pipeline.py +0 -0
  67. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/pipeline/parsed_content_provider.py +0 -0
  68. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/pipeline/text_artifact.py +0 -0
  69. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/pipeline/worker_pool.py +0 -0
  70. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/__init__.py +0 -0
  71. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/asset_metadata.py +0 -0
  72. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/atlassian_common.py +0 -0
  73. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/azure_blob_storage/__init__.py +0 -0
  74. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/azure_blob_storage/source.py +0 -0
  75. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/base.py +0 -0
  76. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/confluence/__init__.py +0 -0
  77. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/confluence/source.py +0 -0
  78. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/databricks/__init__.py +0 -0
  79. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/databricks/source.py +0 -0
  80. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/delta_lake/__init__.py +0 -0
  81. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/delta_lake/source.py +0 -0
  82. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/dependencies.py +0 -0
  83. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/elasticsearch/__init__.py +0 -0
  84. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/elasticsearch/source.py +0 -0
  85. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/email/__init__.py +0 -0
  86. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/email/source.py +0 -0
  87. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/google_cloud_storage/__init__.py +0 -0
  88. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/google_cloud_storage/source.py +0 -0
  89. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/google_workspace/__init__.py +0 -0
  90. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/google_workspace/source.py +0 -0
  91. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/hive/__init__.py +0 -0
  92. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/hive/source.py +0 -0
  93. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/iceberg/__init__.py +0 -0
  94. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/iceberg/source.py +0 -0
  95. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/jira/__init__.py +0 -0
  96. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/jira/source.py +0 -0
  97. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/kafka/__init__.py +0 -0
  98. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/kafka/source.py +0 -0
  99. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/lakehouse_base.py +0 -0
  100. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/local_folder/__init__.py +0 -0
  101. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/local_folder/source.py +0 -0
  102. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/meilisearch/__init__.py +0 -0
  103. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/meilisearch/source.py +0 -0
  104. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/microsoft_365/__init__.py +0 -0
  105. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/microsoft_365/source.py +0 -0
  106. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/mongodb/__init__.py +0 -0
  107. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/mongodb/source.py +0 -0
  108. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/mssql/__init__.py +0 -0
  109. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/mssql/source.py +0 -0
  110. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/mysql/__init__.py +0 -0
  111. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/mysql/source.py +0 -0
  112. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/neo4j/__init__.py +0 -0
  113. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/neo4j/source.py +0 -0
  114. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/notion/__init__.py +0 -0
  115. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/notion/client.py +0 -0
  116. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/notion/source.py +0 -0
  117. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/opensearch/__init__.py +0 -0
  118. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/opensearch/source.py +0 -0
  119. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/oracle/__init__.py +0 -0
  120. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/oracle/source.py +0 -0
  121. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/postgresql/__init__.py +0 -0
  122. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/postgresql/source.py +0 -0
  123. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/powerbi/__init__.py +0 -0
  124. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/powerbi/source.py +0 -0
  125. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/recipe_normalizer.py +0 -0
  126. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/s3_client.py +0 -0
  127. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/s3_compatible_storage/README.md +0 -0
  128. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/s3_compatible_storage/__init__.py +0 -0
  129. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/s3_compatible_storage/source.py +0 -0
  130. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/sandbox/__init__.py +0 -0
  131. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/sandbox/source.py +0 -0
  132. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/search_engine_base.py +0 -0
  133. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/servicedesk/__init__.py +0 -0
  134. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/servicedesk/source.py +0 -0
  135. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/slack/__init__.py +0 -0
  136. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/slack/source.py +0 -0
  137. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/snowflake/__init__.py +0 -0
  138. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/snowflake/source.py +0 -0
  139. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/sqlite/__init__.py +0 -0
  140. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/sqlite/source.py +0 -0
  141. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/tableau/__init__.py +0 -0
  142. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/tableau/source.py +0 -0
  143. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/tabular_base.py +0 -0
  144. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/tabular_utils.py +0 -0
  145. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/wordpress/__init__.py +0 -0
  146. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/wordpress/source.py +0 -0
  147. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/youtube/__init__.py +0 -0
  148. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/sources/youtube/source.py +0 -0
  149. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/telemetry.py +0 -0
  150. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/__init__.py +0 -0
  151. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/content_extraction.py +0 -0
  152. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/dependency_groups.py +0 -0
  153. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/embedded_images.py +0 -0
  154. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/file_to_images.py +0 -0
  155. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/hashing.py +0 -0
  156. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/resources.py +0 -0
  157. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/transcription.py +0 -0
  158. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/uv_sync.py +0 -0
  159. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/validation.py +0 -0
  160. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/src/utils/video_processing.py +0 -0
  161. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/__init__.py +0 -0
  162. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/_lakehouse_fakes.py +0 -0
  163. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/conftest.py +0 -0
  164. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/__init__.py +0 -0
  165. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
  166. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/conftest.py +0 -0
  167. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/content/__init__.py +0 -0
  168. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/__init__.py +0 -0
  169. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/conftest.py +0 -0
  170. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_gliner2_classification.py +0 -0
  171. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_invoice_extraction.py +0 -0
  172. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_llm_runner.py +0 -0
  173. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_pipeline_integration.py +0 -0
  174. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_regex_runner.py +0 -0
  175. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_runner_hardening.py +0 -0
  176. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/custom/test_transformer_runners.py +0 -0
  177. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/__init__.py +0 -0
  178. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/conftest.py +0 -0
  179. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/sample_invoice.pdf +0 -0
  180. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/test_pii_chunking.py +0 -0
  181. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/test_pii_detector.py +0 -0
  182. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
  183. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/pii/test_pii_severity.py +0 -0
  184. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/secrets/__init__.py +0 -0
  185. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/secrets/test_secrets_detector.py +0 -0
  186. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
  187. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_base_detector.py +0 -0
  188. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
  189. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_detector_catalog_commercial.py +0 -0
  190. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_detector_pipeline_types.py +0 -0
  191. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_detector_schema_examples.py +0 -0
  192. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_detector_types.py +0 -0
  193. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_phase2_detectors.py +0 -0
  194. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/test_registry.py +0 -0
  195. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/threat/__init__.py +0 -0
  196. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/threat/test_code_security_detector.py +0 -0
  197. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/detectors/threat/test_yara_detector.py +0 -0
  198. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
  199. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/integration/test_wordpress_links_assets.py +0 -0
  200. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/pipeline/test_detector_outcomes.py +0 -0
  201. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/pipeline/test_detector_pipeline.py +0 -0
  202. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/pipeline/test_worker_pool.py +0 -0
  203. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_assets_metadata_catalog.py +0 -0
  204. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_azure_blob_storage_source.py +0 -0
  205. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_base_source_attachment.py +0 -0
  206. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_base_source_sampling.py +0 -0
  207. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_config.py +0 -0
  208. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_confluence_source.py +0 -0
  209. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_custom_extractor.py +0 -0
  210. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_databricks_source.py +0 -0
  211. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_delta_lake_source.py +0 -0
  212. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_dependency_groups.py +0 -0
  213. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_elasticsearch_source.py +0 -0
  214. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_email_source.py +0 -0
  215. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_file_evaluation_runner.py +0 -0
  216. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_google_cloud_storage_source.py +0 -0
  217. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_google_workspace_source.py +0 -0
  218. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_hashing.py +0 -0
  219. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_hive_source.py +0 -0
  220. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_iceberg_source.py +0 -0
  221. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_jira_source.py +0 -0
  222. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_kafka_source.py +0 -0
  223. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_meilisearch_source.py +0 -0
  224. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_microsoft_365_source.py +0 -0
  225. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_mongodb_source.py +0 -0
  226. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_mssql_source.py +0 -0
  227. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_mysql_source.py +0 -0
  228. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_neo4j_source.py +0 -0
  229. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_notion_source.py +0 -0
  230. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_opensearch_source.py +0 -0
  231. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_oracle_source.py +0 -0
  232. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_outputs.py +0 -0
  233. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_postgresql_source.py +0 -0
  234. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_powerbi_source.py +0 -0
  235. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_recipe_normalizer.py +0 -0
  236. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_sampling_automatic.py +0 -0
  237. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_sandbox_source.py +0 -0
  238. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_servicedesk_source.py +0 -0
  239. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_slack_source.py +0 -0
  240. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_snowflake_source.py +0 -0
  241. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_source_dependency_groups.py +0 -0
  242. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_source_strategy_conformance.py +0 -0
  243. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_sqlite_source.py +0 -0
  244. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_tableau_source.py +0 -0
  245. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_tabular_automatic_sampling.py +0 -0
  246. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_tabular_utils.py +0 -0
  247. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_uv_sync.py +0 -0
  248. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_wordpress_source.py +0 -0
  249. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_youtube_source.py +0 -0
  250. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/test_youtube_source_integration.py +0 -0
  251. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_content_extraction.py +0 -0
  252. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_embedded_images.py +0 -0
  253. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_file_metadata.py +0 -0
  254. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_file_to_images.py +0 -0
  255. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_transcription.py +0 -0
  256. {classifyre_cli-0.4.57 → classifyre_cli-0.4.58}/tests/utils/test_video_processing.py +0 -0
@@ -0,0 +1,3 @@
1
+ $ uv sync
2
+ Resolved 300 packages in 202ms
3
+ Checked 51 packages in 3ms
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: classifyre-cli
3
- Version: 0.4.57
3
+ Version: 0.4.58
4
4
  Summary: Classifyre CLI — scan and classify unstructured data sources
5
5
  License: MIT
6
6
  Keywords: data,ingestion,metadata,pii,secrets,unstructured
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classifyre/cli",
3
- "version": "0.4.57",
3
+ "version": "0.4.58",
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.57"
3
+ version = "0.4.58"
4
4
  description = "Classifyre CLI — scan and classify unstructured data sources"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -60,7 +60,7 @@ privacy = [
60
60
  # phonenumbers 9.x introduced lazy region loading via __import__ which fails
61
61
  # mid-run in frozen/venv contexts. 8.x eagerly loads all data at import time,
62
62
  # avoiding ModuleNotFoundError during Presidio phone number analysis.
63
- "phonenumbers>=8.13.0,<10.0.0",
63
+ "phonenumbers>=8.13.0,<9.0.0",
64
64
  "numpy>=1.26.0,<3.0.0",
65
65
  ]
66
66
  security = [
@@ -135,6 +135,18 @@ file-processing = [
135
135
  "openpyxl>=3.1.5",
136
136
  "chardet>=7.4.3",
137
137
  "pyarrow>=18.0.0",
138
+ # RTF text extraction (tiny, pure Python).
139
+ "striprtf>=0.0.32",
140
+ # Secure XML parsing (entity-expansion / XXE hardened stdlib drop-in).
141
+ "defusedxml>=0.7.1",
142
+ # Image decoding for GIF-frame extraction and HEIC/HEIF conversion before OCR.
143
+ "pillow>=12.2.0",
144
+ "pillow-heif>=1.4.0",
145
+ # Archive member extraction (zip/tar via stdlib). RAR additionally needs an
146
+ # external unrar/unar/bsdtar backend at runtime; rarfile degrades gracefully
147
+ # when none is installed.
148
+ "py7zr>=1.1.3",
149
+ "rarfile>=4.3",
138
150
  # Table layer: Iceberg metadata resolution + SQL over Parquet without Spark.
139
151
  "pyiceberg>=0.9.0",
140
152
  "duckdb>=1.1.0",
@@ -152,7 +164,8 @@ video = [
152
164
  ]
153
165
  ocr = [
154
166
  { include-group = "file-processing" },
155
- "docling>=2.94.0",
167
+ # >=2.113 for the OpenDocument (odt/ods/odp) and email (eml/msg) backends.
168
+ "docling>=2.113.0",
156
169
  "opencv-python-headless>=5.0.0.93,<6.0.0",
157
170
  "rapidocr-onnxruntime>=1.4.0",
158
171
  # torchvision is a transitive dep of docling-ibm-models, but [tool.uv.sources]
@@ -20,6 +20,10 @@ class FileEvaluationRunner:
20
20
 
21
21
  def __init__(self, detectors_config: list[dict[str, Any]]) -> None:
22
22
  self._config = detectors_config
23
+ # Detector construction failures, surfaced in the evaluate-file JSON
24
+ # output so callers can distinguish "misconfigured detector" from
25
+ # "no findings".
26
+ self.detector_errors: list[str] = []
23
27
 
24
28
  def _build_detectors(self) -> list[Any]:
25
29
  from ..detectors import get_detector
@@ -44,6 +48,7 @@ class FileEvaluationRunner:
44
48
  logger.info(f"Initialized file-evaluation detector: {detector_name}")
45
49
  except Exception as e:
46
50
  logger.error(f"Failed to initialize detector {detector_type}: {e}")
51
+ self.detector_errors.append(f"{detector_type}: {e}")
47
52
 
48
53
  return detectors
49
54
 
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import asyncio
2
3
  import json
3
4
  import logging
4
5
  import os
@@ -17,6 +18,7 @@ _SURROGATE_RE = re.compile(r"[\ud800-\udfff]")
17
18
 
18
19
  _TIMEOUT_PHRASES = ("timed out", "timeout", "connection reset", "errno 110", "connection refused")
19
20
  _TIMEOUT_MYSQL_CODES = {2003, 2006, 2013}
21
+ _TEXT_CHUNK_EMIT_ATTEMPTS = 3
20
22
 
21
23
 
22
24
  def _is_timeout_error(exc: BaseException) -> bool:
@@ -43,6 +45,34 @@ def _sanitize_for_json(value: Any) -> Any:
43
45
  return value
44
46
 
45
47
 
48
+ async def _emit_text_chunks_with_retry(
49
+ sink: Any,
50
+ asset_hash: str,
51
+ artifact: Any,
52
+ *,
53
+ attempts: int = _TEXT_CHUNK_EMIT_ATTEMPTS,
54
+ ) -> None:
55
+ """Persist chunk provenance, retrying transient API failures.
56
+
57
+ The caller keeps the already-persisted asset successful, but propagates a
58
+ final failure at run level so missing chunks/embeddings cannot look healthy.
59
+ """
60
+ for attempt in range(1, attempts + 1):
61
+ try:
62
+ await sink.emit_text_chunks(asset_hash, artifact)
63
+ return
64
+ except Exception:
65
+ if attempt == attempts:
66
+ raise
67
+ logger.warning(
68
+ "Text-chunk emission failed for asset %s (attempt %d/%d); retrying",
69
+ asset_hash,
70
+ attempt,
71
+ attempts,
72
+ )
73
+ await asyncio.sleep(0.25 * (2 ** (attempt - 1)))
74
+
75
+
46
76
  def setup_logging() -> None:
47
77
  """Configure the logging facility."""
48
78
  logging.basicConfig(
@@ -250,13 +280,18 @@ async def run_command_async(args: argparse.Namespace, recipe: dict[str, Any]) ->
250
280
  stub_batch = [_asset_to_payload(asset) for asset in raw_batch]
251
281
  for stub in stub_batch:
252
282
  stub["findings"] = []
253
- await sink.emit_batch(stub_batch, skip_findings=True)
254
- output_batch_count += 1
255
283
 
284
+ # Register BEFORE ingesting: the API stamps per-run
285
+ # change_type onto runner_assets rows during ingest, so
286
+ # the row must exist or the CREATED stamp is lost and
287
+ # first-run counters report every asset as unchanged.
256
288
  hashes = [s["hash"] for s in stub_batch if s.get("hash")]
257
289
  if hasattr(sink, "register_discovered_assets") and hashes:
258
290
  await sink.register_discovered_assets(hashes)
259
291
 
292
+ await sink.emit_batch(stub_batch, skip_findings=True)
293
+ output_batch_count += 1
294
+
260
295
  all_stubs.extend(raw_batch)
261
296
  logger.info(
262
297
  "Discovered %s assets (total: %s)",
@@ -269,13 +304,11 @@ async def run_command_async(args: argparse.Namespace, recipe: dict[str, Any]) ->
269
304
 
270
305
  # --- Phase 2: Processing ---
271
306
  if all_stubs:
272
- import asyncio as _asyncio
273
-
274
307
  processed_count = 0
275
308
  _pw = worker_pool.max_workers if worker_pool else 4
276
309
  max_concurrent = args.max_concurrent_assets or (_pw * 2)
277
310
  max_concurrent = max(1, max_concurrent)
278
- _asset_semaphore = _asyncio.Semaphore(max_concurrent)
311
+ _asset_semaphore = asyncio.Semaphore(max_concurrent)
279
312
  logger.info(
280
313
  "Phase 2 starting: %d assets, pool_workers=%s, max_concurrent_assets=%d",
281
314
  len(all_stubs),
@@ -283,6 +316,7 @@ async def run_command_async(args: argparse.Namespace, recipe: dict[str, Any]) ->
283
316
  max_concurrent,
284
317
  )
285
318
  error_count = 0
319
+ chunk_errors: list[str] = []
286
320
 
287
321
  async def _process_one(asset: Any) -> None:
288
322
  async with _asset_semaphore:
@@ -326,7 +360,24 @@ async def run_command_async(args: argparse.Namespace, recipe: dict[str, Any]) ->
326
360
  if hasattr(sink, "emit_text_chunks"):
327
361
  artifact = pipeline.take_text_artifact(asset_hash)
328
362
  if artifact is not None:
329
- await sink.emit_text_chunks(asset_hash, artifact)
363
+ try:
364
+ await _emit_text_chunks_with_retry(
365
+ sink, asset_hash, artifact
366
+ )
367
+ except Exception as chunk_exc:
368
+ # Findings and the asset remain persisted,
369
+ # preserving B-2's graceful degradation.
370
+ # The run fails after phase 2 so operators
371
+ # cannot mistake missing embeddings for a
372
+ # healthy completed scan.
373
+ detail = f"{asset_hash}: {chunk_exc}"
374
+ chunk_errors.append(detail)
375
+ logger.error(
376
+ "Text-chunk emission exhausted retries"
377
+ " for asset %s: %s",
378
+ asset_hash,
379
+ chunk_exc,
380
+ )
330
381
 
331
382
  if hasattr(sink, "update_asset_status"):
332
383
  f_total, f_by_sev, f_by_det = _compute_findings_counts(
@@ -354,13 +405,22 @@ async def run_command_async(args: argparse.Namespace, recipe: dict[str, Any]) ->
354
405
  except Exception:
355
406
  pass
356
407
 
357
- tasks = [_asyncio.create_task(_process_one(a)) for a in all_stubs]
358
- await _asyncio.gather(*tasks, return_exceptions=True)
408
+ tasks = [asyncio.create_task(_process_one(a)) for a in all_stubs]
409
+ await asyncio.gather(*tasks, return_exceptions=True)
359
410
  logger.info(
360
411
  "Phase 2 complete: %d processed, %d errors",
361
412
  processed_count,
362
413
  error_count,
363
414
  )
415
+ if chunk_errors:
416
+ preview = "; ".join(chunk_errors[:3])
417
+ remaining = len(chunk_errors) - 3
418
+ if remaining > 0:
419
+ preview += f"; and {remaining} more"
420
+ raise RuntimeError(
421
+ f"Text-chunk emission failed for {len(chunk_errors)}"
422
+ f" asset(s) after retries: {preview}"
423
+ )
364
424
 
365
425
  # Persist the advanced AUTOMATIC sampling cursor (no-op for
366
426
  # other strategies, which return None). Only on the normal
@@ -506,6 +566,8 @@ def run_evaluate_file_command(args: argparse.Namespace) -> None:
506
566
  }
507
567
  if parsed.parse_error:
508
568
  output["parse_error"] = parsed.parse_error
569
+ if runner.detector_errors:
570
+ output["detector_errors"] = runner.detector_errors
509
571
  print(json.dumps(_sanitize_for_json(output), ensure_ascii=False))
510
572
  except Exception as e:
511
573
  logger.error("File evaluation failed: %s", e, exc_info=True)
@@ -1,14 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import hashlib
4
5
  import itertools
5
6
  import logging
6
7
  import random
8
+ import tempfile
7
9
  from abc import ABC, abstractmethod
8
10
  from collections.abc import AsyncGenerator, Iterator
9
11
  from dataclasses import dataclass
10
12
  from datetime import UTC, datetime
11
- from pathlib import PurePosixPath
13
+ from pathlib import Path, PurePosixPath
12
14
  from typing import Any
13
15
 
14
16
  from ...models.generated_input import SamplingStrategy
@@ -20,6 +22,7 @@ from ...models.generated_single_asset_scan_results import (
20
22
  Location,
21
23
  SingleAssetScanResults,
22
24
  )
25
+ from ...utils.archive_extraction import ArchiveMember, is_archive_mime, iter_archive_members
23
26
  from ...utils.embedded_images import EmbeddedImage, has_embedded_images, iter_embedded_images
24
27
  from ...utils.file_metadata import extract_file_metadata
25
28
  from ...utils.file_parser import infer_mime_type_from_file_name, resolve_mime_type
@@ -65,6 +68,8 @@ _FILE_EXTENSION_HINTS: dict[str, OutputAssetType] = {
65
68
  ".wav": OutputAssetType.AUDIO,
66
69
  ".aac": OutputAssetType.AUDIO,
67
70
  ".ogg": OutputAssetType.AUDIO,
71
+ ".heic": OutputAssetType.IMAGE,
72
+ ".heif": OutputAssetType.IMAGE,
68
73
  ".pdf": OutputAssetType.BINARY,
69
74
  ".doc": OutputAssetType.BINARY,
70
75
  ".docx": OutputAssetType.BINARY,
@@ -72,11 +77,18 @@ _FILE_EXTENSION_HINTS: dict[str, OutputAssetType] = {
72
77
  ".xlsx": OutputAssetType.TABLE,
73
78
  ".ppt": OutputAssetType.BINARY,
74
79
  ".pptx": OutputAssetType.BINARY,
80
+ ".odt": OutputAssetType.BINARY,
81
+ ".ods": OutputAssetType.TABLE,
82
+ ".odp": OutputAssetType.BINARY,
83
+ ".rtf": OutputAssetType.BINARY,
84
+ ".eml": OutputAssetType.BINARY,
85
+ ".msg": OutputAssetType.BINARY,
75
86
  ".zip": OutputAssetType.BINARY,
76
87
  ".rar": OutputAssetType.BINARY,
77
88
  ".7z": OutputAssetType.BINARY,
78
89
  ".tar": OutputAssetType.BINARY,
79
90
  ".gz": OutputAssetType.BINARY,
91
+ ".tgz": OutputAssetType.BINARY,
80
92
  ".parquet": OutputAssetType.TABLE,
81
93
  ".json": OutputAssetType.TXT,
82
94
  ".xml": OutputAssetType.TXT,
@@ -134,6 +146,11 @@ class ObjectStorageSourceBase(BaseSource, ABC):
134
146
  self._file_processing_deps_checked = False
135
147
  # Keyed by both asset_hash and external_url for O(1) lookup from either.
136
148
  self._bytes_cache: dict[str, bytes] = {}
149
+ # Archive members can expand to 100 MB per container. Keep their
150
+ # discovery-to-processing lifetime on disk so many archives cannot
151
+ # multiply that budget into unbounded resident memory.
152
+ self._archive_bytes_cache: dict[str, Path] = {}
153
+ self._archive_cache_dir: tempfile.TemporaryDirectory[str] | None = None
137
154
  self._mime_cache: dict[str, str] = {}
138
155
  # asset_ids for which fetch_content_pages ran the full bytes path
139
156
  # (even if it produced no text, e.g. all-silence audio). Checked by
@@ -339,6 +356,13 @@ class ObjectStorageSourceBase(BaseSource, ABC):
339
356
  }
340
357
  return mapping.get(asset_type, "file")
341
358
 
359
+ @classmethod
360
+ def _asset_kind_for_mime(cls, mime_type: str | None, asset_type: OutputAssetType) -> str:
361
+ """Catalog kind for an asset: archives get their own kind for display."""
362
+ if mime_type and is_archive_mime(mime_type):
363
+ return "archive"
364
+ return cls._asset_kind_for_asset_type(asset_type)
365
+
342
366
  def _ensure_file_processing_dependencies(self) -> None:
343
367
  if self._file_processing_deps_checked:
344
368
  return
@@ -399,10 +423,9 @@ class ObjectStorageSourceBase(BaseSource, ABC):
399
423
 
400
424
  # Images and audio/video are extractable automatically. Opaque binaries
401
425
  # remain available to binary detectors but have no text page stream.
402
- is_non_extractable = normalized_mime in (
403
- "application/octet-stream",
404
- "application/zip",
405
- )
426
+ # Archives keep their bytes so member files can be expanded into child
427
+ # assets (the container itself still yields no text pages).
428
+ is_non_extractable = normalized_mime == "application/octet-stream"
406
429
 
407
430
  return ContentSnapshot(
408
431
  mime_type=mime_type,
@@ -479,7 +502,9 @@ class ObjectStorageSourceBase(BaseSource, ABC):
479
502
  created_at=ref.last_modified,
480
503
  updated_at=ref.last_modified,
481
504
  runner_id=self.runner_id,
482
- **self.metadata_fields(self._asset_kind_for_asset_type(asset_type), asset_metadata),
505
+ **self.metadata_fields(
506
+ self._asset_kind_for_mime(snapshot.mime_type, asset_type), asset_metadata
507
+ ),
483
508
  )
484
509
  self._hash_to_uri[asset_hash] = external_url
485
510
  self._object_ref_by_hash[asset_hash] = ref
@@ -497,6 +522,18 @@ class ObjectStorageSourceBase(BaseSource, ABC):
497
522
  ref=ref,
498
523
  )
499
524
 
525
+ # Archive containers (zip/tar/gz/7z/rar) expand into one child asset per
526
+ # member file so each member gets its own MIME resolution, text
527
+ # extraction, and detector pass. Nested archives are kept as opaque
528
+ # child assets (single-level expansion) to bound memory and fan-out.
529
+ if snapshot.raw_bytes is not None and is_archive_mime(snapshot.mime_type):
530
+ self._queue_child_archive_assets(
531
+ parent=asset,
532
+ file_bytes=snapshot.raw_bytes,
533
+ mime_type=snapshot.mime_type,
534
+ ref=ref,
535
+ )
536
+
500
537
  return asset
501
538
 
502
539
  def _queue_child_image_assets(
@@ -555,6 +592,84 @@ class ObjectStorageSourceBase(BaseSource, ABC):
555
592
  **self.metadata_fields("image", metadata),
556
593
  )
557
594
 
595
+ def _queue_child_archive_assets(
596
+ self,
597
+ *,
598
+ parent: SingleAssetScanResults,
599
+ file_bytes: bytes,
600
+ mime_type: str,
601
+ ref: ObjectRef,
602
+ ) -> None:
603
+ """Expand archive members into child assets linked from the parent
604
+ (appended to ``parent.links``, never removing existing links)."""
605
+ try:
606
+ for member in iter_archive_members(
607
+ file_bytes,
608
+ mime_type,
609
+ file_name=self._object_file_name(ref),
610
+ ):
611
+ child = self._build_child_archive_member_asset(parent, member, ref)
612
+ self._pending_child_assets.append(child)
613
+ if child.hash not in parent.links:
614
+ parent.links.append(child.hash)
615
+ except Exception as exc:
616
+ logger.warning("Failed to expand archive members from %s: %s", parent.external_url, exc)
617
+
618
+ def _build_child_archive_member_asset(
619
+ self,
620
+ parent: SingleAssetScanResults,
621
+ member: ArchiveMember,
622
+ ref: ObjectRef,
623
+ ) -> SingleAssetScanResults:
624
+ child_url = f"{parent.external_url}#{member.location}"
625
+ child_hash = self.generate_hash_id(child_url)
626
+ member_type = self._asset_type_from_mime_or_key(member.mime_type, member.location)
627
+ metadata: dict[str, Any] = {
628
+ "provider": self.provider_label,
629
+ "object_key": f"{ref.key}#{member.location}",
630
+ "source_hash": parent.hash,
631
+ "location": member.location,
632
+ "mime_type": member.mime_type,
633
+ "size_bytes": len(member.member_bytes),
634
+ }
635
+ file_meta = extract_file_metadata(
636
+ member.member_bytes,
637
+ member.mime_type,
638
+ file_name=member.location,
639
+ )
640
+ metadata.update({k: v for k, v in file_meta.items() if v is not None})
641
+ # Spool member bytes to a per-scan temporary directory. Phase 1 must
642
+ # discover every asset before phase 2 starts (B-4), so retaining these
643
+ # bytes in memory would make the per-archive extraction cap unbounded
644
+ # across the whole source.
645
+ if self._archive_cache_dir is None:
646
+ self._archive_cache_dir = tempfile.TemporaryDirectory(
647
+ prefix="classifyre-archive-members-"
648
+ )
649
+ cache_name = hashlib.sha256(child_hash.encode()).hexdigest()
650
+ member_path = Path(self._archive_cache_dir.name) / cache_name
651
+ member_path.write_bytes(member.member_bytes)
652
+ self._archive_bytes_cache[child_hash] = member_path
653
+ self._archive_bytes_cache[child_url] = member_path
654
+ self._mime_cache[child_hash] = member.mime_type
655
+ self._mime_cache[child_url] = member.mime_type
656
+ self._hash_to_uri[child_hash] = child_url
657
+ return SingleAssetScanResults(
658
+ hash=child_hash,
659
+ checksum=self.calculate_checksum(metadata),
660
+ name=f"{parent.name}#{member.location}",
661
+ external_url=child_url,
662
+ links=[],
663
+ asset_type=member_type,
664
+ source_id=self.source_id,
665
+ created_at=ref.last_modified,
666
+ updated_at=ref.last_modified,
667
+ runner_id=self.runner_id,
668
+ **self.metadata_fields(
669
+ self._asset_kind_for_mime(member.mime_type, member_type), metadata
670
+ ),
671
+ )
672
+
558
673
  def test_connection(self) -> dict[str, Any]:
559
674
  result = {
560
675
  "timestamp": datetime.now(UTC).isoformat(),
@@ -581,6 +696,7 @@ class ObjectStorageSourceBase(BaseSource, ABC):
581
696
  self._hash_to_uri = {}
582
697
  self._object_ref_by_hash = {}
583
698
  self._bytes_cache = {}
699
+ self._reset_archive_cache()
584
700
  self._mime_cache = {}
585
701
  self._content_pages_processed = set()
586
702
  self._pending_child_assets = []
@@ -617,6 +733,10 @@ class ObjectStorageSourceBase(BaseSource, ABC):
617
733
 
618
734
  async def fetch_content_bytes(self, asset_id: str) -> tuple[bytes, str] | None:
619
735
  raw_bytes = self._bytes_cache.get(asset_id)
736
+ if raw_bytes is None:
737
+ member_path = self._archive_bytes_cache.get(asset_id)
738
+ if member_path is not None:
739
+ raw_bytes = member_path.read_bytes()
620
740
  mime = self._mime_cache.get(asset_id, "")
621
741
  if raw_bytes is not None and mime:
622
742
  return raw_bytes, mime
@@ -660,6 +780,10 @@ class ObjectStorageSourceBase(BaseSource, ABC):
660
780
 
661
781
  async def fetch_content_pages(self, asset_id: str) -> AsyncGenerator[tuple[str, str], None]:
662
782
  raw_bytes = self._bytes_cache.get(asset_id)
783
+ if raw_bytes is None:
784
+ member_path = self._archive_bytes_cache.get(asset_id)
785
+ if member_path is not None:
786
+ raw_bytes = member_path.read_bytes()
663
787
  mime = self._mime_cache.get(asset_id, "")
664
788
 
665
789
  logger.info(
@@ -807,20 +931,31 @@ class ObjectStorageSourceBase(BaseSource, ABC):
807
931
 
808
932
  def evict_asset_cache(self, asset_hash: str) -> None:
809
933
  external_url = self._hash_to_uri.get(asset_hash)
934
+ archive_path = self._archive_bytes_cache.pop(asset_hash, None)
810
935
  self._content_cache.pop(asset_hash, None)
811
936
  self._bytes_cache.pop(asset_hash, None)
812
937
  self._mime_cache.pop(asset_hash, None)
813
938
  self._object_ref_by_hash.pop(asset_hash, None)
814
939
  if external_url:
940
+ archive_path = self._archive_bytes_cache.pop(external_url, archive_path)
815
941
  self._content_cache.pop(external_url, None)
816
942
  self._bytes_cache.pop(external_url, None)
817
943
  self._mime_cache.pop(external_url, None)
944
+ if archive_path is not None:
945
+ archive_path.unlink(missing_ok=True)
946
+
947
+ def _reset_archive_cache(self) -> None:
948
+ self._archive_bytes_cache = {}
949
+ if self._archive_cache_dir is not None:
950
+ self._archive_cache_dir.cleanup()
951
+ self._archive_cache_dir = None
818
952
 
819
953
  def abort(self) -> None:
820
954
  logger.info("Aborting object storage extraction...")
821
955
  super().abort()
822
956
 
823
957
  def cleanup(self) -> None:
958
+ self._reset_archive_cache()
824
959
  client = self._cached_client
825
960
  if client is None:
826
961
  return