Samara 0.1.0__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 (65) hide show
  1. samara/__init__.py +58 -0
  2. samara/__main__.py +9 -0
  3. samara/actions/__init__.py +14 -0
  4. samara/actions/base.py +34 -0
  5. samara/actions/http.py +51 -0
  6. samara/actions/move_or_copy_job_files.py +484 -0
  7. samara/alert/__init__.py +11 -0
  8. samara/alert/channels/__init__.py +30 -0
  9. samara/alert/channels/base.py +42 -0
  10. samara/alert/channels/email.py +77 -0
  11. samara/alert/channels/file.py +57 -0
  12. samara/alert/channels/http.py +48 -0
  13. samara/alert/controller.py +103 -0
  14. samara/alert/rules/__init__.py +26 -0
  15. samara/alert/rules/base.py +38 -0
  16. samara/alert/rules/env_vars_matches.py +54 -0
  17. samara/alert/rules/exception_regex.py +48 -0
  18. samara/alert/template.py +58 -0
  19. samara/alert/trigger.py +79 -0
  20. samara/cli.py +248 -0
  21. samara/exceptions.py +154 -0
  22. samara/runtime/__init__.py +0 -0
  23. samara/runtime/controller.py +136 -0
  24. samara/runtime/jobs/__init__.py +14 -0
  25. samara/runtime/jobs/hooks.py +40 -0
  26. samara/runtime/jobs/models/__init__.py +1 -0
  27. samara/runtime/jobs/models/model_extract.py +77 -0
  28. samara/runtime/jobs/models/model_job.py +102 -0
  29. samara/runtime/jobs/models/model_load.py +79 -0
  30. samara/runtime/jobs/models/model_transform.py +84 -0
  31. samara/runtime/jobs/models/transforms/__init__.py +22 -0
  32. samara/runtime/jobs/models/transforms/model_cast.py +54 -0
  33. samara/runtime/jobs/models/transforms/model_drop.py +41 -0
  34. samara/runtime/jobs/models/transforms/model_dropduplicates.py +47 -0
  35. samara/runtime/jobs/models/transforms/model_filter.py +41 -0
  36. samara/runtime/jobs/models/transforms/model_join.py +56 -0
  37. samara/runtime/jobs/models/transforms/model_select.py +45 -0
  38. samara/runtime/jobs/models/transforms/model_withcolumn.py +43 -0
  39. samara/runtime/jobs/polars/.gitkeep +0 -0
  40. samara/runtime/jobs/spark/__init__.py +20 -0
  41. samara/runtime/jobs/spark/extract.py +165 -0
  42. samara/runtime/jobs/spark/function.py +33 -0
  43. samara/runtime/jobs/spark/job.py +138 -0
  44. samara/runtime/jobs/spark/load.py +173 -0
  45. samara/runtime/jobs/spark/schema.py +202 -0
  46. samara/runtime/jobs/spark/session.py +131 -0
  47. samara/runtime/jobs/spark/transform.py +79 -0
  48. samara/runtime/jobs/spark/transforms/__init__.py +39 -0
  49. samara/runtime/jobs/spark/transforms/cast.py +58 -0
  50. samara/runtime/jobs/spark/transforms/drop.py +71 -0
  51. samara/runtime/jobs/spark/transforms/dropduplicates.py +78 -0
  52. samara/runtime/jobs/spark/transforms/filter.py +87 -0
  53. samara/runtime/jobs/spark/transforms/join.py +75 -0
  54. samara/runtime/jobs/spark/transforms/select.py +94 -0
  55. samara/runtime/jobs/spark/transforms/withcolumn.py +95 -0
  56. samara/runtime/jobs/spark/validate.py +150 -0
  57. samara/types.py +297 -0
  58. samara/utils/__init__.py +13 -0
  59. samara/utils/file.py +306 -0
  60. samara/utils/http.py +91 -0
  61. samara/utils/logger.py +44 -0
  62. samara-0.1.0.dist-info/METADATA +214 -0
  63. samara-0.1.0.dist-info/RECORD +65 -0
  64. samara-0.1.0.dist-info/WHEEL +4 -0
  65. samara-0.1.0.dist-info/licenses/LICENSE +396 -0
