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.
- samara/__init__.py +58 -0
- samara/__main__.py +9 -0
- samara/actions/__init__.py +14 -0
- samara/actions/base.py +34 -0
- samara/actions/http.py +51 -0
- samara/actions/move_or_copy_job_files.py +484 -0
- samara/alert/__init__.py +11 -0
- samara/alert/channels/__init__.py +30 -0
- samara/alert/channels/base.py +42 -0
- samara/alert/channels/email.py +77 -0
- samara/alert/channels/file.py +57 -0
- samara/alert/channels/http.py +48 -0
- samara/alert/controller.py +103 -0
- samara/alert/rules/__init__.py +26 -0
- samara/alert/rules/base.py +38 -0
- samara/alert/rules/env_vars_matches.py +54 -0
- samara/alert/rules/exception_regex.py +48 -0
- samara/alert/template.py +58 -0
- samara/alert/trigger.py +79 -0
- samara/cli.py +248 -0
- samara/exceptions.py +154 -0
- samara/runtime/__init__.py +0 -0
- samara/runtime/controller.py +136 -0
- samara/runtime/jobs/__init__.py +14 -0
- samara/runtime/jobs/hooks.py +40 -0
- samara/runtime/jobs/models/__init__.py +1 -0
- samara/runtime/jobs/models/model_extract.py +77 -0
- samara/runtime/jobs/models/model_job.py +102 -0
- samara/runtime/jobs/models/model_load.py +79 -0
- samara/runtime/jobs/models/model_transform.py +84 -0
- samara/runtime/jobs/models/transforms/__init__.py +22 -0
- samara/runtime/jobs/models/transforms/model_cast.py +54 -0
- samara/runtime/jobs/models/transforms/model_drop.py +41 -0
- samara/runtime/jobs/models/transforms/model_dropduplicates.py +47 -0
- samara/runtime/jobs/models/transforms/model_filter.py +41 -0
- samara/runtime/jobs/models/transforms/model_join.py +56 -0
- samara/runtime/jobs/models/transforms/model_select.py +45 -0
- samara/runtime/jobs/models/transforms/model_withcolumn.py +43 -0
- samara/runtime/jobs/polars/.gitkeep +0 -0
- samara/runtime/jobs/spark/__init__.py +20 -0
- samara/runtime/jobs/spark/extract.py +165 -0
- samara/runtime/jobs/spark/function.py +33 -0
- samara/runtime/jobs/spark/job.py +138 -0
- samara/runtime/jobs/spark/load.py +173 -0
- samara/runtime/jobs/spark/schema.py +202 -0
- samara/runtime/jobs/spark/session.py +131 -0
- samara/runtime/jobs/spark/transform.py +79 -0
- samara/runtime/jobs/spark/transforms/__init__.py +39 -0
- samara/runtime/jobs/spark/transforms/cast.py +58 -0
- samara/runtime/jobs/spark/transforms/drop.py +71 -0
- samara/runtime/jobs/spark/transforms/dropduplicates.py +78 -0
- samara/runtime/jobs/spark/transforms/filter.py +87 -0
- samara/runtime/jobs/spark/transforms/join.py +75 -0
- samara/runtime/jobs/spark/transforms/select.py +94 -0
- samara/runtime/jobs/spark/transforms/withcolumn.py +95 -0
- samara/runtime/jobs/spark/validate.py +150 -0
- samara/types.py +297 -0
- samara/utils/__init__.py +13 -0
- samara/utils/file.py +306 -0
- samara/utils/http.py +91 -0
- samara/utils/logger.py +44 -0
- samara-0.1.0.dist-info/METADATA +214 -0
- samara-0.1.0.dist-info/RECORD +65 -0
- samara-0.1.0.dist-info/WHEEL +4 -0
- 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,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
|
samara/alert/__init__.py
ADDED