classifyre-cli 0.4.2__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.
Files changed (101) hide show
  1. classifyre_cli-0.4.2.dist-info/METADATA +167 -0
  2. classifyre_cli-0.4.2.dist-info/RECORD +101 -0
  3. classifyre_cli-0.4.2.dist-info/WHEEL +4 -0
  4. classifyre_cli-0.4.2.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/detectors/__init__.py +105 -0
  7. src/detectors/base.py +97 -0
  8. src/detectors/broken_links/__init__.py +3 -0
  9. src/detectors/broken_links/detector.py +280 -0
  10. src/detectors/config.py +59 -0
  11. src/detectors/content/__init__.py +0 -0
  12. src/detectors/custom/__init__.py +13 -0
  13. src/detectors/custom/detector.py +45 -0
  14. src/detectors/custom/runners/__init__.py +56 -0
  15. src/detectors/custom/runners/_base.py +177 -0
  16. src/detectors/custom/runners/_factory.py +51 -0
  17. src/detectors/custom/runners/_feature_extraction.py +138 -0
  18. src/detectors/custom/runners/_gliner2.py +324 -0
  19. src/detectors/custom/runners/_image_classification.py +98 -0
  20. src/detectors/custom/runners/_llm.py +22 -0
  21. src/detectors/custom/runners/_object_detection.py +107 -0
  22. src/detectors/custom/runners/_regex.py +147 -0
  23. src/detectors/custom/runners/_text_classification.py +109 -0
  24. src/detectors/custom/trainer.py +293 -0
  25. src/detectors/dependencies.py +109 -0
  26. src/detectors/pii/__init__.py +0 -0
  27. src/detectors/pii/detector.py +883 -0
  28. src/detectors/secrets/__init__.py +0 -0
  29. src/detectors/secrets/detector.py +399 -0
  30. src/detectors/threat/__init__.py +0 -0
  31. src/detectors/threat/code_security_detector.py +206 -0
  32. src/detectors/threat/yara_detector.py +177 -0
  33. src/main.py +608 -0
  34. src/models/generated_detectors.py +1296 -0
  35. src/models/generated_input.py +2732 -0
  36. src/models/generated_single_asset_scan_results.py +240 -0
  37. src/outputs/__init__.py +3 -0
  38. src/outputs/base.py +69 -0
  39. src/outputs/console.py +62 -0
  40. src/outputs/factory.py +156 -0
  41. src/outputs/file.py +83 -0
  42. src/outputs/rest.py +258 -0
  43. src/pipeline/__init__.py +7 -0
  44. src/pipeline/content_provider.py +26 -0
  45. src/pipeline/detector_pipeline.py +742 -0
  46. src/pipeline/parsed_content_provider.py +59 -0
  47. src/sandbox/__init__.py +5 -0
  48. src/sandbox/runner.py +145 -0
  49. src/sources/__init__.py +95 -0
  50. src/sources/atlassian_common.py +389 -0
  51. src/sources/azure_blob_storage/__init__.py +3 -0
  52. src/sources/azure_blob_storage/source.py +130 -0
  53. src/sources/base.py +296 -0
  54. src/sources/confluence/__init__.py +3 -0
  55. src/sources/confluence/source.py +733 -0
  56. src/sources/databricks/__init__.py +3 -0
  57. src/sources/databricks/source.py +1279 -0
  58. src/sources/dependencies.py +81 -0
  59. src/sources/google_cloud_storage/__init__.py +3 -0
  60. src/sources/google_cloud_storage/source.py +114 -0
  61. src/sources/hive/__init__.py +3 -0
  62. src/sources/hive/source.py +709 -0
  63. src/sources/jira/__init__.py +3 -0
  64. src/sources/jira/source.py +605 -0
  65. src/sources/mongodb/__init__.py +3 -0
  66. src/sources/mongodb/source.py +550 -0
  67. src/sources/mssql/__init__.py +3 -0
  68. src/sources/mssql/source.py +1034 -0
  69. src/sources/mysql/__init__.py +3 -0
  70. src/sources/mysql/source.py +797 -0
  71. src/sources/neo4j/__init__.py +0 -0
  72. src/sources/neo4j/source.py +523 -0
  73. src/sources/object_storage/base.py +679 -0
  74. src/sources/oracle/__init__.py +3 -0
  75. src/sources/oracle/source.py +982 -0
  76. src/sources/postgresql/__init__.py +3 -0
  77. src/sources/postgresql/source.py +774 -0
  78. src/sources/powerbi/__init__.py +3 -0
  79. src/sources/powerbi/source.py +774 -0
  80. src/sources/recipe_normalizer.py +179 -0
  81. src/sources/s3_compatible_storage/README.md +66 -0
  82. src/sources/s3_compatible_storage/__init__.py +3 -0
  83. src/sources/s3_compatible_storage/source.py +150 -0
  84. src/sources/servicedesk/__init__.py +3 -0
  85. src/sources/servicedesk/source.py +620 -0
  86. src/sources/slack/__init__.py +3 -0
  87. src/sources/slack/source.py +534 -0
  88. src/sources/snowflake/__init__.py +3 -0
  89. src/sources/snowflake/source.py +912 -0
  90. src/sources/tableau/__init__.py +3 -0
  91. src/sources/tableau/source.py +799 -0
  92. src/sources/tabular_utils.py +165 -0
  93. src/sources/wordpress/__init__.py +3 -0
  94. src/sources/wordpress/source.py +590 -0
  95. src/telemetry.py +96 -0
  96. src/utils/__init__.py +1 -0
  97. src/utils/content_extraction.py +108 -0
  98. src/utils/file_parser.py +777 -0
  99. src/utils/hashing.py +82 -0
  100. src/utils/uv_sync.py +79 -0
  101. src/utils/validation.py +56 -0
