swiss-army-upload 0.3.4.dev16__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ Swiss Army Upload:
2
+ Copyright (C) 2025 Teahouse Developers
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: swiss-army-upload
3
+ Version: 0.3.4.dev16
4
+ Summary: Interact with multiple web hosts
5
+ License-File: LICENSE
6
+ Author: Jamie Bliss
7
+ Author-email: jamie@teahouse.cafe
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: anyio[trio] (>=4.12.0,<5.0.0)
14
+ Requires-Dist: handtruck (>=0.3.0)
15
+ Requires-Dist: httpdate (>=1.2.0,<2.0.0)
16
+ Requires-Dist: httpx[http2] (>=0.28.1,<0.29.0)
17
+ Requires-Dist: keyring (>=25.7.0,<26.0.0)
18
+ Requires-Dist: platformdirs (>=4.5.1,<5.0.0)
19
+ Requires-Dist: rich (>=14.2.0,<15.0.0)
20
+ Requires-Dist: svcs
21
+ Project-URL: docs, https://swiss-army-upload.teahouse.cafe
22
+ Project-URL: source, https://codeberg.org/teahouse/swiss-army-upload
23
+ Description-Content-Type: text/markdown
24
+
25
+ Swiss Army Upload is a tool to interact with static hosts, such as:
26
+
27
+ * [Teahouse Hosting](https://teahouse.cafe/)
28
+ * [Git Pages](https://git-pages.org/) (partial)
29
+ * [Neocities](https://neocities.org/) (soon)
30
+ * [Nekoweb](https://nekoweb.org/) (soon)
31
+
@@ -0,0 +1,6 @@
1
+ Swiss Army Upload is a tool to interact with static hosts, such as:
2
+
3
+ * [Teahouse Hosting](https://teahouse.cafe/)
4
+ * [Git Pages](https://git-pages.org/) (partial)
5
+ * [Neocities](https://neocities.org/) (soon)
6
+ * [Nekoweb](https://nekoweb.org/) (soon)
@@ -0,0 +1,194 @@
1
+ [project]
2
+ name = "swiss-army-upload"
3
+ dynamic = []
4
+ description = "Interact with multiple web hosts"
5
+ authors = [
6
+ {name = "Jamie Bliss",email = "jamie@teahouse.cafe"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12,<4.0"
10
+ dependencies = [
11
+ "anyio[trio] (>=4.12.0,<5.0.0)",
12
+ "httpx[http2] (>=0.28.1,<0.29.0)",
13
+ "handtruck (>=0.3.0)",
14
+ "keyring (>=25.7.0,<26.0.0)",
15
+ # Doesn't implement get_credential(), so useless to us
16
+ # "bitwarden-keyring (>=0.3.1,<0.4.0)",
17
+ # Incorrectly implements get_credential()
18
+ # "onepassword-keyring (>=0.1.1,<0.2.0)",
19
+ "svcs",
20
+ "platformdirs (>=4.5.1,<5.0.0)",
21
+ "rich (>=14.2.0,<15.0.0)",
22
+ "httpdate (>=1.2.0,<2.0.0)",
23
+ ]
24
+ version = "0.3.4.dev16"
25
+
26
+ [project.urls]
27
+ source = "https://codeberg.org/teahouse/swiss-army-upload"
28
+ docs = "https://swiss-army-upload.teahouse.cafe"
29
+
30
+ [project.scripts]
31
+ swiss-army-upload = 'swiss_army_upload:entrypoint'
32
+ sau = 'swiss_army_upload:entrypoint'
33
+
34
+ [project.entry-points."swiss_army_upload.backend"]
35
+ # Teahouse Hosting, https://teahouse.cafe/
36
+ tea = "swiss_army_upload.backends.teahouse:TeahouseBackend"
37
+ # Git Pages, https://git-pages.org/
38
+ pages = "swiss_army_upload.backends.gitpages:GitPagesBackend"
39
+ # Nekoweb, https://nekoweb.org/
40
+ # neko = "swiss_army_upload.backends.nekoweb"
41
+ # Neocities, https://neocities.org/
42
+ # neo = "swiss_army_upload.backends.neocities"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "mypy (>=1.19.0,<2.0.0)",
47
+ "pytest (>=9.0.2,<10.0.0)",
48
+ "httpx[http2] (>=0.28.1,<0.29.0)",
49
+ "keyrings-alt (>=5.0.2,<6.0.0)",
50
+ "starlette (>=0.52.1,<0.53.0)",
51
+ "pytest-ephemeral-container (>=0.3.0)",
52
+ ]
53
+ docs = [
54
+ "sphinx (>=9.1.0,<10.0.0)",
55
+ "sphinxext-opengraph[social-cards] (>=0.13.0,<0.14.0)",
56
+ "sphinx-autobuild (>=2025.8.25,<2026.0.0)",
57
+ "sphinx-rtd-theme (>=3.1.0,<4.0.0)"
58
+ ]
59
+
60
+ [tool.poetry]
61
+ packages = [
62
+ {include = "swiss_army_upload", from = "src"},
63
+ {include = "scr.py", from = "src"},
64
+ ]
65
+
66
+ [tool.poetry.requires-plugins]
67
+ poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
68
+
69
+ [tool.poetry-dynamic-versioning]
70
+ enable = false
71
+ bump = true
72
+ metadata = false
73
+
74
+ [tool.mypy]
75
+ check_untyped_defs = true
76
+
77
+ [[tool.mypy.overrides]]
78
+ module = ["aws_request_signer.*"]
79
+ follow_untyped_imports = true
80
+
81
+ [[tool.mypy.overrides]]
82
+ module = ["keyrings.*"]
83
+ follow_untyped_imports = true
84
+
85
+ [tool.pytest.ini_options]
86
+ anyio_mode = "auto"
87
+ required_plugins = ["anyio"]
88
+
89
+ [build-system]
90
+ requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
91
+ build-backend = "poetry.core.masonry.api"
92
+
93
+ # content below this line added by briefcase convert
94
+ # This project was generated with 0.3.25 using template: https://github.com/beeware/briefcase-template @ v0.3.25
95
+ [tool.briefcase]
96
+ project_name = "Swiss Army Upload"
97
+ bundle = "cafe.teahouse"
98
+ # version = "0.0.1"
99
+ url = "https://codeberg.org/teahouse/swiss-army-upload"
100
+ license.file = "LICENSE"
101
+ author = "Jamie Bliss"
102
+ author_email = "jamie@teahouse.cafe"
103
+
104
+ [tool.briefcase.app.swiss-army-upload]
105
+ formal_name = "Swiss Army Upload"
106
+ description = "Interact with multiple web hosts"
107
+ long_description = """Uploads/downloads files with multiple web hosts:
108
+ * Teahouse Hosting
109
+ * Git Pages (soon)
110
+ * Neocities (soon)
111
+ * Nekoweb (soon)
112
+ """
113
+ sources = [
114
+ "src/swiss_army_upload",
115
+ ]
116
+ test_sources = [
117
+ "tests",
118
+ ]
119
+ console_app = true
120
+
121
+ requires = [
122
+ # Add your cross-platform app requirements here
123
+ ]
124
+ test_requires = [
125
+ # Add your cross-platform test requirements here
126
+ ]
127
+
128
+ [tool.briefcase.app.swiss-army-upload.macOS]
129
+ universal_build = true
130
+ requires = [
131
+ # Add your macOS-specific app requirements here
132
+ ]
133
+
134
+ [tool.briefcase.app.swiss-army-upload.linux]
135
+ requires = [
136
+ # Add your Linux-specific app requirements here
137
+ ]
138
+
139
+ [tool.briefcase.app.swiss-army-upload.linux.system.debian]
140
+ system_requires = [
141
+ # Add any system packages needed at build the app here
142
+ ]
143
+
144
+ system_runtime_requires = [
145
+ # Add any system packages needed at runtime here
146
+ ]
147
+
148
+ [tool.briefcase.app.swiss-army-upload.linux.system.rhel]
149
+ system_requires = [
150
+ # Add any system packages needed at build the app here
151
+ ]
152
+
153
+ system_runtime_requires = [
154
+ # Add any system packages needed at runtime here
155
+ ]
156
+
157
+ [tool.briefcase.app.swiss-army-upload.linux.system.suse]
158
+ system_requires = [
159
+ # Add any system packages needed at build the app here
160
+ ]
161
+
162
+ system_runtime_requires = [
163
+ # Add any system packages needed at runtime here
164
+ ]
165
+
166
+ [tool.briefcase.app.swiss-army-upload.linux.system.arch]
167
+ system_requires = [
168
+ # Add any system packages needed at build the app here
169
+ ]
170
+
171
+ system_runtime_requires = [
172
+ # Add any system packages needed at runtime here
173
+ ]
174
+
175
+ [tool.briefcase.app.swiss-army-upload.linux.flatpak]
176
+ flatpak_runtime = "org.freedesktop.Platform"
177
+ flatpak_runtime_version = "24.08"
178
+ flatpak_sdk = "org.freedesktop.Sdk"
179
+
180
+ [tool.briefcase.app.swiss-army-upload.windows]
181
+ requires = [
182
+ # Add your Windows-specific app requirements here
183
+ ]
184
+
185
+ # Mobile deployments
186
+ [tool.briefcase.app.swiss-army-upload.iOS]
187
+ supported = false
188
+
189
+ [tool.briefcase.app.swiss-army-upload.android]
190
+ supported = false
191
+
192
+ # Web deployments
193
+ [tool.briefcase.app.swiss-army-upload.web]
194
+ supported = false
@@ -0,0 +1,73 @@
1
+ # SPDX-FileCopyrightText: 2023 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ from functools import singledispatch
9
+
10
+ from svcs import exceptions
11
+ from svcs._core import (
12
+ Container,
13
+ RegisteredService,
14
+ Registry,
15
+ ServicePing,
16
+ )
17
+
18
+
19
+ __all__ = [
20
+ "Container",
21
+ "RegisteredService",
22
+ "Registry",
23
+ "ServicePing",
24
+ "exceptions",
25
+ ]
26
+
27
+
28
+ @singledispatch
29
+ def scr_from(obj) -> Container:
30
+ raise TypeError(f"Don't know how to get Container from {obj!r}")
31
+
32
+
33
+ svcs_from = scr_from
34
+
35
+
36
+ @scr_from.register
37
+ def _(obj: Container) -> Container:
38
+ return obj
39
+
40
+
41
+ async def aget(ctx: object, *svc_types: type) -> object:
42
+ """
43
+ Same as :meth:`svcs.Container.aget`, but uses the container from *ctx*.
44
+ """
45
+ return await scr_from(ctx).aget(*svc_types)
46
+
47
+
48
+ registry: Registry = Registry()
49
+ root: Container
50
+
51
+
52
+ @contextlib.asynccontextmanager
53
+ async def ainit():
54
+ """
55
+ Handles cleanup of the registry and root container, for async apps.
56
+ """
57
+ global root # noqa: PLW0603
58
+ # Do not close the registry; it'll erase all the init
59
+ root = Container(registry)
60
+ async with root:
61
+ yield root
62
+
63
+
64
+ @contextlib.contextmanager
65
+ def init():
66
+ """
67
+ Handles cleanup of the registry and root container, for sync apps.
68
+ """
69
+ global root # noqa: PLW0603
70
+ # Do not close the registry; it'll erase all the init
71
+ root = Container(registry)
72
+ with root:
73
+ yield root
@@ -0,0 +1,290 @@
1
+ import argparse
2
+ from enum import Enum
3
+ import logging
4
+ import logging.config
5
+ from pathlib import Path
6
+ import sys
7
+ import typing as T
8
+
9
+ import anyio
10
+ import httpx
11
+ import rich.logging
12
+ import scr
13
+
14
+ from .backends import get_backend, UnknownURLError, NoCredentialsFound, Backend
15
+ from .deps import enter_container
16
+
17
+
18
+ LOG = logging.getLogger(__name__)
19
+
20
+
21
+ class ExcludeSpecial(Enum):
22
+ Nothing = "NOTHING"
23
+ Default = "DEFAULT"
24
+
25
+
26
+ # Is actually a protocol, but isn't for Reasons:tm:
27
+ class CliArgs:
28
+ command: str
29
+
30
+ # Copy commands
31
+ src: httpx.URL | anyio.Path
32
+ dest: httpx.URL | anyio.Path
33
+ exclusions: list[str | anyio.Path | ExcludeSpecial]
34
+
35
+ # login
36
+ url: httpx.URL
37
+
38
+
39
+ def _path_or_url(arg: str) -> httpx.URL | anyio.Path:
40
+ # In this context, all the URLs given should have schemes
41
+ if ":" in arg:
42
+ # FIXME: Windows absolute paths
43
+ url = httpx.URL(arg)
44
+ if url.scheme:
45
+ return url
46
+ return anyio.Path(arg)
47
+
48
+
49
+ def parse_args(args: list[str] | None = None) -> CliArgs:
50
+ opts: dict[str, T.Any] = {}
51
+ if sys.version_info >= (3, 14):
52
+ opts |= {"suggest_on_error": True}
53
+ parser = argparse.ArgumentParser(
54
+ description="""
55
+ Upload to a variety of web hosts
56
+ """,
57
+ **opts,
58
+ )
59
+
60
+ subs = parser.add_subparsers(dest="command", required=True)
61
+
62
+ p_login = subs.add_parser(
63
+ "login",
64
+ help="Log in to a provider",
65
+ **opts,
66
+ description="""
67
+ Log in to a provider.
68
+
69
+ Any of the following forms are allowed:
70
+ * Just a name (tea)
71
+ * A URL stub (tea:, tea://)
72
+ * A URL base (tea://mysite.example)
73
+
74
+ Note that support for multiple credentials to the same provider will vary.
75
+ """,
76
+ )
77
+ p_login.add_argument("url", type=httpx.URL, help="URL to log in to")
78
+
79
+ p_get = subs.add_parser(
80
+ "get",
81
+ **opts,
82
+ help="Download a file",
83
+ description="""
84
+ Download a file.
85
+
86
+ Both the source and the destination must include the file name.
87
+ """,
88
+ )
89
+ p_get.add_argument("src", type=httpx.URL, help="URL to read from")
90
+ p_get.add_argument("dest", type=anyio.Path, help="Path to write to")
91
+
92
+ p_put = subs.add_parser(
93
+ "put",
94
+ **opts,
95
+ help="Upload a file",
96
+ description="""
97
+ Upload a file
98
+
99
+ Both the source and the destination must include the file name.
100
+ """,
101
+ )
102
+ p_put.add_argument("src", type=anyio.Path, help="Path to read from")
103
+ p_put.add_argument("dest", type=httpx.URL, help="URL to write to")
104
+
105
+ p_sync = subs.add_parser(
106
+ "sync",
107
+ **opts,
108
+ help="Synchronize one directory to another",
109
+ description="""
110
+ Synchronize one directory to another.
111
+
112
+ No implicit names are added to the end of the destination path.
113
+ """,
114
+ )
115
+ p_sync.add_argument("src", type=_path_or_url, help="Path or URL to read from")
116
+ p_sync.add_argument("dest", type=_path_or_url, help="URL or Path to write to")
117
+ p_sync.add_argument(
118
+ "--exclude",
119
+ action="append",
120
+ dest="exclusions",
121
+ metavar="PATH",
122
+ default=[ExcludeSpecial.Default],
123
+ help="Exclude the given file/directory",
124
+ )
125
+ # p_sync.add_argument("--ignore-file", action="append", dest="exclusions", metavar="PATH", type=anyio.Path, help="Read and use an ignore file")
126
+ p_sync.add_argument(
127
+ "--exclude-nothing",
128
+ action="append_const",
129
+ dest="exclusions",
130
+ const=ExcludeSpecial.Nothing,
131
+ help="Disable exclusions, including implied ones",
132
+ )
133
+
134
+ pargs = parser.parse_args(args)
135
+ return T.cast(CliArgs, pargs)
136
+
137
+
138
+ scr.registry.register_factory(CliArgs, parse_args)
139
+
140
+
141
+ async def do_login(svc: scr.Container):
142
+ args = await svc.aget(CliArgs)
143
+ if not args.url.scheme:
144
+ assert not args.url.host
145
+ args.url = httpx.URL(scheme=args.url.path)
146
+
147
+ async with get_backend(args, args.url) as backend:
148
+ # Check if credentials exist, and warn if they do
149
+ try:
150
+ have_creds_already = await backend.check_credentials(args.url)
151
+ except* NoCredentialsFound:
152
+ pass
153
+ else:
154
+ if have_creds_already:
155
+ LOG.warning("Already have credentials for %s; overwriting", args.url)
156
+
157
+ await backend.prompt_for_credentials(args.url)
158
+
159
+
160
+ async def do_get(svc: scr.Container):
161
+ args = await svc.aget(CliArgs)
162
+ usrc = T.cast(httpx.URL, args.src)
163
+ pdest = T.cast(anyio.Path, args.dest)
164
+ async with get_backend(args, usrc) as backend:
165
+ await backend.get_to_file(usrc, pdest)
166
+
167
+
168
+ async def do_put(svc: scr.Container):
169
+ args = await svc.aget(CliArgs)
170
+ src = T.cast(Path, args.src)
171
+ dest = T.cast(httpx.URL, args.dest)
172
+ async with get_backend(args, dest) as backend:
173
+ await backend.put_from_file(src, dest)
174
+
175
+
176
+ async def do_sync(svc: scr.Container):
177
+ args = await svc.aget(CliArgs)
178
+ if isinstance(args.src, httpx.URL):
179
+ surl: httpx.URL = args.src
180
+ sbe = get_backend(args, surl)
181
+ else:
182
+ sbe = None
183
+
184
+ if isinstance(args.dest, httpx.URL):
185
+ durl: httpx.URL = args.dest
186
+ dbe = get_backend(args, durl)
187
+ else:
188
+ dbe = None
189
+
190
+ if sbe is not None and dbe is not None:
191
+ sys.exit("One of source or destination must be a local path")
192
+ elif sbe is None and dbe is None:
193
+ sys.exit("One of source or destination must be a remote path")
194
+ elif sbe is None:
195
+ async with T.cast(Backend, dbe):
196
+ await T.cast(Backend, dbe).rsync_up(
197
+ T.cast(Path, args.src), durl, delete=True
198
+ )
199
+ elif dbe is None:
200
+ async with T.cast(Backend, sbe):
201
+ await T.cast(Backend, sbe).rsync_down(
202
+ surl, T.cast(Path, args.dest), delete=True
203
+ )
204
+ else:
205
+ assert False, "Shouldn't get here"
206
+
207
+
208
+ def flatten_excetions[E: Exception](grp: ExceptionGroup[E]) -> T.Iterable[E]:
209
+ for exc in grp.exceptions:
210
+ if isinstance(exc, ExceptionGroup):
211
+ yield from flatten_excetions(exc)
212
+ else:
213
+ yield exc
214
+
215
+
216
+ async def main():
217
+ async with scr.ainit():
218
+ args = await scr.root.aget(CliArgs)
219
+
220
+ retval = 0
221
+ try:
222
+ async with enter_container(args):
223
+ if args.command == "login":
224
+ await do_login(scr.root)
225
+ elif args.command == "get":
226
+ await do_get(scr.root)
227
+ elif args.command == "put":
228
+ await do_put(scr.root)
229
+ elif args.command == "sync":
230
+ await do_sync(scr.root)
231
+ else:
232
+ # FIXME: Print usage
233
+ sys.exit("No command specified")
234
+ except* UnknownURLError as egrp:
235
+ for uue in flatten_excetions(egrp):
236
+ LOG.error("%s", str(uue.args[0]))
237
+ del uue
238
+ retval = 1
239
+ except* NoCredentialsFound as egrp:
240
+ for ncf in flatten_excetions(egrp):
241
+ LOG.error("%s\nDid you need to use the login command?", ncf.args[0])
242
+ del ncf
243
+ retval = 1
244
+ except* httpx.HTTPError as egrp:
245
+ for rt in flatten_excetions(egrp):
246
+ try:
247
+ rt.add_note(f"URL: {rt.request.url}")
248
+ except RuntimeError:
249
+ # Request property has not been set
250
+ pass
251
+ del rt
252
+ raise
253
+ except* KeyboardInterrupt:
254
+ retval = 1
255
+ sys.exit(retval)
256
+
257
+
258
+ def entrypoint():
259
+ logging.config.dictConfig(
260
+ {
261
+ "version": 1,
262
+ "incremental": False,
263
+ "disable_existing_loggers": False,
264
+ "formatters": {
265
+ "standard": {"format": "%(message)s"},
266
+ },
267
+ "handlers": {
268
+ "default": {
269
+ "level": "NOTSET",
270
+ "formatter": "standard",
271
+ "class": "rich.logging.RichHandler",
272
+ "rich_tracebacks": True, # TODO: Only in development
273
+ "console": rich.console.Console(stderr=True),
274
+ },
275
+ },
276
+ "root": {"handlers": ["default"], "level": "INFO", "propagate": True},
277
+ "loggers": {
278
+ __name__: {
279
+ "handlers": ["default"],
280
+ "level": "DEBUG",
281
+ "propagate": False,
282
+ },
283
+ "httpx": {
284
+ # INFO spews all requests
285
+ "level": "WARNING",
286
+ },
287
+ },
288
+ }
289
+ )
290
+ anyio.run(main, backend="trio")
@@ -0,0 +1,3 @@
1
+ import swiss_army_upload
2
+
3
+ swiss_army_upload.entrypoint()
@@ -0,0 +1,83 @@
1
+ import abc
2
+ import importlib.metadata
3
+ import os
4
+ import typing
5
+
6
+ import httpx
7
+ import scr
8
+ from scr import scr_from
9
+
10
+
11
+ class InvalidCredentials(Exception):
12
+ """
13
+ Raised when the credentials were rejected
14
+ """
15
+
16
+
17
+ class UnknownSite(Exception):
18
+ """
19
+ Raised when the backend doesn't know the domain
20
+ """
21
+
22
+
23
+ class NoCredentialsFound(Exception):
24
+ """
25
+ Unable to find the appropriate credentials
26
+ """
27
+
28
+
29
+ class Backend(abc.ABC):
30
+ hinted_base: httpx.URL
31
+ scr: scr.Container
32
+
33
+ def __init__(self, ctx: object, hint: httpx.URL):
34
+ self.hinted_base = hint
35
+ self.scr = scr_from(ctx)
36
+
37
+ @abc.abstractmethod
38
+ async def __aenter__(self) -> typing.Self: ...
39
+
40
+ @abc.abstractmethod
41
+ async def __aexit__(self, exc_type, exc_value, traceback): ...
42
+
43
+ @abc.abstractmethod
44
+ async def check_credentials(self, url: httpx.URL) -> bool: ...
45
+
46
+ @abc.abstractmethod
47
+ async def prompt_for_credentials(self, url: httpx.URL): ...
48
+
49
+ @abc.abstractmethod
50
+ async def is_file(self, url: httpx.URL) -> bool: ...
51
+
52
+ @abc.abstractmethod
53
+ async def get_to_file(self, url: httpx.URL, file: os.PathLike | str): ...
54
+
55
+ @abc.abstractmethod
56
+ async def put_from_file(self, file: os.PathLike | str, url: httpx.URL): ...
57
+
58
+ @abc.abstractmethod
59
+ async def rsync_up(
60
+ self, src: os.PathLike | str, dest: httpx.URL, *, delete: bool
61
+ ): ...
62
+
63
+ @abc.abstractmethod
64
+ async def rsync_down(
65
+ self, src: httpx.URL, dest: os.PathLike | str, *, delete: bool
66
+ ): ...
67
+
68
+
69
+ class UnknownURLError(ValueError):
70
+ pass
71
+
72
+
73
+ def get_backend(ctx: object, url: httpx.URL) -> Backend:
74
+ try:
75
+ (ep,) = importlib.metadata.entry_points(
76
+ name=url.scheme, group="swiss_army_upload.backend"
77
+ )
78
+ except ValueError as exc:
79
+ raise UnknownURLError(
80
+ f"Don't know how to handle a {url.scheme!r} URL", url
81
+ ) from exc
82
+ backcls: type[Backend] = ep.load()
83
+ return backcls(ctx, url)