classifyre-cli 0.4.45__py3-none-any.whl → 0.4.46__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: classifyre-cli
3
- Version: 0.4.45
3
+ Version: 0.4.46
4
4
  Summary: Classifyre CLI — scan and classify unstructured data sources
5
5
  License: MIT
6
6
  Keywords: data,ingestion,metadata,pii,secrets,unstructured
@@ -31,7 +31,7 @@ src/detectors/threat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
31
31
  src/detectors/threat/code_security_detector.py,sha256=SdjhvTSU10wHVxP2EwyzoTGMq8-J8CtwypayHVHdMUs,7049
32
32
  src/detectors/threat/yara_detector.py,sha256=VuJ6Tu3o3wMhv2bskSx2YaB7sTILuQaZlK0uWEiLqe0,6549
33
33
  src/models/generated_detectors.py,sha256=bDTV8bBG2IJhC37lYu32t_-VTEf-pTPqB_Q-yTMclIo,47709
34
- src/models/generated_input.py,sha256=kU6vivkgnexaDRhmlYraeSfKzZ9deIQ3b-6y24kDu88,112653
34
+ src/models/generated_input.py,sha256=XA81Wk9H5vFhtRIcExXrPP3WNENDjkeRILzImGIODcQ,115008
35
35
  src/models/generated_single_asset_scan_results.py,sha256=BRHBLb9wAKghAF60KPJEYPio2i2tklmbt5kMTLLt2qY,7761
36
36
  src/outputs/__init__.py,sha256=41SESY2nC1y4zF0CTWn1dnTh9AK7qMdRxmRSN_GuWss,126
37
37
  src/outputs/base.py,sha256=mRFTwqkaWLqyS55OkdbO49yBnSHXlaIGd_bzLyNGj9E,1556
@@ -79,6 +79,8 @@ src/sources/jira/__init__.py,sha256=p3G96i94D6ZrdHKE6bzgVtYvO8h2-Aet4zERRYdJAyA,
79
79
  src/sources/jira/source.py,sha256=OApvZQTQC6sV4qKN3jZMpaA2gTdC0U-ky2VTgL2G3ts,24559
80
80
  src/sources/kafka/__init__.py,sha256=BJoJs6vgviCpcyJXuUf70i46XqRnBrqcT9dJJZtHC5o,59
81
81
  src/sources/kafka/source.py,sha256=3WAJGobP85HHSozH2noaSnsJmEtTkD22AcBrmvaYfy4,17610
82
+ src/sources/local_folder/__init__.py,sha256=WYo83Wg7ONugPZzisjwPkaWe0TADD7AetTpSXs2k908,71
83
+ src/sources/local_folder/source.py,sha256=ed6DdgqIt-w8thchYs6bQVp89lr8sa9dyg6ms5hI3Vc,5472
82
84
  src/sources/meilisearch/__init__.py,sha256=2ZESbCcM68x8B7qF_bRUt0Aah32pdzje_YndSHizAjM,71
83
85
  src/sources/meilisearch/source.py,sha256=2Gm3WA_1llxmmdrRkmDyOZfMg_Oo8I16sbrGXM8AXyk,14487
84
86
  src/sources/mongodb/__init__.py,sha256=IcSTuVMB3JOXAjYpwFcFvRuc2a7m6xgZ1VG5AfM4eJU,63
@@ -130,7 +132,7 @@ src/utils/resources.py,sha256=nE3YBKNBCFMnFErQcO9DKkatHPFQfFt-OsXCpoEz6sA,2125
130
132
  src/utils/transcription.py,sha256=_wr0DuPnzBb8tR4IgmMcS0PDSgJ5YbvIBZSx0_iXgIs,14067
131
133
  src/utils/uv_sync.py,sha256=VZ0TZ7t3_pSjxOb20BxUs2XLVjigXnVqa5y69XWlnE8,9307
132
134
  src/utils/validation.py,sha256=ab6OzB-GeZBMr413U10jG2FDiiWXCil-_e70T0BXwFE,1694
