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.
- prefect_managedfiletransfer/AssetDownloadResult.py +40 -0
- prefect_managedfiletransfer/FileMatcher.py +41 -0
- prefect_managedfiletransfer/FileToFolderMapping.py +92 -0
- prefect_managedfiletransfer/PathUtil.py +84 -0
- prefect_managedfiletransfer/RCloneCommandBuilder.py +217 -0
- prefect_managedfiletransfer/RCloneConfig.py +10 -0
- prefect_managedfiletransfer/RCloneConfigFileBlock.py +51 -0
- prefect_managedfiletransfer/RCloneConfigSavedInPrefect.py +19 -0
- prefect_managedfiletransfer/RemoteAsset.py +16 -0
- prefect_managedfiletransfer/RemoteConnectionType.py +7 -0
- prefect_managedfiletransfer/ServerWithBasicAuthBlock.py +64 -0
- prefect_managedfiletransfer/ServerWithPublicKeyAuthBlock.py +101 -0
- prefect_managedfiletransfer/SortFilesBy.py +26 -0
- prefect_managedfiletransfer/TransferType.py +6 -0
- prefect_managedfiletransfer/__init__.py +54 -0
- prefect_managedfiletransfer/constants.py +18 -0
- prefect_managedfiletransfer/deploy.py +123 -0
- prefect_managedfiletransfer/download_asset.py +392 -0
- prefect_managedfiletransfer/download_file_task.py +95 -0
- prefect_managedfiletransfer/ensure_space.py +27 -0
- prefect_managedfiletransfer/flows.py +30 -0
- prefect_managedfiletransfer/invoke_rclone.py +207 -0
- prefect_managedfiletransfer/list_remote_assets.py +316 -0
- prefect_managedfiletransfer/list_remote_files_task.py +88 -0
- prefect_managedfiletransfer/main.py +364 -0
- prefect_managedfiletransfer/rclone/osx/rclone +0 -0
- prefect_managedfiletransfer/rclone/rclone +0 -0
- prefect_managedfiletransfer/rclone/rclone.exe +0 -0
- prefect_managedfiletransfer/sftp_utils.py +239 -0
- prefect_managedfiletransfer/tasks.py +25 -0
- prefect_managedfiletransfer/time_util.py +82 -0
- prefect_managedfiletransfer/transfer_files_flow.py +202 -0
- prefect_managedfiletransfer/unpack_files_flow.py +217 -0
- prefect_managedfiletransfer/upload_asset.py +336 -0
- prefect_managedfiletransfer/upload_file_flow.py +240 -0
- prefect_managedfiletransfer/upload_file_task.py +107 -0
- prefect_managedfiletransfer-0.1.0.dist-info/METADATA +193 -0
- prefect_managedfiletransfer-0.1.0.dist-info/RECORD +42 -0
- prefect_managedfiletransfer-0.1.0.dist-info/WHEEL +5 -0
- prefect_managedfiletransfer-0.1.0.dist-info/entry_points.txt +2 -0
- prefect_managedfiletransfer-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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,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
|
+
)
|