@@ -0,0 +1,534 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import AsyncGenerator, Iterable
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from ...models.generated_input import (
10
+ SamplingStrategy,
11
+ SlackInput,
12
+ SlackMaskedBotToken,
13
+ SlackMaskedToken,
14
+ SlackMaskedUserToken,
15
+ SlackOptionalChannels,
16
+ SlackOptionalIngestion,
17
+ SlackOptionalTimeRange,
18
+ )
19
+ from ...models.generated_single_asset_scan_results import (
20
+ AssetType as OutputAssetType,
21
+ )
22
+ from ...models.generated_single_asset_scan_results import (
23
+ DetectionResult,
24
+ Location,
25
+ SingleAssetScanResults,
26
+ )
27
+ from ...utils.hashing import hash_id, unhash_id
28
+ from ..base import BaseSource
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class SlackSource(BaseSource):
34
+ source_type = "slack"
35
+ API_BASE = "https://slack.com/api"
36
+
37
+ def __init__(
38
+ self,
39
+ recipe: dict[str, Any],
40
+ source_id: str | None = None,
41
+ runner_id: str | None = None,
42
+ ):
43
+ super().__init__(recipe, source_id, runner_id)
44
+ self.config = SlackInput.model_validate(recipe)
45
+ required = self.config.required
46
+ self.workspace = (
47
+ self._normalize_workspace(required.workspace)
48
+ if required and required.workspace
49
+ else None
50
+ )
51
+ self.team_id: str | None = None
52
+
53
+ self.session = requests.Session()
54
+ self.session.headers.update({"Authorization": f"Bearer {self._get_token()}"})
55
+ self.rate_limit_delay = float(self._ingestion_options().rate_limit_delay_seconds or 0)
56
+
57
+ def _channels_options(self) -> SlackOptionalChannels:
58
+ optional = self.config.optional
59
+ if optional and optional.channels:
60
+ return optional.channels
61
+ return SlackOptionalChannels()
62
+
63
+ def _time_range_options(self) -> SlackOptionalTimeRange:
64
+ optional = self.config.optional
65
+ if optional and optional.time_range:
66
+ return optional.time_range
67
+ return SlackOptionalTimeRange()
68
+
69
+ def _ingestion_options(self) -> SlackOptionalIngestion:
70
+ optional = self.config.optional
71
+ if optional and optional.ingestion:
72
+ return optional.ingestion
73
+ return SlackOptionalIngestion()
74
+
75
+ def _get_token(self) -> str:
76
+ masked = self.config.masked
77
+ if isinstance(masked, SlackMaskedBotToken):
78
+ return masked.bot_token
79
+ if isinstance(masked, SlackMaskedUserToken):
80
+ return masked.user_token
81
+ if isinstance(masked, SlackMaskedToken):
82
+ return masked.token
83
+ raise ValueError("Slack token is required in masked configuration")
84
+
85
+ def _normalize_workspace(self, workspace: str) -> str:
86
+ cleaned = workspace.strip()
87
+ for prefix in ("https://", "http://"):
88
+ if cleaned.startswith(prefix):
89
+ cleaned = cleaned[len(prefix) :]
90
+ return cleaned.strip("/").replace(".slack.com", "")
91
+
92
+ def _request(
93
+ self,
94
+ method: str,
95
+ endpoint: str,
96
+ *,
97
+ params: dict[str, Any] | None = None,
98
+ data: dict[str, Any] | None = None,
99
+ ) -> dict[str, Any]:
100
+ url = f"{self.API_BASE}/{endpoint}"
101
+
102
+ while True:
103
+ try:
104
+ response = self.session.request(
105
+ method,
106
+ url,
107
+ params=params,
108
+ data=data,
109
+ timeout=30,
110
+ )
111
+ except requests.RequestException as exc:
112
+ raise RuntimeError(f"Slack API request failed: {exc}") from exc
113
+
114
+ if response.status_code == 429:
115
+ retry_after = int(response.headers.get("Retry-After", "1"))
116
+ logger.warning(
117
+ "Slack rate limit hit. Retrying after %s seconds...",
118
+ retry_after,
119
+ )
120
+ time.sleep(retry_after)
121
+ continue
122
+
123
+ try:
124
+ payload = response.json()
125
+ except ValueError as exc:
126
+ raise RuntimeError("Slack API returned invalid JSON") from exc
127
+
128
+ if payload.get("ok"):
129
+ if self.rate_limit_delay > 0:
130
+ time.sleep(self.rate_limit_delay)
131
+ return payload
132
+
133
+ if payload.get("error") == "ratelimited":
134
+ retry_after = int(response.headers.get("Retry-After", "1"))
135
+ logger.warning(
136
+ "Slack rate limit hit. Retrying after %s seconds...",
137
+ retry_after,
138
+ )
139
+ time.sleep(retry_after)
140
+ continue
141
+
142
+ raise RuntimeError(f"{endpoint} error: {payload.get('error')}")
143
+
144
+ def test_connection(self) -> dict[str, Any]:
145
+ logger.info("Testing connection to Slack API...")
146
+ result = {
147
+ "timestamp": datetime.now(UTC).isoformat(),
148
+ "source_type": self.recipe.get("type"),
149
+ }
150
+
151
+ try:
152
+ payload = self._request("get", "auth.test")
153
+ self.team_id = payload.get("team_id")
154
+ team_name = payload.get("team")
155
+ result["status"] = "SUCCESS"
156
+ result["message"] = (
157
+ f"Successfully connected to Slack workspace {team_name}."
158
+ if team_name
159
+ else "Successfully connected to Slack."
160
+ )
161
+ except Exception as exc:
162
+ result["status"] = "FAILURE"
163
+ result["message"] = str(exc)
164
+
165
+ return result
166
+
167
+ def _ensure_team_id(self) -> None:
168
+ if self.team_id:
169
+ return
170
+ try:
171
+ payload = self._request("get", "auth.test")
172
+ except Exception:
173
+ logger.debug("Slack auth.test failed while resolving team_id", exc_info=True)
174
+ return
175
+ team_id = payload.get("team_id")
176
+ if isinstance(team_id, str) and team_id.strip():
177
+ self.team_id = team_id.strip()
178
+
179
+ def _normalize_ts(self, value: str | None) -> str | None:
180
+ if not value:
181
+ return None
182
+
183
+ try:
184
+ float(value)
185
+ return value
186
+ except ValueError:
187
+ pass
188
+
189
+ try:
190
+ cleaned = value.replace("Z", "+00:00")
191
+ parsed = datetime.fromisoformat(cleaned)
192
+ except ValueError:
193
+ return None
194
+
195
+ if parsed.tzinfo is None:
196
+ parsed = parsed.replace(tzinfo=UTC)
197
+ return f"{parsed.timestamp():.6f}"
198
+
199
+ def _list_channels(self) -> list[dict[str, Any]]:
200
+ channel_options = self._channels_options()
201
+ channel_types = channel_options.channel_types or ["public_channel"]
202
+ types_param = ",".join(channel_types)
203
+ exclude_archived = channel_options.exclude_archived is not False
204
+ channels: list[dict[str, Any]] = []
205
+ cursor: str | None = None
206
+
207
+ while True:
208
+ params: dict[str, Any] = {
209
+ "types": types_param,
210
+ "limit": 200,
211
+ "exclude_archived": str(exclude_archived).lower(),
212
+ }
213
+ if cursor:
214
+ params["cursor"] = cursor
215
+
216
+ payload = self._request("get", "conversations.list", params=params)
217
+ channels.extend(payload.get("channels", []))
218
+ cursor = payload.get("response_metadata", {}).get("next_cursor")
219
+ if not cursor:
220
+ break
221
+
222
+ return channels
223
+
224
+ def discover(self) -> dict[str, Any]:
225
+ logger.info("Discovering Slack channels...")
226
+ channels = self._list_channels()
227
+ results = []
228
+ for channel in channels:
229
+ results.append(
230
+ {
231
+ "id": channel.get("id"),
232
+ "name": channel.get("name"),
233
+ "is_private": channel.get("is_private"),
234
+ "type": "SLACK_CHANNEL",
235
+ }
236
+ )
237
+ return {"channels": results}
238
+
239
+ STREAM_DETECTIONS = True
240
+
241
+ async def extract_raw(self) -> AsyncGenerator[list[SingleAssetScanResults], None]:
242
+ if self._aborted:
243
+ return
244
+
245
+ logger.info("Extracting Slack messages...")
246
+ self._ensure_team_id()
247
+
248
+ channel_ids, channel_lookup = self._resolve_channels()
249
+
250
+ if not channel_ids:
251
+ logger.warning("No Slack channels found to extract.")
252
+ return
253
+
254
+ for channel_id in channel_ids:
255
+ if self._aborted:
256
+ return
257
+
258
+ channel_name = channel_lookup.get(channel_id, channel_id)
259
+ async for batch in self._stream_channel_batches(
260
+ channel_id,
261
+ channel_name,
262
+ ):
263
+ yield batch
264
+
265
+ def _resolve_channels(self) -> tuple[list[str], dict[str, str]]:
266
+ channel_ids = self._channels_options().channel_ids or []
267
+ channel_lookup: dict[str, str] = {}
268
+
269
+ if channel_ids:
270
+ return list(channel_ids), channel_lookup
271
+
272
+ channels = self._list_channels()
273
+ channel_lookup = {
274
+ channel.get("id"): channel.get("name", "") for channel in channels if channel.get("id")
275
+ }
276
+ return list(channel_lookup.keys()), channel_lookup
277
+
278
+ async def _stream_channel_batches(
279
+ self,
280
+ channel_id: str,
281
+ channel_name: str,
282
+ ) -> AsyncGenerator[list[SingleAssetScanResults], None]:
283
+ assets = self._iter_channel_assets(channel_id, channel_name)
284
+ async for batch in self._yield_batches(assets):
285
+ yield batch
286
+
287
+ def _iter_channel_assets(
288
+ self,
289
+ channel_id: str,
290
+ channel_name: str,
291
+ ) -> Iterable[SingleAssetScanResults]:
292
+ for message in self._iter_channel_messages(channel_id):
293
+ if self._aborted:
294
+ return
295
+ yield self._message_to_asset(message, channel_id, channel_name)
296
+
297
+ async def _yield_batches(
298
+ self,
299
+ assets: Iterable[SingleAssetScanResults],
300
+ ) -> AsyncGenerator[list[SingleAssetScanResults], None]:
301
+ batch: list[SingleAssetScanResults] = []
302
+
303
+ for asset in assets:
304
+ if self._aborted:
305
+ return
306
+ batch.append(asset)
307
+
308
+ if len(batch) >= self.BATCH_SIZE:
309
+ yield batch
310
+ batch = []
311
+
312
+ if batch:
313
+ yield batch
314
+
315
+ def _iter_channel_messages(self, channel_id: str) -> Iterable[dict[str, Any]]:
316
+ cursor: str | None = None
317
+ fetched = 0
318
+ ingestion_options = self._ingestion_options()
319
+ time_range_options = self._time_range_options()
320
+ sampling = self.config.sampling
321
+ max_total: int | None = None if sampling.strategy == SamplingStrategy.ALL else 100
322
+ oldest = self._normalize_ts(time_range_options.oldest)
323
+ latest = self._normalize_ts(time_range_options.latest)
324
+ batch_size = min(int(ingestion_options.batch_size or 200), 200)
325
+
326
+ while True:
327
+ if self._aborted:
328
+ break
329
+
330
+ payload_data: dict[str, Any] = {
331
+ "channel": channel_id,
332
+ "limit": batch_size,
333
+ }
334
+
335
+ if cursor:
336
+ payload_data["cursor"] = cursor
337
+ if oldest:
338
+ payload_data["oldest"] = oldest
339
+ if latest:
340
+ payload_data["latest"] = latest
341
+
342
+ payload = self._request("post", "conversations.history", data=payload_data)
343
+ messages = payload.get("messages", [])
344
+
345
+ if not messages:
346
+ break
347
+
348
+ for message in messages:
349
+ yield message
350
+ fetched += 1
351
+ if max_total and fetched >= max_total:
352
+ return
353
+
354
+ cursor = payload.get("response_metadata", {}).get("next_cursor")
355
+ has_more = payload.get("has_more", False)
356
+ if not has_more or not cursor:
357
+ break
358
+
359
+ def _message_to_asset(
360
+ self,
361
+ message: dict[str, Any],
362
+ channel_id: str,
363
+ channel_name: str,
364
+ ) -> SingleAssetScanResults:
365
+ ts = str(message.get("ts", ""))
366
+ thread_ts = message.get("thread_ts")
367
+ edited_ts = message.get("edited", {}).get("ts")
368
+ text = message.get("text", "") or ""
369
+ user = message.get("user") or message.get("bot_id") or message.get("username")
370
+
371
+ created_at = self._parse_ts(ts)
372
+ updated_at = self._parse_ts(edited_ts) if edited_ts else created_at
373
+
374
+ snippet = self._message_snippet(text)
375
+ display_channel = f"#{channel_name}" if channel_name else channel_id
376
+ name = f"{display_channel}: {snippet}" if snippet else f"{display_channel} message {ts}"
377
+
378
+ metadata = {
379
+ "channel_id": channel_id,
380
+ "ts": ts,
381
+ "thread_ts": thread_ts,
382
+ "edited_ts": edited_ts,
383
+ "user": user,
384
+ "text": text,
385
+ "subtype": message.get("subtype"),
386
+ }
387
+
388
+ raw_id = f"{channel_id}_#_{ts}"
389
+ hashed_id = self.generate_hash_id(raw_id)
390
+
391
+ external_url = self.ensure_location(
392
+ self._message_permalink(channel_id, ts),
393
+ fallback=f"slack://channel?channel={channel_id}&message={ts}",
394
+ )
395
+
396
+ return SingleAssetScanResults(
397
+ hash=hashed_id,
398
+ checksum=self.calculate_checksum(metadata),
399
+ name=name,
400
+ external_url=external_url,
401
+ links=[],
402
+ asset_type=OutputAssetType.TXT,
403
+ created_at=created_at,
404
+ updated_at=updated_at,
405
+ source_id=self.source_id,
406
+ runner_id=self.runner_id,
407
+ )
408
+
409
+ def _message_snippet(self, text: str, max_length: int = 120) -> str:
410
+ cleaned = " ".join(text.strip().split())
411
+ if not cleaned:
412
+ return ""
413
+ if len(cleaned) <= max_length:
414
+ return cleaned
415
+ return f"{cleaned[:max_length].rstrip()}..."
416
+
417
+ def _parse_ts(self, ts: str | None) -> datetime:
418
+ if not ts:
419
+ return datetime.now(UTC)
420
+ try:
421
+ return datetime.fromtimestamp(float(ts), tz=UTC)
422
+ except (TypeError, ValueError):
423
+ return datetime.now(UTC)
424
+
425
+ def _message_permalink(self, channel_id: str, ts: str) -> str:
426
+ if self.workspace:
427
+ ts_compact = ts.replace(".", "")
428
+ return f"https://{self.workspace}.slack.com/archives/{channel_id}/p{ts_compact}"
429
+ return f"slack://channel?channel={channel_id}&message={ts}"
430
+
431
+ def generate_hash_id(self, asset_id: str) -> str:
432
+ identity = self.workspace or self.team_id or "slack"
433
+ raw_id = f"{identity}_#_{asset_id}"
434
+ type_value = (
435
+ self.config.type.value if hasattr(self.config.type, "value") else str(self.config.type)
436
+ )
437
+ return hash_id(type_value, raw_id)
438
+
439
+ async def fetch_content(self, asset_id: str) -> tuple[str, str] | None:
440
+ try:
441
+ decoded = asset_id
442
+ if "_#_" not in asset_id:
443
+ try:
444
+ decoded = unhash_id(asset_id)
445
+ except Exception:
446
+ decoded = asset_id
447
+
448
+ # Format: SLACK_#_{workspace}_#_{channel_id}_#_{ts}
449
+ parts = decoded.split("_#_")
450
+ if len(parts) < 4:
451
+ return None
452
+ channel_id = parts[-2]
453
+ ts = parts[-1]
454
+
455
+ message = self._fetch_message(channel_id, ts)
456
+ if not message:
457
+ return None
458
+
459
+ text = message.get("text", "") or ""
460
+ combined_text = text
461
+
462
+ if self._ingestion_options().include_thread_replies:
463
+ thread_ts = message.get("thread_ts") or ts
464
+ replies = self._fetch_thread_replies(channel_id, thread_ts)
465
+ if replies:
466
+ replies_text = "\n".join(self._format_reply(reply) for reply in replies)
467
+ combined_text = f"{text}\n{replies_text}".strip()
468
+
469
+ return combined_text, combined_text
470
+ except Exception as exc:
471
+ logger.error("Failed to fetch Slack message content: %s", exc)
472
+ return None
473
+
474
+ def _fetch_message(self, channel_id: str, ts: str) -> dict[str, Any] | None:
475
+ payload = self._request(
476
+ "post",
477
+ "conversations.history",
478
+ data={
479
+ "channel": channel_id,
480
+ "oldest": ts,
481
+ "latest": ts,
482
+ "inclusive": "true",
483
+ "limit": 1,
484
+ },
485
+ )
486
+ messages = payload.get("messages", [])
487
+ return messages[0] if messages else None
488
+
489
+ def _fetch_thread_replies(self, channel_id: str, thread_ts: str) -> list[dict[str, Any]]:
490
+ replies: list[dict[str, Any]] = []
491
+ cursor: str | None = None
492
+
493
+ while True:
494
+ payload_data: dict[str, Any] = {
495
+ "channel": channel_id,
496
+ "ts": thread_ts,
497
+ "limit": 200,
498
+ }
499
+ if cursor:
500
+ payload_data["cursor"] = cursor
501
+
502
+ payload = self._request("post", "conversations.replies", data=payload_data)
503
+ messages = payload.get("messages", [])
504
+ if messages:
505
+ replies.extend(messages[1:] if cursor is None else messages)
506
+
507
+ cursor = payload.get("response_metadata", {}).get("next_cursor")
508
+ if not payload.get("has_more") or not cursor:
509
+ break
510
+
511
+ return replies
512
+
513
+ def _format_reply(self, reply: dict[str, Any]) -> str:
514
+ user = reply.get("user") or reply.get("bot_id") or "unknown"
515
+ text = reply.get("text", "") or ""
516
+ return f"{user}: {text}".strip()
517
+
518
+ def enrich_finding_location(
519
+ self,
520
+ finding: DetectionResult,
521
+ asset: SingleAssetScanResults,
522
+ text_content: str,
523
+ ) -> None:
524
+ finding.location = Location(path=asset.external_url)
525
+
526
+ def abort(self) -> None:
527
+ logger.info("Aborting Slack extraction...")
528
+ super().abort()
529
+ if hasattr(self, "session"):
530
+ self.session.close()
531
+
532
+ def cleanup(self) -> None:
533
+ if hasattr(self, "session"):
534
+ self.session.close()
@@ -0,0 +1,3 @@
1
+ from .source import SnowflakeSource
2
+
3
+ __all__ = ["SnowflakeSource"]