133
- classifyre_cli-0.4.45.dist-info/METADATA,sha256=BHEwscKkcXC-TzeG6b9DWnPxt1jJAukaEfB_vMuASlE,4222
134
- classifyre_cli-0.4.45.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
135
- classifyre_cli-0.4.45.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
136
- classifyre_cli-0.4.45.dist-info/RECORD,,
135
+ classifyre_cli-0.4.46.dist-info/METADATA,sha256=jWJZvJ0w4g_B0KYyTJiG9iMkpU74DBiQceAJItsWmHU,4222
136
+ classifyre_cli-0.4.46.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
137
+ classifyre_cli-0.4.46.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
138
+ classifyre_cli-0.4.46.dist-info/RECORD,,
@@ -51,6 +51,7 @@ class AssetType(StrEnum):
51
51
  ELASTICSEARCH = 'ELASTICSEARCH'
52
52
  OPENSEARCH = 'OPENSEARCH'
53
53
  MEILISEARCH = 'MEILISEARCH'
54
+ LOCAL_FOLDER = 'LOCAL_FOLDER'
54
55
 
55
56
 
56
57
  class DetectorType(StrEnum):
@@ -355,6 +356,7 @@ class Type(StrEnum):
355
356
  ELASTICSEARCH = 'ELASTICSEARCH'
356
357
  OPENSEARCH = 'OPENSEARCH'
357
358
  MEILISEARCH = 'MEILISEARCH'
359
+ LOCAL_FOLDER = 'LOCAL_FOLDER'
358
360
 
359
361
 
360
362
  class YouTubeRequired(BaseModel):
@@ -644,6 +646,64 @@ class S3CompatibleStorageOptional(BaseModel):
644
646
  scope: ObjectStorageOptionalScope | None = None
645
647
 
646
648
 
649
+ class LocalFolderRequired(BaseModel):
650
+ model_config = ConfigDict(
651
+ extra='forbid',
652
+ )
653
+ path: str = Field(
654
+ ...,
655
+ description='Absolute path of the local folder to scan (for example, /Users/jane/Documents)',
656
+ )
657
+
658
+
659
+ class LocalFolderMasked(BaseModel):
660
+ """
661
+ Local folder scans run with the desktop application's filesystem permissions; no credentials are required.
662
+ """
663
+
664
+ model_config = ConfigDict(
665
+ extra='forbid',
666
+ )
667
+
668
+
669
+ class LocalFolderOptionalTraversal(BaseModel):
670
+ """
671
+ Directory traversal controls.
672
+ """
673
+
674
+ model_config = ConfigDict(
675
+ extra='forbid',
676
+ )
677
+ follow_symlinks: bool | None = Field(
678
+ False,
679
+ description='Follow symbolic links while traversing (loops are not detected; leave off unless needed)',
680
+ )
681
+ include_hidden: bool | None = Field(
682
+ False,
683
+ description='Include hidden files and dot-directories (names starting with a dot)',
684
+ )
685
+ max_depth: int | None = Field(
686
+ None,
687
+ description='Maximum directory depth below the selected folder (unset scans all levels)',
688
+ ge=1,
689
+ le=64,
690
+ )
691
+ max_file_bytes: int | None = Field(
692
+ 10485760,
693
+ description='Maximum bytes read per file for MIME detection and text extraction',
694
+ ge=1024,
695
+ le=104857600,
696
+ )
697
+
698
+
699
+ class LocalFolderOptional(BaseModel):
700
+ model_config = ConfigDict(
701
+ extra='forbid',
702
+ )
703
+ traversal: LocalFolderOptionalTraversal | None = None
704
+ scope: ObjectStorageOptionalScope | None = None
705
+
706
+
647
707
  class AzureBlobStorageRequired(BaseModel):
