prefect-managedfiletransfer 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 (42) hide show
  1. prefect_managedfiletransfer/AssetDownloadResult.py +40 -0
  2. prefect_managedfiletransfer/FileMatcher.py +41 -0
  3. prefect_managedfiletransfer/FileToFolderMapping.py +92 -0
  4. prefect_managedfiletransfer/PathUtil.py +84 -0
  5. prefect_managedfiletransfer/RCloneCommandBuilder.py +217 -0
  6. prefect_managedfiletransfer/RCloneConfig.py +10 -0
  7. prefect_managedfiletransfer/RCloneConfigFileBlock.py +51 -0
  8. prefect_managedfiletransfer/RCloneConfigSavedInPrefect.py +19 -0
  9. prefect_managedfiletransfer/RemoteAsset.py +16 -0
  10. prefect_managedfiletransfer/RemoteConnectionType.py +7 -0
  11. prefect_managedfiletransfer/ServerWithBasicAuthBlock.py +64 -0
  12. prefect_managedfiletransfer/ServerWithPublicKeyAuthBlock.py +101 -0
  13. prefect_managedfiletransfer/SortFilesBy.py +26 -0
  14. prefect_managedfiletransfer/TransferType.py +6 -0
  15. prefect_managedfiletransfer/__init__.py +54 -0
  16. prefect_managedfiletransfer/constants.py +18 -0
  17. prefect_managedfiletransfer/deploy.py +123 -0
  18. prefect_managedfiletransfer/download_asset.py +392 -0
  19. prefect_managedfiletransfer/download_file_task.py +95 -0
  20. prefect_managedfiletransfer/ensure_space.py +27 -0
  21. prefect_managedfiletransfer/flows.py +30 -0
  22. prefect_managedfiletransfer/invoke_rclone.py +207 -0
  23. prefect_managedfiletransfer/list_remote_assets.py +316 -0
  24. prefect_managedfiletransfer/list_remote_files_task.py +88 -0
  25. prefect_managedfiletransfer/main.py +364 -0
  26. prefect_managedfiletransfer/rclone/osx/rclone +0 -0
  27. prefect_managedfiletransfer/rclone/rclone +0 -0
  28. prefect_managedfiletransfer/rclone/rclone.exe +0 -0
  29. prefect_managedfiletransfer/sftp_utils.py +239 -0
  30. prefect_managedfiletransfer/tasks.py +25 -0
  31. prefect_managedfiletransfer/time_util.py +82 -0
  32. prefect_managedfiletransfer/transfer_files_flow.py +202 -0
  33. prefect_managedfiletransfer/unpack_files_flow.py +217 -0
  34. prefect_managedfiletransfer/upload_asset.py +336 -0
  35. prefect_managedfiletransfer/upload_file_flow.py +240 -0
  36. prefect_managedfiletransfer/upload_file_task.py +107 -0
  37. prefect_managedfiletransfer-0.1.0.dist-info/METADATA +193 -0
  38. prefect_managedfiletransfer-0.1.0.dist-info/RECORD +42 -0
  39. prefect_managedfiletransfer-0.1.0.dist-info/WHEEL +5 -0
  40. prefect_managedfiletransfer-0.1.0.dist-info/entry_points.txt +2 -0
  41. prefect_managedfiletransfer-0.1.0.dist-info/licenses/LICENSE +202 -0
  42. prefect_managedfiletransfer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,40 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+
