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.
- swiss_army_upload-0.3.4.dev16/LICENSE +15 -0
- swiss_army_upload-0.3.4.dev16/PKG-INFO +31 -0
- swiss_army_upload-0.3.4.dev16/README.md +6 -0
- swiss_army_upload-0.3.4.dev16/pyproject.toml +194 -0
- swiss_army_upload-0.3.4.dev16/src/scr.py +73 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/__init__.py +290 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/__main__.py +3 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/backends/__init__.py +83 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/backends/gitpages.py +411 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/backends/teahouse.py +418 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/deps.py +114 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/_pathinfo.py +196 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/ahashlib.py +24 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/cookiejar.py +31 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/keyring.py +136 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/oidc.py +116 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/rsync.py +370 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/sync.py +24 -0
- swiss_army_upload-0.3.4.dev16/src/swiss_army_upload/junk_drawer/typefinger.py +27 -0
|
@@ -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,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,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)
|