648
708
  model_config = ConfigDict(
649
709
  extra='forbid',
@@ -2100,6 +2160,24 @@ class S3CompatibleStorageInput(CoreInput):
2100
2160
  resources: ResourceOverrides | None = None
2101
2161
 
2102
2162
 
2163
+ class LocalFolderInput(CoreInput):
2164
+ type: Literal['LOCAL_FOLDER'] | None = Field(
2165
+ None, description='Type of the asset or source'
2166
+ )
2167
+ required: LocalFolderRequired
2168
+ masked: LocalFolderMasked | None = None
2169
+ optional: LocalFolderOptional | None = None
2170
+ detectors: list[Detector] | None = Field(
2171
+ None, description='Detectors to run on ingested content'
2172
+ )
2173
+ custom_detectors: list[CustomDetectorSelection] | None = Field(
2174
+ None,
2175
+ description='Reusable custom detector IDs selected from the custom detector catalog.',
2176
+ )
2177
+ sampling: SamplingConfig
2178
+ resources: ResourceOverrides | None = None
2179
+
2180
+
2103
2181
  class AzureBlobStorageInput(CoreInput):
2104
2182
  type: Literal['AZURE_BLOB_STORAGE'] | None = Field(
2105
2183
  None, description='Type of the asset or source'
@@ -2531,7 +2609,7 @@ class ConfluenceOptionalConnection(BaseModel):
2531
2609
  )
2532
2610
 
2533
2611
 
2534
- class Type18(StrEnum):
2612
+ class Type19(StrEnum):
2535
2613
  """
2536
2614
  Filter spaces by space type
2537
2615
  """
@@ -2568,7 +2646,7 @@ class ConfluenceOptionalScopeSpaces(BaseModel):
2568
2646
  keys: list[str] | None = Field(
2569
2647
  None, description='Filter spaces by keys (up to 250)', max_length=250
2570
2648
  )
2571
- type: Type18 | None = Field(None, description='Filter spaces by space type')
2649
+ type: Type19 | None = Field(None, description='Filter spaces by space type')
2572
2650
  status: Status | None = Field(None, description='Filter spaces by status')
2573
2651
  labels: list[str] | None = Field(
2574
2652
  None,
@@ -2834,7 +2912,7 @@ class ServiceDeskOptional(BaseModel):
2834
2912
  content: ServiceDeskOptionalContent | None = None
2835
2913
 
2836
2914
 
2837
- class Type19(StrEnum):
2915
+ class Type20(StrEnum):
2838
2916
  """
2839
2917
  Type of the asset or source
2840
2918
  """
@@ -2868,6 +2946,7 @@ class Type19(StrEnum):
2868
2946
  ELASTICSEARCH = 'ELASTICSEARCH'
2869
2947
  OPENSEARCH = 'OPENSEARCH'
2870
2948
  MEILISEARCH = 'MEILISEARCH'
2949
+ LOCAL_FOLDER = 'LOCAL_FOLDER'
2871
2950
 
2872
2951
 
2873
2952
  class ConfluenceInput(CoreInput):
@@ -3682,6 +3761,7 @@ class SourceInput(
3682
3761
  RootModel[
3683
3762
  SlackInput
3684
3763
  | S3CompatibleStorageInput
3764
+ | LocalFolderInput
3685
3765
  | AzureBlobStorageInput
3686
3766
  | GoogleCloudStorageInput
3687
3767
  | PostgreSQLInput
@@ -3714,6 +3794,7 @@ class SourceInput(
3714
3794
  root: (
3715
3795
  SlackInput
3716
3796
  | S3CompatibleStorageInput
3797
+ | LocalFolderInput
3717
3798
  | AzureBlobStorageInput
3718
3799
  | GoogleCloudStorageInput
3719
3800
  | PostgreSQLInput
@@ -0,0 +1,3 @@
1
+ from .source import LocalFolderSource
2
+
3
+ __all__ = ["LocalFolderSource"]
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from collections.abc import Iterator
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from ...models.generated_input import LocalFolderInput
12
+ from ..object_storage.base import ObjectRef, ObjectStorageSourceBase
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class LocalFolderSource(ObjectStorageSourceBase):
18
+ """Scans a folder on the local filesystem (desktop application deployments).
19
+
20
+ Reuses the object-storage pipeline: each file becomes an ObjectRef whose key
21
+ is the path relative to the scanned root, so sampling, MIME resolution, text
22
+ extraction, file metadata, and embedded-image child assets all come from
23
+ ObjectStorageSourceBase.
24
+ """
25
+
26
+ source_type = "local_folder"
27
+ provider_label = "LOCAL_FOLDER"
28
+ input_model = LocalFolderInput
29
+
30
+ DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024
31
+
32
+ _resolved_root: Path | None = None
33
+
34
+ def _root(self) -> Path:
35
+ if self._resolved_root is not None:
36
+ return self._resolved_root
37
+ raw = str(self.config.required.path).strip()
38
+ if not raw:
39
+ raise ValueError("required.path must be set")
40
+ root = Path(raw).expanduser()
41
+ if not root.is_absolute():
42
+ raise ValueError(f"required.path must be an absolute path, got: {raw}")
43
+ if not root.exists():
44
+ raise ValueError(f"Folder does not exist: {root}")
45
+ if not root.is_dir():
46
+ raise ValueError(f"Path is not a folder: {root}")
47
+ self._resolved_root = root.resolve()
48
+ return self._resolved_root
49
+
50
+ def _traversal_option(self, key: str, default: Any = None) -> Any:
51
+ optional = self.config.optional
52
+ traversal = getattr(optional, "traversal", None) if optional else None
53
+ if traversal is not None:
54
+ value = getattr(traversal, key, None)
55
+ if value is not None:
56
+ return value
57
+ return default
58
+
59
+ def _follow_symlinks(self) -> bool:
60
+ return bool(self._traversal_option("follow_symlinks", False))
61
+
62
+ def _include_hidden(self) -> bool:
63
+ return bool(self._traversal_option("include_hidden", False))
64
+
65
+ def _max_depth(self) -> int | None:
66
+ value = self._traversal_option("max_depth")
67
+ try:
68
+ return int(value) if value is not None else None
69
+ except (TypeError, ValueError):
70
+ return None
71
+
72
+ def _max_file_bytes(self) -> int:
73
+ value = self._traversal_option("max_file_bytes", self.DEFAULT_MAX_FILE_BYTES)
74
+ try:
75
+ parsed = int(value)
76
+ except (TypeError, ValueError):
77
+ return self.DEFAULT_MAX_FILE_BYTES
78
+ return max(parsed, 1024)
79
+
80
+ def _is_hidden(self, name: str) -> bool:
81
+ return name.startswith(".")
82
+
83
+ def _walk(self, directory: Path, depth: int) -> Iterator[ObjectRef]:
84
+ max_depth = self._max_depth()
85
+ follow_symlinks = self._follow_symlinks()
86
+ include_hidden = self._include_hidden()
87
+
88
+ try:
89
+ entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
90
+ except OSError as exc:
91
+ logger.warning("Skipping unreadable directory %s: %s", directory, exc)
92
+ return
93
+
94
+ for entry in entries:
95
+ if not include_hidden and self._is_hidden(entry.name):
96
+ continue
97
+ try:
98
+ if entry.is_dir(follow_symlinks=follow_symlinks):
99
+ if max_depth is None or depth < max_depth:
100
+ yield from self._walk(Path(entry.path), depth + 1)
101
+ continue
102
+ if not entry.is_file(follow_symlinks=follow_symlinks):
103
+ continue
104
+ stat = entry.stat(follow_symlinks=follow_symlinks)
105
+ except OSError as exc:
106
+ logger.warning("Skipping unreadable entry %s: %s", entry.path, exc)
107
+ continue
108
+
109
+ key = Path(entry.path).relative_to(self._root()).as_posix()
110
+ if not self._object_matches_extension_filters(key):
111
+ continue
112
+ prefix = self._prefix()
113
+ if prefix and not key.startswith(prefix):
114
+ continue
115
+ if stat.st_size == 0 and not self._include_empty_objects():
116
+ continue
117
+
118
+ yield ObjectRef(
119
+ key=key,
120
+ size=stat.st_size,
121
+ last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
122
+ # Change signature: mtime+size, so edits produce a new checksum
123
+ # without hashing file bytes during discovery.
124
+ etag=f"{stat.st_mtime_ns:x}-{stat.st_size:x}",
125
+ )
126
+
127
+ def _list_objects(self) -> Iterator[ObjectRef]:
128
+ yield from self._walk(self._root(), depth=0)
129
+
130
+ def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
131
+ file_path = self._root() / ref.key
132
+ max_bytes = self._max_file_bytes()
133
+ with open(file_path, "rb") as handle:
134
+ file_bytes = handle.read(max_bytes)
135
+ if ref.size > max_bytes:
136
+ logger.info(
137
+ "Truncated %s to %d of %d bytes for content extraction",
138
+ file_path,
139
+ max_bytes,
140
+ ref.size,
141
+ )
142
+ return file_bytes, None
143
+
144
+ def _external_url(self, key: str) -> str:
145
+ absolute = (self._root() / key).as_posix()
146
+ return f"file://{quote(absolute)}"