asanypath 2.0.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.
asanypath/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ from asanypath.artifactory import ArtifactoryPath
8
+ from asanypath.asanypath import (
9
+ PROTOCOL_MAP,
10
+ AsAnyPath,
11
+ AsAnyPurePath,
12
+ list_protocols,
13
+ register_protocol,
14
+ )
15
+ from asanypath.azure import AzurePath
16
+ from asanypath.cloud import CloudPathMixin
17
+ from asanypath.common import CommonPurePathMixin
18
+ from asanypath.exceptions import (
19
+ AsAnyPathError,
20
+ InvalidPathError,
21
+ PathIsADirectoryError,
22
+ PathNotADirectoryError,
23
+ PathNotFoundError,
24
+ UnsupportedProtocolError,
25
+ )
26
+ from asanypath.gcs import GCSPath
27
+ from asanypath.local import AsyncPath, SyncPath
28
+ from asanypath.options import AccessGrant, AccessPolicy, AccessPolicyPatch, BackendOptions
29
+ from asanypath.s3 import S3Path
30
+ from asanypath.unsupported import UnsupportedProtocolPath
31
+
32
+ try:
33
+ from asanypath.ssh import SSHPath # noqa: F401
34
+
35
+ _HAS_SSH = True
36
+ except ImportError: # pragma: no cover
37
+ _HAS_SSH = False
38
+
39
+ try:
40
+ from asanypath.ftp import FTPPath, FTPSPath # noqa: F401
41
+
42
+ _HAS_FTP = True
43
+ except ImportError: # pragma: no cover
44
+ _HAS_FTP = False
45
+
46
+ try:
47
+ from asanypath._version import __version__
48
+ except ModuleNotFoundError: # pragma: no cover
49
+ __version__ = "0.0.0-dev"
50
+ __all__ = [
51
+ "AsAnyPurePath",
52
+ "AsAnyPath",
53
+ "AsyncPath",
54
+ "SyncPath",
55
+ "BackendOptions",
56
+ "AccessGrant",
57
+ "AccessPolicy",
58
+ "AccessPolicyPatch",
59
+ "S3Path",
60
+ "CloudPathMixin",
61
+ "CommonPurePathMixin",
62
+ "GCSPath",
63
+ "AzurePath",
64
+ "ArtifactoryPath",
65
+ "register_protocol",
66
+ "list_protocols",
67
+ "AsAnyPathError",
68
+ "InvalidPathError",
69
+ "UnsupportedProtocolError",
70
+ "UnsupportedProtocolPath",
71
+ "PathNotFoundError",
72
+ "PathIsADirectoryError",
73
+ "PathNotADirectoryError",
74
+ "PROTOCOL_MAP",
75
+ ]
76
+
77
+ if _HAS_FTP:
78
+ __all__.extend(["FTPPath", "FTPSPath"])
79
+
80
+ if _HAS_SSH:
81
+ __all__.append("SSHPath")
asanypath/__init__.pyi ADDED
@@ -0,0 +1,62 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._version import __version__ as __version__
10
+ from .artifactory import ArtifactoryPath as ArtifactoryPath
11
+ from .asanypath import (
12
+ PROTOCOL_MAP as PROTOCOL_MAP,
13
+ )
14
+ from .asanypath import (
15
+ AsAnyPath as AsAnyPath,
16
+ )
17
+ from .asanypath import (
18
+ AsAnyPurePath as AsAnyPurePath,
19
+ )
20
+ from .asanypath import (
21
+ list_protocols as list_protocols,
22
+ )
23
+ from .asanypath import (
24
+ register_protocol as register_protocol,
25
+ )
26
+ from .azure import AzurePath as AzurePath
27
+ from .cloud import CloudPathMixin as CloudPathMixin
28
+ from .common import CommonPurePathMixin as CommonPurePathMixin
29
+ from .exceptions import (
30
+ AsAnyPathError as AsAnyPathError,
31
+ )
32
+ from .exceptions import (
33
+ InvalidPathError as InvalidPathError,
34
+ )
35
+ from .exceptions import (
36
+ PathIsADirectoryError as PathIsADirectoryError,
37
+ )
38
+ from .exceptions import (
39
+ PathNotADirectoryError as PathNotADirectoryError,
40
+ )
41
+ from .exceptions import (
42
+ PathNotFoundError as PathNotFoundError,
43
+ )
44
+ from .exceptions import (
45
+ UnsupportedProtocolError as UnsupportedProtocolError,
46
+ )
47
+ from .ftp import FTPPath as FTPPath
48
+ from .ftp import FTPSPath as FTPSPath
49
+ from .gcs import GCSPath as GCSPath
50
+ from .http import HTTPPath as HTTPPath
51
+ from .http import HTTPSPath as HTTPSPath
52
+ from .local import AsyncPath as AsyncPath
53
+ from .local import SyncPath as SyncPath
54
+ from .options import AccessGrant as AccessGrant
55
+ from .options import AccessPolicy as AccessPolicy
56
+ from .options import AccessPolicyPatch as AccessPolicyPatch
57
+ from .options import BackendOptions as BackendOptions
58
+ from .s3 import S3Path as S3Path
59
+ from .ssh import SSHPath as SSHPath
60
+ from .unsupported import UnsupportedProtocolPath as UnsupportedProtocolPath
61
+
62
+ __all__: list[str]
@@ -0,0 +1,102 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ """Per-host password cache backed by the OS keyring.
8
+
9
+ Stores ``"<epoch>|<password>"`` so callers can enforce a TTL on top of the
10
+ keyring's own (effectively unlimited) lifetime.
11
+
12
+ Headless hosts without a Secret Service backend (or any usable backend)
13
+ silently no-op: ``load`` returns ``None``, ``save``/``forget`` swallow the
14
+ ``NoKeyringError``. We refuse to fall back to ``keyrings.alt`` (plaintext
15
+ on disk) — re-prompting is strictly safer than leaking.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from os import getenv
21
+ from time import time
22
+
23
+ try:
24
+ import keyring
25
+ from keyring.errors import NoKeyringError
26
+ except ImportError: # pragma: no cover - keyring is a hard dep, defensive
27
+ keyring = None # type: ignore[assignment]
28
+ NoKeyringError = Exception # type: ignore[misc, assignment]
29
+
30
+
31
+ SERVICE = "asanypath"
32
+ DEFAULT_TTL_SECONDS = 3600
33
+
34
+ # Module-level safety knob: tests and CI flip this off.
35
+ _ENABLED = getenv("ASANYPATH_CREDSTORE", "1") != "0"
36
+
37
+
38
+ def _ok() -> bool:
39
+ if not _ENABLED or keyring is None:
40
+ return False
41
+ # ``get_keyring()`` returns a ``fail.Keyring`` (or the ``null`` backend)
42
+ # when no real backend is available; their ``priority`` is <= 0. Some
43
+ # backends (e.g. SecretService on a headless host) raise from the
44
+ # ``priority`` property itself when their runtime deps are missing —
45
+ # treat that as "not available".
46
+ try:
47
+ backend = keyring.get_keyring()
48
+ priority = backend.priority
49
+ except Exception: # noqa: BLE001
50
+ return False
51
+ if priority <= 0:
52
+ return False
53
+ # Refuse the plaintext fallback shipped by ``keyrings.alt``.
54
+ if type(backend).__module__.startswith("keyrings.alt"):
55
+ return False
56
+ return True
57
+
58
+
59
+ def load(key: str, *, ttl: int = DEFAULT_TTL_SECONDS) -> str | None:
60
+ """Return the cached password for ``key`` if still within ``ttl`` seconds."""
61
+ ring = keyring
62
+ if not _ok() or ring is None:
63
+ return None
64
+ try:
65
+ raw = ring.get_password(SERVICE, key)
66
+ except (NoKeyringError, Exception): # noqa: BLE001
67
+ return None
68
+ if not raw or "|" not in raw:
69
+ forget(key)
70
+ return None
71
+ ts_str, _, password = raw.partition("|")
72
+ try:
73
+ ts = float(ts_str)
74
+ except ValueError:
75
+ forget(key)
76
+ return None
77
+ if (time() - ts) > ttl:
78
+ forget(key)
79
+ return None
80
+ return password
81
+
82
+
83
+ def save(key: str, password: str) -> None:
84
+ """Persist ``password`` for ``key`` with a fresh timestamp."""
85
+ ring = keyring
86
+ if not _ok() or ring is None:
87
+ return
88
+ try:
89
+ ring.set_password(SERVICE, key, f"{time()}|{password}")
90
+ except (NoKeyringError, Exception): # noqa: BLE001
91
+ pass
92
+
93
+
94
+ def forget(key: str) -> None:
95
+ """Drop any cached entry for ``key``; silent if absent."""
96
+ ring = keyring
97
+ if not _ok() or ring is None:
98
+ return
99
+ try:
100
+ ring.delete_password(SERVICE, key)
101
+ except (NoKeyringError, Exception): # noqa: BLE001
102
+ pass
asanypath/_transfer.py ADDED
@@ -0,0 +1,216 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ import os
11
+ import shutil
12
+ import uuid
13
+ from collections.abc import Awaitable, Callable
14
+ from hashlib import sha256
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ _CHECKSUM_PREFERENCE = ("sha256", "sha1", "md5")
19
+
20
+ # Default streaming chunk size for chunk_size=None ("auto").
21
+ _DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024
22
+
23
+
24
+ def _validate_chunk_size(chunk_size: int | None) -> None:
25
+ if chunk_size is not None and chunk_size < 0:
26
+ raise ValueError("chunk_size must be None, 0, or a positive integer")
27
+
28
+
29
+ def _atomic_temp_path(dst_path: Path) -> Path:
30
+ return dst_path.with_name(f".{dst_path.name}.tmp-{uuid.uuid4().hex}")
31
+
32
+
33
+ def _commit_temp_sync(tmp_path: Path, dst_path: Path, *, force: bool) -> None:
34
+ try:
35
+ if force:
36
+ tmp_path.replace(dst_path)
37
+ else:
38
+ os.link(tmp_path, dst_path)
39
+ tmp_path.unlink()
40
+ except BaseException:
41
+ tmp_path.unlink(missing_ok=True)
42
+ raise
43
+
44
+
45
+ def _copy_file_data_sync(src_path: Path, dst_path: Path, *, chunk_size: int | None) -> int:
46
+ # chunk_size: None → auto (stream with default size); 0 → disabled (one-shot);
47
+ # positive → stream with that size.
48
+ if chunk_size == 0:
49
+ shutil.copy2(src_path, dst_path)
50
+ return src_path.stat().st_size
51
+ if chunk_size is None:
52
+ chunk_size = _DEFAULT_CHUNK_SIZE
53
+ total = 0
54
+ with src_path.open("rb") as src_file, dst_path.open("wb") as dst_file:
55
+ while chunk := src_file.read(chunk_size):
56
+ dst_file.write(chunk)
57
+ total += len(chunk)
58
+ shutil.copystat(src_path, dst_path)
59
+ return total
60
+
61
+
62
+ def local_copy_file_sync(
63
+ src_path: Path, dst_path: Path, *, chunk_size: int | None, atomic: bool, force: bool
64
+ ) -> int:
65
+ """Copy one local file, optionally staging via a temp file for atomic commit."""
66
+ if atomic:
67
+ tmp_path = _atomic_temp_path(dst_path)
68
+ tmp_path.parent.mkdir(parents=True, exist_ok=True)
69
+ copied = _copy_file_data_sync(src_path, tmp_path, chunk_size=chunk_size)
70
+ _commit_temp_sync(tmp_path, dst_path, force=force)
71
+ return copied
72
+ return _copy_file_data_sync(src_path, dst_path, chunk_size=chunk_size)
73
+
74
+
75
+ def _normalized_checksums(checksums: dict[str, str]) -> dict[str, str]:
76
+ return {key.lower().replace("-", ""): value.strip().lower() for key, value in checksums.items()}
77
+
78
+
79
+ def is_remote_destination(dst: Any) -> bool:
80
+ protocol = getattr(dst, "protocol", None)
81
+ if protocol is not None:
82
+ return protocol != "file"
83
+ value = str(dst)
84
+ return "://" in value and not value.startswith("file:")
85
+
86
+
87
+ def has_async_transfer_api(dst: Any) -> bool:
88
+ return inspect.iscoroutinefunction(
89
+ getattr(dst, "exists", None)
90
+ ) and inspect.iscoroutinefunction(getattr(dst, "write_bytes", None))
91
+
92
+
93
+ def has_write_api(dst: Any) -> bool:
94
+ return callable(getattr(dst, "write_bytes", None))
95
+
96
+
97
+ def run_sync_maybe(value: Any) -> Any:
98
+ if inspect.isawaitable(value):
99
+ from asanypath.sync import _SyncRunner
100
+
101
+ return _SyncRunner.get().run(value)
102
+ return value
103
+
104
+
105
+ async def async_destination_state(src: Any, dst: Any) -> tuple[bool, bool, bool]:
106
+ """Return ``(needs_copy, same_path, destination_exists)``."""
107
+ same_path = str(src) == str(dst)
108
+ if same_path:
109
+ return (False, True, True)
110
+ if not await dst.exists():
111
+ return (True, False, False)
112
+ if await dst.is_dir():
113
+ raise IsADirectoryError(21, f"Is a directory: '{dst}'")
114
+
115
+ try:
116
+ src_checksums = _normalized_checksums(await src.checksums())
117
+ dst_checksums = _normalized_checksums(await dst.checksums())
118
+ except NotImplementedError:
119
+ src_checksums = {}
120
+ dst_checksums = {}
121
+ for algorithm in _CHECKSUM_PREFERENCE:
122
+ if algorithm in src_checksums and algorithm in dst_checksums:
123
+ return (src_checksums[algorithm] != dst_checksums[algorithm], False, True)
124
+
125
+ src_data = await src.read_bytes()
126
+ dst_data = await dst.read_bytes()
127
+ return (sha256(src_data).digest() != sha256(dst_data).digest(), False, True)
128
+
129
+
130
+ def sync_destination_state(src: Any, dst: Any) -> tuple[bool, bool, bool]:
131
+ """Return ``(needs_copy, same_path, destination_exists)``."""
132
+ same_path = str(src) == str(dst)
133
+ if same_path:
134
+ return (False, True, True)
135
+ if not run_sync_maybe(dst.exists()):
136
+ return (True, False, False)
137
+ if run_sync_maybe(dst.is_dir()):
138
+ raise IsADirectoryError(21, f"Is a directory: '{dst}'")
139
+
140
+ src_checksums = _normalized_checksums(src.checksums())
141
+ dst_checksums = _normalized_checksums(run_sync_maybe(dst.checksums()))
142
+ for algorithm in _CHECKSUM_PREFERENCE:
143
+ if algorithm in src_checksums and algorithm in dst_checksums:
144
+ return (src_checksums[algorithm] != dst_checksums[algorithm], False, True)
145
+
146
+ return (
147
+ sha256(src.read_bytes()).digest() != sha256(run_sync_maybe(dst.read_bytes())).digest(),
148
+ False,
149
+ True,
150
+ )
151
+
152
+
153
+ def require_force(force: bool, dst: Any) -> None:
154
+ if not force:
155
+ raise FileExistsError(17, f"File exists with different content: '{dst}'")
156
+
157
+
158
+ def run_transfer_sync(
159
+ *,
160
+ src: Any,
161
+ dst: Any,
162
+ copy_fn: Callable[[], int],
163
+ remove_fn: Callable[[], None] | None = None,
164
+ remove_src: bool = False,
165
+ on_progress: Callable[[int, int, Any, Any], None] | None = None,
166
+ on_done: Callable[[Any, Any, int, bool, BaseException | None], None] | None = None,
167
+ ) -> Any:
168
+ transferred = 0
169
+ error: BaseException | None = None
170
+ try:
171
+ delta = copy_fn()
172
+ if delta > 0 and on_progress is not None:
173
+ transferred += delta
174
+ on_progress(delta, transferred, src, dst)
175
+ if remove_src and remove_fn is not None:
176
+ remove_fn()
177
+ return dst
178
+ except BaseException as exc: # noqa: BLE001 # pragma: no cover
179
+ error = exc # pragma: no cover
180
+ raise # pragma: no cover
181
+ finally:
182
+ if on_done is not None:
183
+ on_done(src, dst, transferred, error is None, error)
184
+
185
+
186
+ async def run_transfer_async(
187
+ *,
188
+ src: Any,
189
+ dst: Any,
190
+ copy_fn: Callable[[], Awaitable[int]],
191
+ remove_fn: Callable[[], Awaitable[None]] | None = None,
192
+ remove_src: bool = False,
193
+ on_progress: Callable[[int, int, Any, Any], None | Awaitable[None]] | None = None,
194
+ on_done: Callable[[Any, Any, int, bool, BaseException | None], None | Awaitable[None]]
195
+ | None = None,
196
+ ) -> Any:
197
+ transferred = 0
198
+ error: BaseException | None = None
199
+ try:
200
+ delta = await copy_fn()
201
+ if delta > 0 and on_progress is not None:
202
+ transferred += delta
203
+ maybe = on_progress(delta, transferred, src, dst)
204
+ if inspect.isawaitable(maybe):
205
+ await maybe
206
+ if remove_src and remove_fn is not None:
207
+ await remove_fn()
208
+ return dst
209
+ except BaseException as exc: # noqa: BLE001 # pragma: no cover
210
+ error = exc # pragma: no cover
211
+ raise # pragma: no cover
212
+ finally:
213
+ if on_done is not None:
214
+ maybe = on_done(src, dst, transferred, error is None, error)
215
+ if inspect.isawaitable(maybe): # pragma: no cover
216
+ await maybe # pragma: no cover
asanypath/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.0.0'
22
+ __version_tuple__ = version_tuple = (2, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None