samara/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """PySpark Ingestion Framework.
2
+
3
+ A scalable, modular framework for building data ingestion pipelines using Apache PySpark.
4
+ This framework provides tools and components for extracting data from various sources,
5
+ transforming it using customizable operations, and loading it into target systems.
6
+
7
+ The framework is built with configurability in mind, allowing pipeline definitions
8
+ through configuration files rather than code changes. It leverages PySpark for
9
+ distributed data processing and follows standard ETL (Extract, Transform, Load) patterns.
10
+
11
+ Example:
12
+ Basic usage of the framework:
13
+
14
+ ```python
15
+ from pathlib import Path
16
+ from samara.runtime.etl.spark.job import Job
17
+
18
+ # Create a job from configuration
19
+ job = Job.from_file(filepath=Path("config.json"))
20
+
21
+ # Execute the pipeline
22
+ job.execute()
23
+ ```
24
+ """
25
+
26
+ __author__ = "Krijn van der Burg"
27
+ __copyright__ = "Krijn van der Burg"
28
+ __credits__ = [""]
29
+ __license__ = "Creative Commons BY-NC-ND 4.0 DEED Attribution-NonCommercial-NoDerivs 4.0 International License"
30
+ __maintainer__ = "Krijn van der Burg"
31
+ __email__ = ""
32
+ __status__ = "Prototype"
33
+
34
+
35
+ from abc import ABC
36
+
37
+ from pydantic import BaseModel as PydanticBaseModel
38
+ from samara.utils.logger import get_logger
39
+
40
+ logger = get_logger(__name__)
41
+
42
+
43
+ class BaseModel(PydanticBaseModel, ABC):
44
+ """Abstract base class for all configuration models in the framework.
45
+
46
+ Defines the common interface that all model classes must implement using Pydantic v2.
47
+ Model classes are responsible for converting dictionary-based configuration
48
+ into strongly-typed objects that can be used by the framework components.
49
+ """
50
+
51
+ # class Config:
52
+ # """Pydantic configuration."""
53
+
54
+ # extra = "forbid"
55
+ # validate_assignment = True
56
+ # str_strip_whitespace = True
57
+ # validate_default = True
58
+ # arbitrary_types_allowed = True
samara/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Samara module entry point.
2
+
3
+ Handles CLI execution using Click framework.
4
+ """
5
+
6
+ from samara.cli import cli
7
+
8
+ if __name__ == "__main__": # pragma: no cover
9
+ cli()
@@ -0,0 +1,14 @@
1
+ """HTTP actions and other action implementations for Spark.
2
+
3
+ This module imports all available action functions to register them with the
4
+ HooksActionsUnion. Each action function is automatically registered
5
+ when imported.
6
+ """
7
+
8
+ from samara.actions.http import HttpAction
9
+
10
+ # from samara.actions.move_or_copy_job_files import MoveOrCopyJobFiles
11
+
12
+ # When there's only one action type, use it directly
13
+ # When there are multiple, use: Annotated[HttpAction | OtherAction, Discriminator("action")]
14
+ HooksActionsUnion = HttpAction
samara/actions/base.py ADDED
@@ -0,0 +1,34 @@
1
+ """Actions base"""
2
+
3
+ from abc import abstractmethod
4
+
5
+ from pydantic import Field
6
+ from samara import BaseModel
7
+ from samara.utils.logger import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ class ActionBase(BaseModel):
13
+ """Base class for defining actions in hooks.
14
+
15
+ Attributes:
16
+ id (str): Unique identifier for the action.
17
+ enabled (bool): Whether the action is enabled.
18
+ parameters (dict): A dictionary of parameters for the action.
19
+ """
20
+
21
+ id_: str = Field(..., alias="id", description="Unique identifier for the action.", min_length=1)
22
+ description: str = Field(..., description="A description of the action.")
23
+ enabled: bool = Field(..., description="Whether the action is enabled.")
24
+
25
+ def execute(self) -> None:
26
+ """Execute the action."""
27
+ if not self.enabled:
28
+ logger.debug("Action '%s' is disabled; skipping execution.", self.id_)
29
+ return
30
+ self._execute()
31
+
32
+ @abstractmethod
33
+ def _execute(self) -> None:
34
+ """Execute the action."""
samara/actions/http.py ADDED
@@ -0,0 +1,51 @@
1
+ """HTTP action for executing HTTP requests in ETL pipelines.
2
+
3
+ This module implements the HTTP action that allows ETL jobs to make
4
+ HTTP requests as part of their execution flow. It supports custom headers,
5
+ different HTTP methods, and configurable timeouts and failure handling.
6
+
7
+ The HttpAction follows the Samara framework patterns for configuration-driven
8
+ initialization and implements the ActionBase interface.
9
+ """
10
+
11
+ import logging
12
+ from typing import Any, Literal
13
+
14
+ from pydantic import Field
15
+ from samara.actions.base import ActionBase
16
+ from samara.utils.http import HttpBase
17
+ from samara.utils.logger import get_logger
18
+ from typing_extensions import override
19
+
20
+ logger: logging.Logger = get_logger(__name__)
21
+
22
+
23
+ class HttpAction(HttpBase, ActionBase):
24
+ """HTTP action for making HTTP requests in ETL pipelines.
25
+
26
+ This class implements HTTP request functionality for ETL jobs,
27
+ allowing pipelines to interact with external HTTP endpoints.
28
+ It inherits HTTP functionality from HttpBase and action functionality
29
+ from ActionBase.
30
+
31
+ Attributes:
32
+ action_type: Always "http" for HTTP actions
33
+ payload: Optional payload data to send in the request
34
+ """
35
+
36
+ action_type: Literal["http"] = Field(..., description="Action type discriminator")
37
+ payload: dict[str, Any] = Field(default_factory=dict, description="Optional payload data to send in the request")
38
+
39
+ @override
40
+ def _execute(self) -> None:
41
+ """Execute the HTTP request action.
42
+
43
+ Makes an HTTP request to the configured endpoint with the
44
+ specified payload and configuration.
45
+
46
+ Raises:
47
+ requests.RequestException: If the HTTP request fails after all retries.
48
+ """
49
+ logger.info("Executing HTTP action: %s", self.id_)
50
+ self._make_http_request(self.payload)
51
+ logger.info("HTTP action completed: %s", self.id_)
@@ -0,0 +1,484 @@
1
+ # // {
2
+ # // "id": "archive-processed-files",
3
+ # // "description": "",
4
+ # // "enabled": true,
5
+ # // "action_type": "move_or_copy_job_files",
6
+ # // "operation": "move", // allowed: "move" | "copy"
7
+ # // "source_location": "incoming/daily/", // filepath, directory, or "dataframe"
8
+ # // "destination_location": "archive/daily/", // filepath or directory (base target)
9
+ # // "hierarchy_base_path": "incoming/daily/", // optional; root used when preserving source hierarchy
10
+ # // "duplicate_handling": {
11
+ # // "dedupe_by": ["size", "checksum"], // options: "checksum" | "size"
12
+ # // "on_match": "skip",
13
+ # // destination already exists and is identical -> allowed: "overwrite" | "version"
14
+ # // "on_mismatch": "version",
15
+ # // destination already exists but is not identical -> allowed: "overwrite" | "version" | "fail" | "notify"
16
+ # // "checksum": {
17
+ # // "algorithm": "sha256", // allowed: "md5", "sha1", "sha256"
18
+ # // "chunk_size_bytes": 65536, // default 65536 (64 KiB)
19
+ # // "verify_checksum_after_transfer": false // optional post-transfer verification
20
+ # // },
21
+ # // "version": {
22
+ # // "datetime_format": "yyyyMMddHHmmss", // format applied when creating versioned filenames
23
+ # // "timestamp_timezone": "UTC"
24
+ # // }
25
+ # // }
26
+ # // }
27
+
28
+
29
+ # """Module for moving files as an action in Samara runtime hooks."""
30
+
31
+ # import hashlib
32
+ # import logging
33
+ # import shutil
34
+ # from datetime import datetime
35
+ # from pathlib import Path
36
+ # from typing import Literal
37
+ # from zoneinfo import ZoneInfo
38
+
39
+ # from pydantic import field_validator
40
+
41
+ # from samara import BaseModel
42
+ # from samara.actions.base import ActionBase
43
+
44
+ # logger = logging.getLogger(__name__)
45
+
46
+
47
+ # class Version(BaseModel):
48
+ # """Model to handle versioning of files.
49
+
50
+ # Attributes:
51
+ # datetime_format: The strftime format string for timestamp (e.g., '%Y%m%d_%H%M%S').
52
+ # timestamp_timezone: The timezone for the timestamp (e.g., 'UTC', 'America/New_York').
53
+ # """
54
+
55
+ # datetime_format: str
56
+ # timestamp_timezone: str
57
+
58
+
59
+ # class Checksum(BaseModel):
60
+ # """Model to handle file checksum verification.
61
+
62
+ # Attributes:
63
+ # algorithm: The checksum algorithm to use (e.g., 'sha256', 'sha512', 'blake2b').
64
+ # chunk_size_bytes: The size of chunks to read when calculating checksums.
65
+ # verify_checksum_after_transfer: Whether to verify checksum after file transfer.
66
+ # """
67
+
68
+ # algorithm: str
69
+ # chunk_size_bytes: int
70
+ # verify_checksum_after_transfer: bool
71
+
72
+ # @field_validator("algorithm")
73
+ # @classmethod
74
+ # def validate_algorithm(cls, v: str) -> str:
75
+ # """Validate and normalize the algorithm name.
76
+
77
+ # Args:
78
+ # v: The algorithm name.
79
+
80
+ # Returns:
81
+ # The lowercase algorithm name.
82
+
83
+ # Raises:
84
+ # ValueError: If the algorithm is not supported by hashlib.
85
+ # """
86
+ # algorithm_lower = v.lower()
87
+ # try:
88
+ # # Test if the algorithm is available
89
+ # hashlib.new(algorithm_lower)
90
+ # except ValueError as exc:
91
+ # raise ValueError(f"Unsupported checksum algorithm: {v}. Algorithm must be supported by hashlib.") from exc
92
+ # return algorithm_lower
93
+
94
+
95
+ # class DuplicateHandling(BaseModel):
96
+ # """Model to handle duplicate file scenarios.
97
+
98
+ # Attributes:
99
+ # dedupe_by: List of strategies to check for duplicates in order (e.g., ['size', 'checksum']).
100
+ # on_match: Action when a duplicate is found ('skip', 'overwrite', 'rename').
101
+ # on_mismatch: Action when files exist but aren't duplicates ('overwrite', 'version', 'rename', 'skip').
102
+ # checksum: Checksum configuration for verification.
103
+ # version: Versioning configuration for file naming.
104
+ # """
105
+
106
+ # dedupe_by: list[Literal["size", "checksum", "name"]]
107
+ # on_match: Literal["skip", "overwrite", "rename"]
108
+ # on_mismatch: Literal["overwrite", "version", "rename", "skip"]
109
+ # checksum: Checksum
110
+ # version: Version
111
+
112
+
113
+ # class MoveOrCopyJobFiles(ActionBase):
114
+ # """Action to move or copy files from source to destination with duplicate handling.
115
+
116
+ # This action processes files from a source location and moves or copies them to a destination,
117
+ # handling duplicates based on configurable strategies. It supports file deduplication by size
118
+ # and checksum, with options for overwrite, skip, rename, or versioning.
119
+
120
+ # Args:
121
+ # action: The action identifier, must be 'move_or_copy_job_files'.
122
+ # source_location: The source file or directory path.
123
+ # destination_location: The destination directory path.
124
+ # hierarchy_base_path: The base path used to construct destination hierarchy.
125
+ # operation: The file operation to perform ('move' or 'copy').
126
+ # duplicate_handling: Configuration for handling duplicate files.
127
+ # """
128
+
129
+ # action: Literal["move_or_copy_job_files"]
130
+ # source_location: str
131
+ # destination_location: str
132
+ # hierarchy_base_path: str
133
+ # operation: Literal["move", "copy"]
134
+ # duplicate_handling: DuplicateHandling
135
+
136
+ # def _execute(self) -> None:
137
+ # """Execute the move or copy files action."""
138
+ # source_path = Path(self.source_location)
139
+ # destination_base = Path(self.destination_location)
140
+ # hierarchy_base = Path(self.hierarchy_base_path)
141
+
142
+ # # Validate source exists
143
+ # if not source_path.exists():
144
+ # error_msg = f"Source path does not exist: {source_path}"
145
+ # logger.error(error_msg)
146
+ # raise FileNotFoundError(error_msg)
147
+
148
+ # # Ensure destination base exists
149
+ # destination_base.mkdir(parents=True, exist_ok=True)
150
+
151
+ # # Process files
152
+ # if source_path.is_file():
153
+ # self._process_file(source_path, destination_base, hierarchy_base)
154
+ # elif source_path.is_dir():
155
+ # self._process_directory(source_path, destination_base, hierarchy_base)
156
+ # else:
157
+ # error_msg = f"Source path is neither a file nor a directory: {source_path}"
158
+ # logger.error(error_msg)
159
+ # raise ValueError(error_msg)
160
+
161
+ # def _process_directory(self, source_dir: Path, destination_base: Path, hierarchy_base: Path) -> None:
162
+ # """Process all files in a directory recursively.
163
+
164
+ # Args:
165
+ # source_dir: The source directory to process.
166
+ # destination_base: The base destination directory.
167
+ # hierarchy_base: The base path for constructing destination hierarchy.
168
+ # """
169
+ # for item in source_dir.rglob("*"):
170
+ # if item.is_file():
171
+ # self._process_file(item, destination_base, hierarchy_base)
172
+
173
+ # def _process_file(self, source_file: Path, destination_base: Path, hierarchy_base: Path) -> None:
174
+ # """Process a single file: determine destination and handle duplicates.
175
+
176
+ # Args:
177
+ # source_file: The source file to process.
178
+ # destination_base: The base destination directory.
179
+ # hierarchy_base: The base path for constructing destination hierarchy.
180
+ # """
181
+ # # Construct destination path maintaining hierarchy
182
+ # destination_path = self._construct_destination_path(source_file, destination_base, hierarchy_base)
183
+
184
+ # # Ensure destination directory exists
185
+ # destination_path.parent.mkdir(parents=True, exist_ok=True)
186
+
187
+ # # Check if destination file exists
188
+ # if destination_path.exists():
189
+ # # Handle duplicate file
190
+ # self._handle_duplicate(source_file, destination_path)
191
+ # else:
192
+ # # No duplicate, check on_mismatch strategy
193
+ # self._handle_new_file(source_file, destination_path)
194
+
195
+ # def _construct_destination_path(self, source_file: Path, destination_base: Path, hierarchy_base: Path) -> Path:
196
+ # """Construct the destination path by maintaining hierarchy relative to base path.
197
+
198
+ # Args:
199
+ # source_file: The source file path.
200
+ # destination_base: The base destination directory.
201
+ # hierarchy_base: The base path for hierarchy calculation.
202
+
203
+ # Returns:
204
+ # The constructed destination path.
205
+ # """
206
+ # try:
207
+ # relative_path = source_file.relative_to(hierarchy_base)
208
+ # except ValueError:
209
+ # # If source is not relative to hierarchy_base, use just the filename
210
+ # relative_path = source_file.name
211
+
212
+ # return destination_base / relative_path
213
+
214
+ # def _handle_duplicate(self, source_file: Path, destination_file: Path) -> None:
215
+ # """Handle a duplicate file based on deduplication strategies.
216
+
217
+ # Args:
218
+ # source_file: The source file path.
219
+ # destination_file: The existing destination file path.
220
+ # """
221
+ # is_duplicate = self._is_duplicate(source_file, destination_file)
222
+
223
+ # if is_duplicate:
224
+ # # Files are duplicates, use on_match strategy
225
+ # logger.info(f"Duplicate file detected: {source_file} -> {destination_file}")
226
+ # self._apply_on_match_strategy(source_file, destination_file)
227
+ # else:
228
+ # # Files are not duplicates, use on_mismatch strategy
229
+ # logger.info(f"File exists but not a duplicate: {source_file} -> {destination_file}")
230
+ # self._apply_on_mismatch_strategy(source_file, destination_file)
231
+
232
+ # def _is_duplicate(self, source_file: Path, destination_file: Path) -> bool:
233
+ # """Determine if two files are duplicates based on configured strategies.
234
+
235
+ # Checks each strategy in order. If any strategy determines files are different,
236
+ # they are not duplicates. All strategies must confirm similarity for a duplicate.
237
+
238
+ # Args:
239
+ # source_file: The source file path.
240
+ # destination_file: The destination file path.
241
+
242
+ # Returns:
243
+ # True if files are duplicates based on all checked strategies, False otherwise.
244
+ # """
245
+ # for strategy in self.duplicate_handling.dedupe_by:
246
+ # if strategy == "size":
247
+ # if not self._compare_size(source_file, destination_file):
248
+ # logger.debug(f"Files differ by size: {source_file}")
249
+ # return False
250
+ # elif strategy == "checksum":
251
+ # if not self._compare_checksum(source_file, destination_file):
252
+ # logger.debug(f"Files differ by checksum: {source_file}")
253
+ # return False
254
+ # elif strategy == "name":
255
+ # if not self._compare_name(source_file, destination_file):
256
+ # logger.debug(f"Files differ by name: {source_file}")
257
+ # return False
258
+ # else:
259
+ # logger.warning(f"Unknown deduplication strategy: {strategy}")
260
+
261
+ # # All strategies confirmed similarity
262
+ # return True
263
+
264
+ # def _compare_size(self, file1: Path, file2: Path) -> bool:
265
+ # """Compare file sizes.
266
+
267
+ # Args:
268
+ # file1: First file path.
269
+ # file2: Second file path.
270
+
271
+ # Returns:
272
+ # True if files have the same size, False otherwise.
273
+ # """
274
+ # return file1.stat().st_size == file2.stat().st_size
275
+
276
+ # def _compare_checksum(self, file1: Path, file2: Path) -> bool:
277
+ # """Compare file checksums using configured algorithm.
278
+
279
+ # Args:
280
+ # file1: First file path.
281
+ # file2: Second file path.
282
+
283
+ # Returns:
284
+ # True if files have the same checksum, False otherwise.
285
+ # """
286
+ # checksum1 = self._calculate_checksum(file1)
287
+ # checksum2 = self._calculate_checksum(file2)
288
+ # return checksum1 == checksum2
289
+
290
+ # def _compare_name(self, file1: Path, file2: Path) -> bool:
291
+ # """Compare file names.
292
+
293
+ # Args:
294
+ # file1: First file path.
295
+ # file2: Second file path.
296
+
297
+ # Returns:
298
+ # True if files have the same name, False otherwise.
299
+ # """
300
+ # return file1.name == file2.name
301
+
302
+ # def _calculate_checksum(self, file_path: Path) -> str:
303
+ # """Calculate file checksum using configured algorithm and chunk size.
304
+
305
+ # Args:
306
+ # file_path: The file to calculate checksum for.
307
+
308
+ # Returns:
309
+ # The hexadecimal checksum string.
310
+
311
+ # Raises:
312
+ # ValueError: If the checksum algorithm is not supported.
313
+ # """
314
+ # # Algorithm is already validated and normalized to lowercase by Pydantic
315
+ # algorithm = self.duplicate_handling.checksum.algorithm
316
+ # chunk_size = self.duplicate_handling.checksum.chunk_size_bytes
317
+
318
+ # try:
319
+ # hash_obj = hashlib.new(algorithm)
320
+ # except ValueError as exc:
321
+ # error_msg = f"Unsupported checksum algorithm: {algorithm}"
322
+ # logger.error(error_msg)
323
+ # raise ValueError(error_msg) from exc
324
+
325
+ # with file_path.open("rb") as file:
326
+ # while chunk := file.read(chunk_size):
327
+ # hash_obj.update(chunk)
328
+
329
+ # return hash_obj.hexdigest()
330
+
331
+ # def _apply_on_match_strategy(self, source_file: Path, destination_file: Path) -> None:
332
+ # """Apply the configured strategy when a duplicate is matched.
333
+
334
+ # Args:
335
+ # source_file: The source file path.
336
+ # destination_file: The destination file path.
337
+
338
+ # Raises:
339
+ # ValueError: If an invalid strategy is configured (should not happen with Pydantic validation).
340
+ # """
341
+ # strategy = self.duplicate_handling.on_match
342
+
343
+ # if strategy == "skip":
344
+ # logger.info(f"Skipping duplicate file: {source_file}")
345
+ # # Do nothing, file already exists
346
+ # elif strategy == "overwrite":
347
+ # logger.info(f"Overwriting duplicate file: {destination_file}")
348
+ # self._transfer_file(source_file, destination_file)
349
+ # elif strategy == "rename":
350
+ # logger.info(f"Renaming and transferring file: {source_file}")
351
+ # new_destination = self._generate_renamed_path(destination_file)
352
+ # self._transfer_file(source_file, new_destination)
353
+ # else:
354
+ # # This should never happen due to Pydantic validation
355
+ # error_msg = f"Invalid on_match strategy: {strategy}"
356
+ # logger.error(error_msg)
357
+ # raise ValueError(error_msg)
358
+
359
+ # def _apply_on_mismatch_strategy(self, source_file: Path, destination_file: Path) -> None:
360
+ # """Apply the configured strategy when files exist but are not duplicates.
361
+
362
+ # Args:
363
+ # source_file: The source file path.
364
+ # destination_file: The destination file path.
365
+
366
+ # Raises:
367
+ # ValueError: If an invalid strategy is configured (should not happen with Pydantic validation).
368
+ # """
369
+ # strategy = self.duplicate_handling.on_mismatch
370
+
371
+ # if strategy == "overwrite":
372
+ # logger.info(f"Overwriting non-duplicate file: {destination_file}")
373
+ # self._transfer_file(source_file, destination_file)
374
+ # elif strategy == "version":
375
+ # logger.info(f"Versioning file: {destination_file}")
376
+ # versioned_destination = self._generate_versioned_path(destination_file)
377
+ # self._transfer_file(source_file, versioned_destination)
378
+ # elif strategy == "rename":
379
+ # logger.info(f"Renaming file: {destination_file}")
380
+ # new_destination = self._generate_renamed_path(destination_file)
381
+ # self._transfer_file(source_file, new_destination)
382
+ # elif strategy == "skip":
383
+ # logger.info(f"Skipping non-duplicate file: {source_file}")
384
+ # else:
385
+ # # This should never happen due to Pydantic validation
386
+ # error_msg = f"Invalid on_mismatch strategy: {strategy}"
387
+ # logger.error(error_msg)
388
+ # raise ValueError(error_msg)
389
+
390
+ # def _handle_new_file(self, source_file: Path, destination_file: Path) -> None:
391
+ # """Handle a new file that doesn't exist at destination.
392
+
393
+ # Args:
394
+ # source_file: The source file path.
395
+ # destination_file: The destination file path.
396
+ # """
397
+ # logger.info(f"Transferring new file: {source_file} -> {destination_file}")
398
+ # self._transfer_file(source_file, destination_file)
399
+
400
+ # def _transfer_file(self, source_file: Path, destination_file: Path) -> None:
401
+ # """Transfer (move or copy) a file from source to destination.
402
+
403
+ # Args:
404
+ # source_file: The source file path.
405
+ # destination_file: The destination file path.
406
+
407
+ # Raises:
408
+ # IOError: If file transfer fails or checksum verification fails.
409
+ # ValueError: If an invalid operation is configured (should not happen with Pydantic validation).
410
+ # """
411
+ # try:
412
+ # if self.operation == "move":
413
+ # shutil.move(str(source_file), str(destination_file))
414
+ # logger.debug(f"Moved file: {source_file} -> {destination_file}")
415
+ # elif self.operation == "copy":
416
+ # shutil.copy2(str(source_file), str(destination_file))
417
+ # logger.debug(f"Copied file: {source_file} -> {destination_file}")
418
+ # else:
419
+ # # This should never happen due to Pydantic validation
420
+ # error_msg = f"Invalid operation: {self.operation}"
421
+ # logger.error(error_msg)
422
+ # raise ValueError(error_msg)
423
+
424
+ # # Verify checksum after transfer if configured
425
+ # if self.duplicate_handling.checksum.verify_checksum_after_transfer:
426
+ # if self.operation == "copy":
427
+ # # For copy, verify source and destination match
428
+ # if not self._compare_checksum(source_file, destination_file):
429
+ # error_msg = f"Checksum verification failed after copy: {destination_file}"
430
+ # logger.error(error_msg)
431
+ # raise IOError(error_msg)
432
+ # logger.debug(f"Checksum verified after copy: {destination_file}")
433
+ # # For move, source no longer exists, so we can't verify
434
+
435
+ # except (OSError, IOError) as exc:
436
+ # error_msg = f"Failed to transfer file {source_file} -> {destination_file}: {exc}"
437
+ # logger.error(error_msg)
438
+ # raise IOError(error_msg) from exc
439
+
440
+ # def _generate_versioned_path(self, file_path: Path) -> Path:
441
+ # """Generate a versioned file path with timestamp.
442
+
443
+ # Args:
444
+ # file_path: The original file path.
445
+
446
+ # Returns:
447
+ # A new path with timestamp appended before the extension.
448
+ # """
449
+ # datetime_format = self.duplicate_handling.version.datetime_format
450
+ # timezone_str = self.duplicate_handling.version.timestamp_timezone
451
+
452
+ # try:
453
+ # timezone = ZoneInfo(timezone_str)
454
+ # except Exception as exc:
455
+ # logger.warning(f"Invalid timezone '{timezone_str}', using UTC: {exc}")
456
+ # timezone = ZoneInfo("UTC")
457
+
458
+ # timestamp = datetime.now(tz=timezone).strftime(datetime_format)
459
+ # stem = file_path.stem
460
+ # suffix = file_path.suffix
461
+
462
+ # versioned_name = f"{stem}_{timestamp}{suffix}"
463
+ # return file_path.parent / versioned_name
464
+
465
+ # def _generate_renamed_path(self, file_path: Path) -> Path:
466
+ # """Generate a renamed file path by appending a counter.
467
+
468
+ # Args:
469
+ # file_path: The original file path.
470
+
471
+ # Returns:
472
+ # A new path with a counter appended to ensure uniqueness.
473
+ # """
474
+ # stem = file_path.stem
475
+ # suffix = file_path.suffix
476
+ # parent = file_path.parent
477
+ # counter = 1
478
+
479
+ # while True:
480
+ # new_name = f"{stem}_{counter}{suffix}"
481
+ # new_path = parent / new_name
482
+ # if not new_path.exists():
483
+ # return new_path
484
+ # counter += 1
@@ -0,0 +1,11 @@
1
+ """Alerting module for Samara."""
2
+
3
+ import logging
4
+
5
+ from samara.alert.controller import AlertController
6
+ from samara.utils.logger import get_logger
7
+
8
+ logger: logging.Logger = get_logger(__name__)
9
+
10
+
11
+ __all__ = ["AlertController"]