5
+ class AssetDownloadResult:
6
+ """
7
+ Represents the result of an asset download operation.
8
+ """
9
+
10
+ def __init__(
11
+ self,
12
+ success: bool,
13
+ file_path: Path | None,
14
+ download_skipped: bool = False,
15
+ last_modified: datetime | None = None,
16
+ size: int = 0,
17
+ error: str | None = None,
18
+ ):
19
+ """
20
+ Represents the result of an asset download operation.
21
+ :param success: True if the download was successful, False otherwise.
22
+ :param file_path: The path to the downloaded file, or None if the download failed.
23
+ :param download_skipped: True if the download was skipped because the file was not newer than the destination file.
24
+ :param last_modified: The last modified time of the file, if available.
25
+ """
26
+ self.last_modified = last_modified
27
+ self.success = success
28
+ self.file_path = file_path
29
+ self.download_skipped = download_skipped
30
+ self.error = error
31
+ self.size = size
32
+
33
+ if not success and file_path is not None:
34
+ raise ValueError("If success is False, file_path must be None")
35
+
36
+ def __repr__(self):
37
+ if self.error:
38
+ return f"AssetDownloadResult(success={self.success}, error={self.error})"
39
+
40
+ return f"AssetDownloadResult(success={self.success}, file_path={self.file_path}, download_skipped={self.download_skipped}, last_modified={self.last_modified})"
@@ -0,0 +1,41 @@
1
+ from datetime import timedelta
2
+ from pydantic import BaseModel, Field
3
+ from prefect_managedfiletransfer.SortFilesBy import SortFilesBy
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ class FileMatcher(BaseModel):
9
+ """
10
+ Represents a file matcher with a source path and a pattern to match files.
11
+ This is used to find files in a directory that match a specific pattern.
12
+ """
13
+
14
+ source_folder: Path = Field(
15
+ default=Path("."),
16
+ description="Path to the source directory to look for files.",
17
+ )
18
+ pattern_to_match: str = Field(
19
+ default="*",
20
+ description="Pattern to match files in the source directory. Supports glob patterns like '*.txt' or 'file_*.csv'.",
21
+ )
22
+ minimum_age: str | int | timedelta | None = Field(
23
+ default=None,
24
+ description="Only transfer files older than this in secs (or other time with suffix s|m|h|d|w|month|year). Default off.",
25
+ )
26
+ maximum_age: str | int | timedelta | None = Field(
27
+ default=None,
28
+ description="Only transfer files newer than this in secs (or other time with suffix s|m|h|d|w|month|year). Default off.",
29
+ )
30
+ sort: SortFilesBy = Field(
31
+ default=SortFilesBy.PATH_ASC,
32
+ description="Sort files by a specific attribute. Default is PATH_ASC.",
33
+ )
34
+ skip: int = Field(
35
+ default=0,
36
+ description="Number of files to skip in the sorted list. Default is 0.",
37
+ )
38
+ take: int | None = Field(
39
+ default=None,
40
+ description="Number of files to take from the sorted list. If None, all files are taken. Default is None.",
41
+ )
@@ -0,0 +1,92 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ from pathlib import Path
4
+
5
+ from prefect_managedfiletransfer.RemoteAsset import RemoteAsset
6
+
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class FileToFolderMapping(BaseModel):
13
+ source_path_pattern_to_match: str = Field(
14
+ default="*",
15
+ description="Pattern to match files against in the source directory. Supports glob patterns. E.g. 'folder/*.txt' or '*path*/file.*'",
16
+ )
17
+
18
+ destination_folder: Path = Field(
19
+ default=Path("."),
20
+ description="Path of the destination folder files matching the pattern should be placed into",
21
+ )
22
+
23
+ def is_match(self, file_path: Path) -> bool:
24
+ """
25
+ Check if the given file path matches the source path pattern.
26
+ """
27
+
28
+ if not self.source_path_pattern_to_match:
29
+ return False
30
+
31
+ if not file_path:
32
+ return False
33
+
34
+ return file_path.match(self.source_path_pattern_to_match)
35
+
36
+ # define constructor to allow for easy instantiation
37
+ def __init__(
38
+ self,
39
+ source_path_pattern_to_match: str = "*",
40
+ destination_folder: str = ".",
41
+ ):
42
+ super().__init__(
43
+ source_path_pattern_to_match=source_path_pattern_to_match,
44
+ destination_folder=Path(destination_folder),
45
+ )
46
+
47
+ @staticmethod
48
+ def apply_mappings(
49
+ mappings: list["FileToFolderMapping"],
50
+ source_files: list[RemoteAsset],
51
+ destination_path,
52
+ ) -> list[tuple[RemoteAsset, Path]]:
53
+ source_destination_pairs = []
54
+ for remote_asset in source_files:
55
+ logger.info(f"Found file: {remote_asset}")
56
+
57
+ # calculate the destination path - target + mapping
58
+ target_file_path: Path | None = None
59
+
60
+ for mapping in mappings:
61
+ if mapping.is_match(remote_asset.path):
62
+ if not destination_path:
63
+ target_file_path = (
64
+ mapping.destination_folder / remote_asset.path.name
65
+ )
66
+ elif (
67
+ not mapping.destination_folder
68
+ or mapping.destination_folder == Path(".")
69
+ ):
70
+ target_file_path = destination_path / remote_asset.path.name
71
+ else:
72
+ target_file_path = (
73
+ destination_path
74
+ / mapping.destination_folder
75
+ / remote_asset.path.name
76
+ )
77
+ logger.info(
78
+ f"File {remote_asset.path} matched {mapping.source_path_pattern_to_match} -> {mapping.destination_folder}"
79
+ )
80
+ break
81
+
82
+ if not target_file_path:
83
+ logger.info(
84
+ f"No mapping found for {remote_asset.path}, using default destination path"
85
+ )
86
+ target_file_path = destination_path / remote_asset.path.name
87
+
88
+ assert target_file_path is not None
89
+
90
+ source_destination_pairs.append((remote_asset, target_file_path))
91
+
92
+ return source_destination_pairs
@@ -0,0 +1,84 @@
1
+ from pathlib import Path
2
+
3
+ from prefect_managedfiletransfer.RemoteConnectionType import RemoteConnectionType
4
+
5
+
6
+ class PathUtil:
7
+ @staticmethod
8
+ def resolve_path(
9
+ path_type: RemoteConnectionType,
10
+ basepath: Path | str | None,
11
+ path: Path | str,
12
+ ) -> Path:
13
+ if path_type == RemoteConnectionType.LOCAL:
14
+ remote_source_path = PathUtil._local_resolve_path(
15
+ basepath, path, validate=True
16
+ )
17
+ else:
18
+ remote_source_path = PathUtil._resolve_remote_path(
19
+ basepath, path, validate=True
20
+ )
21
+
22
+ return remote_source_path
23
+
24
+ @staticmethod
25
+ def _local_resolve_path(
26
+ basepath: str | Path | None, path: str | Path, validate: bool = False
27
+ ) -> Path:
28
+ # Only resolve the base path at runtime, default to the current directory
29
+ resolved_basepath = (
30
+ Path(basepath).expanduser().resolve() if basepath else Path(".").resolve()
31
+ )
32
+
33
+ # Determine the path to access relative to the base path, ensuring that paths
34
+ # outside of the base path are off limits
35
+ if path is None:
36
+ return resolved_basepath
37
+
38
+ resolved_path: Path = Path(path).expanduser()
39
+
40
+ if not resolved_path.is_absolute():
41
+ resolved_path = resolved_basepath / resolved_path
42
+ else:
43
+ resolved_path = resolved_path.resolve()
44
+
45
+ if validate:
46
+ if resolved_basepath not in resolved_path.parents and (
47
+ resolved_basepath != resolved_path
48
+ ):
49
+ raise ValueError(
50
+ f"Provided path {resolved_path} is outside of the base path {resolved_basepath}."
51
+ )
52
+ return resolved_path
53
+
54
+ @staticmethod
55
+ def _resolve_remote_path(
56
+ basepath: str | Path | None, path: str | Path, validate: bool = False
57
+ ) -> Path:
58
+ if path is None and basepath is None:
59
+ return Path(".")
60
+ elif path is None:
61
+ return basepath
62
+
63
+ path = str(path).strip()
64
+ basepath = str(basepath).strip() if basepath else ""
65
+
66
+ if not path.startswith("/") and len(basepath) > 0:
67
+ resolved_path = f"{basepath.rstrip('/')}/{path}"
68
+ else:
69
+ resolved_path = path
70
+
71
+ if validate:
72
+ if (
73
+ len(basepath) > 0
74
+ and Path(basepath) not in Path(resolved_path).parents
75
+ and basepath != resolved_path
76
+ ):
77
+ raise ValueError(
78
+ f"Provided path {resolved_path} is outside of the base path {basepath}."
79
+ )
80
+ if len(basepath) > 0 and ".." in resolved_path:
81
+ raise ValueError(
82
+ f"Provided path {resolved_path} contains '..' so cannot be validated to be within basepath. Remove basepath if .. is required"
83
+ )
84
+ return Path(resolved_path)
@@ -0,0 +1,217 @@
1
+ import importlib
2
+ import logging
3
+ from pathlib import Path
4
+ import platform
5
+ import subprocess
6
+ import importlib.resources
7
+ from prefect_managedfiletransfer.RCloneConfig import RCloneConfig
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class RCloneCommandBuilder:
13
+ def __init__(
14
+ self,
15
+ rclone_config_file: Path | None = None,
16
+ rclone_config: RCloneConfig | None = None,
17
+ ):
18
+ self.rclone_config = rclone_config
19
+ self.rclone_config_file = rclone_config_file
20
+
21
+ self._rclone_executable = "rclone"
22
+ packaged_folder = Path(
23
+ str(
24
+ importlib.resources.files("prefect_managedfiletransfer").joinpath(
25
+ "rclone"
26
+ )
27
+ )
28
+ )
29
+ packaged_executable = packaged_folder / self._rclone_executable
30
+
31
+ if platform.system() == "Windows":
32
+ self._rclone_executable = "rclone.exe"
33
+ packaged_executable = packaged_folder / self._rclone_executable
34
+ logger.debug(
35
+ f"rclone windows packaged executable is at {packaged_executable}"
36
+ )
37
+ elif platform.system() == "Darwin":
38
+ packaged_executable = packaged_folder / "osx" / self._rclone_executable
39
+ logger.debug(
40
+ f"rclone macOS packaged executable is at {packaged_executable}"
41
+ )
42
+ else:
43
+ logger.debug(
44
+ f"rclone linux packaged executable is at {packaged_executable}"
45
+ )
46
+
47
+ # Check if rclone is available on the system path
48
+ try:
49
+ subprocess.run(
50
+ f"{self._rclone_executable} version",
51
+ shell=True,
52
+ check=True,
53
+ stdout=subprocess.DEVNULL,
54
+ stderr=subprocess.DEVNULL,
55
+ )
56
+ logger.debug(
57
+ f"Using rclone executable on PATH: '{self._rclone_executable}'"
58
+ )
59
+ except subprocess.CalledProcessError:
60
+ logger.warning(
61
+ "rclone is not available on PATH - using packaged version at "
62
+ + str(packaged_executable)
63
+ )
64
+ self._rclone_executable = str(packaged_executable)
65
+
66
+ try:
67
+ subprocess.run(
68
+ [self._rclone_executable, "version"],
69
+ check=True,
70
+ stdout=subprocess.DEVNULL,
71
+ stderr=subprocess.DEVNULL,
72
+ )
73
+ except subprocess.CalledProcessError:
74
+ logger.critical(
75
+ f"rclone executable {self._rclone_executable} is not available or not executable"
76
+ )
77
+ raise FileNotFoundError(
78
+ f"rclone executable {self._rclone_executable} is not available or not executable"
79
+ )
80
+
81
+ self.rclone_command = [self._rclone_executable]
82
+
83
+ if rclone_config_file is not None and rclone_config is not None:
84
+ raise ValueError(
85
+ "rclone_config_file and rclone_config_contents cannot be used at the same time"
86
+ )
87
+
88
+ if rclone_config_file is not None and not rclone_config_file.exists():
89
+ logger.critical(f"rclone config file {rclone_config_file} does not exist")
90
+ raise FileNotFoundError(
91
+ f"rclone config file {rclone_config_file} does not exist"
92
+ )
93
+
94
+ if rclone_config_file is not None and rclone_config_file.exists():
95
+ logger.info(f"Using rclone config file {rclone_config_file.absolute()}")
96
+ self.rclone_command.insert(1, "--config")
97
+ self.rclone_command.insert(2, str(rclone_config_file.absolute()))
98
+ elif rclone_config is not None:
99
+ logger.info(f"Using rclone config for remote {rclone_config.remote_name}")
100
+ else:
101
+ logger.info(
102
+ "No rclone config file or rclone config object provided, using default rclone config, probably [HOME]/.config/rclone/rclone.conf"
103
+ )
104
+
105
+ def uploadTo(
106
+ self,
107
+ source_file,
108
+ destination_file: Path,
109
+ update_only_if_newer_mode=False,
110
+ ) -> "RCloneCommandBuilder":
111
+ rclone_remote_pathstr = str(destination_file).lstrip()
112
+
113
+ rclone_remote_pathstr = self._apply_remote_prefix(rclone_remote_pathstr)
114
+
115
+ return self.copyTo(
116
+ source_file, update_only_if_newer_mode, rclone_remote_pathstr
117
+ )
118
+
119
+ def downloadTo(
120
+ self,
121
+ source_file,
122
+ destination_file,
123
+ update_only_if_newer_mode=False,
124
+ ) -> "RCloneCommandBuilder":
125
+ rclone_remote_pathstr = str(source_file).lstrip()
126
+
127
+ rclone_remote_pathstr = self._apply_remote_prefix(rclone_remote_pathstr)
128
+
129
+ return self.copyTo(
130
+ rclone_remote_pathstr, update_only_if_newer_mode, destination_file
131
+ )
132
+
133
+ def deleteFile(
134
+ self,
135
+ remote_file,
136
+ ) -> "RCloneCommandBuilder":
137
+ rclone_remote_pathstr = str(remote_file).lstrip()
138
+
139
+ rclone_remote_pathstr = self._apply_remote_prefix(rclone_remote_pathstr)
140
+ logger.info(f"Using rclone to delete {str(remote_file)}")
141
+
142
+ self.rclone_command += ["deletefile", rclone_remote_pathstr]
143
+
144
+ return self
145
+
146
+ def copyTo(self, source_file, update_only_if_newer_mode, rclone_remote_pathstr):
147
+ if update_only_if_newer_mode:
148
+ logger.info(
149
+ "Using rclone with --update flag to skip files that are newer on the destination"
150
+ )
151
+ self.rclone_command.append("--update")
152
+
153
+ logger.info(
154
+ f"Using rclone to copy {str(source_file)} to {rclone_remote_pathstr}"
155
+ )
156
+
157
+ self.rclone_command += [
158
+ "copyto",
159
+ str(source_file),
160
+ rclone_remote_pathstr,
161
+ "--check-first", # try and catch issues before starting upload
162
+ "--checksum", # be thorough and check checksums
163
+ "--max-duration",
164
+ "30m", # don't let the upload take forever, this is a safety net
165
+ ]
166
+
167
+ return self
168
+
169
+ def _apply_remote_prefix(self, rclone_remote_pathstr):
170
+ if self.rclone_config is not None and not rclone_remote_pathstr.startswith(
171
+ f"{self.rclone_config.remote_name}:"
172
+ ):
173
+ logger.debug(
174
+ f"Destination {rclone_remote_pathstr} does not start with {self.rclone_config.remote_name}:, adding it"
175
+ )
176
+ rclone_remote_pathstr = (
177
+ f"{self.rclone_config.remote_name}:{rclone_remote_pathstr}"
178
+ )
179
+
180
+ return rclone_remote_pathstr
181
+
182
+ def lsf(
183
+ self, remote_folder: Path, pattern_to_match: str | None = None
184
+ ) -> "RCloneCommandBuilder":
185
+ rclone_remote_pathstr = str(remote_folder).rstrip(
186
+ "/"
187
+ ) # Ensure no trailing slash
188
+ rclone_remote_pathstr = self._apply_remote_prefix(rclone_remote_pathstr)
189
+
190
+ self.rclone_command += [
191
+ "lsf",
192
+ "--files-only",
193
+ "--format",
194
+ "tsp", # time, size, path
195
+ rclone_remote_pathstr,
196
+ ]
197
+
198
+ if pattern_to_match:
199
+ logger.info(
200
+ f"Using rclone to list files in {rclone_remote_pathstr} matching pattern {pattern_to_match}"
201
+ )
202
+ self.rclone_command += ["--include", pattern_to_match]
203
+
204
+ return self
205
+
206
+ def build(self, custom_executable_path: str | Path = None) -> list[str]:
207
+ """
208
+ Build the rclone command as a list of strings.
209
+ """
210
+
211
+ if custom_executable_path:
212
+ logger.info(f"Using custom rclone executable at {custom_executable_path}")
213
+ self._rclone_executable = custom_executable_path
214
+
215
+ self.rclone_command[0] = str(self._rclone_executable)
216
+
217
+ return self.rclone_command.copy()
@@ -0,0 +1,10 @@
1
+ class RCloneConfig:
2
+ def __init__(self, remote_name: str):
3
+ self.remote_name = remote_name
4
+ self._config_contents: str
5
+
6
+ def get_config(self):
7
+ return self._config_contents
8
+
9
+ async def update_config(self, config_contents: str):
10
+ self._config_contents = config_contents
@@ -0,0 +1,51 @@
1
+ from pydantic import Field
2
+ from prefect.blocks.core import Block
3
+
4
+
5
+ class RCloneConfigFileBlock(Block):
6
+ """
7
+ Block for storing RClone configuration file contents.
8
+ This block is used to store the contents of an RClone configuration file, which can be used to configure RClone for file transfers.
9
+ The block is updated with tokends when they are refreshed, allowing for dynamic updates to the RClone configuration.
10
+
11
+ Generate a config locally with `rclone config create my_sharepoint onedrive` like below, then save the contents in a block with remote_name=my_sharepoint,config_file_contents=
12
+ [my_sharepoint]
13
+ type = onedrive
14
+ token = {"access_token":"...","token_type":"Bearer","refresh_token":"...","expiry":"2000-00-00T00:00:00.000000000Z"}
15
+ drive_id = b!-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
16
+ drive_type = documentLibrary
17
+
18
+ Attributes:
19
+ remote_name (str): The name of the remote connection.
20
+ config_file_contents (str): The contents of the RClone configuration file.
21
+
22
+ Example:
23
+ Load a stored value:
24
+ ```python
25
+ from prefect_managedfiletransfer import RCloneConfigFileBlock
26
+ block = RCloneConfigFileBlock.load("BLOCK_NAME")
27
+ ```
28
+
29
+ Creating a block:
30
+ ```python
31
+ from prefect_managedfiletransfer import RCloneConfigFileBlock
32
+ block = RCloneConfigFileBlock(
33
+ remote_name="my_sharepoint",
34
+ config_file_contents="..."
35
+ )
36
+ block.save("my_sharepoint_block", overwrite=True)
37
+ ```
38
+ """
39
+
40
+ _block_type_name = "RClone Remote Config File [ManagedFileTransfer]"
41
+ _logo_url = "https://github.com/rclone/rclone/blob/master/graphics/logo/logo_symbol/logo_symbol_color_64px.png?raw=true"
42
+ _documentation_url = "https://ImperialCollegeLondon.github.io/prefect-managedfiletransfer/blocks/#prefect-managedfiletransfer.blocks.RCloneConfigFileBlock" # noqa
43
+
44
+ remote_name: str = Field(
45
+ title="Remote Name",
46
+ description="The name of the remote connection in the RClone configuration.",
47
+ )
48
+ config_file_contents: str = Field(
49
+ title="Config File Contents",
50
+ description="The contents of the RClone configuration file.",
51
+ )
@@ -0,0 +1,19 @@
1
+ from prefect_managedfiletransfer import RCloneConfigFileBlock
2
+ from prefect_managedfiletransfer.RCloneConfig import RCloneConfig
3
+
4
+
5
+ class RCloneConfigSavedInPrefect(RCloneConfig):
6
+ """
7
+ RCloneDynamicConfig that uses a Prefect block to store the rclone config file contents. Is saved after sucessful uploads to update the token when it is refreshed
8
+ """
9
+
10
+ def __init__(self, block: RCloneConfigFileBlock):
11
+ self._block = block
12
+ self.remote_name = block.remote_name
13
+
14
+ def get_config(self):
15
+ return self._block.config_file_contents
16
+
17
+ async def update_config(self, config_contents: str):
18
+ self._block.config_file_contents = config_contents
19
+ await self._block.save(overwrite=True)
@@ -0,0 +1,16 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+
5
+ class RemoteAsset:
6
+ """
7
+ Represents a remote asset with its path and last modified time.
8
+ """
9
+
10
+ def __init__(self, path: Path, last_modified: datetime, size: int | None = None):
11
+ self.path: Path = path
12
+ self.last_modified: datetime = last_modified
13
+ self.size: int | None = size
14
+
15
+ def __repr__(self):
16
+ return f"RemoteAsset(path={self.path}, last_modified={self.last_modified}, size={self.size})"
@@ -0,0 +1,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class RemoteConnectionType(Enum):
5
+ LOCAL = "local"
6
+ SFTP = "sftp"
7
+ RCLONE = "rclone"
@@ -0,0 +1,64 @@
1
+ from typing import Optional
2
+ from prefect.blocks.core import Block
3
+ from pydantic import Field, SecretStr
4
+
5
+ from prefect_managedfiletransfer.constants import CONSTANTS
6
+
7
+
8
+ class ServerWithBasicAuthBlock(Block):
9
+ """
10
+ A connection to a remote server with basic authentication.
11
+
12
+ Attributes:
13
+ username (str): The username for authentication.
14
+ password (SecretStr): The password for authentication.
15
+ host (str): The host of the server.
16
+ port (int): The port of the server.
17
+
18
+ Example:
19
+ Load a stored value:
20
+ ```python
21
+ from prefect_managedfiletransfer import ServerWithBasicAuthBlock
22
+ block = ServerWithBasicAuthBlock.load("BLOCK_NAME")
23
+ ```
24
+ """
25
+
26
+ _block_type_name = "Server - Basic Auth [ManagedFileTransfer]"
27
+ _logo_url = CONSTANTS.SERVER_LOGO_URL
28
+ _documentation_url = "https://ImperialCollegeLondon.github.io/prefect-managedfiletransfer/blocks/#prefect-managedfiletransfer.blocks.ServerWithBasicAuthBlock" # noqa
29
+
30
+ username: str = Field(
31
+ title="The username for authentication.",
32
+ description="The username for authentication.",
33
+ )
34
+ password: Optional[SecretStr] = Field(
35
+ default=None,
36
+ title="The password for authentication.",
37
+ description="The password for authentication.",
38
+ )
39
+ host: str = Field(
40
+ title="The host of the server.", description="The host of the server."
41
+ )
42
+ port: int = Field(default=22, description="The port of the server.")
43
+
44
+ @classmethod
45
+ def seed_value_for_example(cls):
46
+ """
47
+ Seeds the field, value, so the block can be loaded.
48
+ """
49
+ block = cls(
50
+ username="example_user",
51
+ password=SecretStr("example_password"),
52
+ host="example.com",
53
+ port=22,
54
+ )
55
+ block.save("sample-block", overwrite=True)
56
+
57
+ def isValid(self) -> bool:
58
+ """Checks if the server credentials are available and valid."""
59
+ return (
60
+ self.username
61
+ and self.password.get_secret_value()
62
+ and self.host
63
+ and self.port > 0
64
+ )