userdir-publish 0.0.1__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.
- userdir_publish/__init__.py +1 -0
- userdir_publish/cli.py +118 -0
- userdir_publish/config.py +299 -0
- userdir_publish/publish.py +235 -0
- userdir_publish/py.typed +0 -0
- userdir_publish/version.py +24 -0
- userdir_publish-0.0.1.dist-info/METADATA +61 -0
- userdir_publish-0.0.1.dist-info/RECORD +12 -0
- userdir_publish-0.0.1.dist-info/WHEEL +5 -0
- userdir_publish-0.0.1.dist-info/entry_points.txt +2 -0
- userdir_publish-0.0.1.dist-info/licenses/LICENSE +21 -0
- userdir_publish-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Publish a static website to a personal web directory served by Apache's ``mod_userdir``."""
|
userdir_publish/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Command-line interface for ``userdir-publish``."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from userdir_publish.config import ConfigError, load_settings
|
|
13
|
+
from userdir_publish.publish import PublishError, publish, use_ssh
|
|
14
|
+
|
|
15
|
+
app = typer.Typer()
|
|
16
|
+
console = Console(soft_wrap=True)
|
|
17
|
+
"""Prints long paths on one line, so that they can be copied."""
|
|
18
|
+
|
|
19
|
+
RESUME_HINT = "Rerun to resume: files that were already copied are not sent again."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def main( # ruff: ignore[too-many-arguments]
|
|
24
|
+
source: Annotated[
|
|
25
|
+
str | None,
|
|
26
|
+
typer.Argument(help="Directory with the built website.", show_default=False),
|
|
27
|
+
] = None,
|
|
28
|
+
*,
|
|
29
|
+
host: Annotated[
|
|
30
|
+
str | None, typer.Option(help="SSH host that serves the pages.")
|
|
31
|
+
] = None,
|
|
32
|
+
user: Annotated[
|
|
33
|
+
str | None, typer.Option(help="Login name on the host.", show_default=False)
|
|
34
|
+
] = None,
|
|
35
|
+
web_root: Annotated[
|
|
36
|
+
str | None,
|
|
37
|
+
typer.Option(help="Web directory, may contain {user}.", show_default=False),
|
|
38
|
+
] = None,
|
|
39
|
+
subdirectory: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option(help="Subdirectory of the web root.", show_default=False),
|
|
42
|
+
] = None,
|
|
43
|
+
keep: Annotated[
|
|
44
|
+
list[str] | None,
|
|
45
|
+
typer.Option(help="rsync pattern under the target that is never deleted."),
|
|
46
|
+
] = None,
|
|
47
|
+
permissions: Annotated[
|
|
48
|
+
str | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
help="private, group-writable, none, or an rsync --chmod spec.",
|
|
51
|
+
show_default=False,
|
|
52
|
+
),
|
|
53
|
+
] = None,
|
|
54
|
+
transport: Annotated[
|
|
55
|
+
str | None,
|
|
56
|
+
typer.Option(help="auto, local, or ssh.", show_default=False),
|
|
57
|
+
] = None,
|
|
58
|
+
dry_run: Annotated[
|
|
59
|
+
bool, typer.Option(help="Show what would change without copying.")
|
|
60
|
+
] = False,
|
|
61
|
+
progress: Annotated[
|
|
62
|
+
bool | None,
|
|
63
|
+
typer.Option(
|
|
64
|
+
help="Show the transfer progress. Default: if the output is a terminal.",
|
|
65
|
+
show_default=False,
|
|
66
|
+
),
|
|
67
|
+
] = None,
|
|
68
|
+
show_config: Annotated[
|
|
69
|
+
bool, typer.Option(help="Print the merged configuration and exit.")
|
|
70
|
+
] = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
r"""Publish a built website to a web directory served by Apache's mod_userdir.
|
|
73
|
+
|
|
74
|
+
Options are read from the user config, then \[tool.userdir-publish] in
|
|
75
|
+
pyproject.toml, then these flags, each overriding the previous.
|
|
76
|
+
"""
|
|
77
|
+
cli = {
|
|
78
|
+
"source": source,
|
|
79
|
+
"host": host,
|
|
80
|
+
"user": user,
|
|
81
|
+
"web_root": web_root,
|
|
82
|
+
"subdirectory": subdirectory,
|
|
83
|
+
"keep": keep or None,
|
|
84
|
+
"permissions": permissions,
|
|
85
|
+
"transport": transport,
|
|
86
|
+
}
|
|
87
|
+
if progress is None:
|
|
88
|
+
progress = sys.stdout.isatty()
|
|
89
|
+
try:
|
|
90
|
+
settings = load_settings(cli)
|
|
91
|
+
if show_config:
|
|
92
|
+
for key, value in asdict(settings).items():
|
|
93
|
+
console.print(f"{key} = {value!r}")
|
|
94
|
+
console.print(f"target = {settings.target!r}")
|
|
95
|
+
return
|
|
96
|
+
command = publish(settings, dry_run=dry_run, progress=progress)
|
|
97
|
+
except (ConfigError, PublishError) as exc:
|
|
98
|
+
console.print(f"[red]{exc}")
|
|
99
|
+
raise typer.Exit(code=1) from exc
|
|
100
|
+
except subprocess.CalledProcessError as exc:
|
|
101
|
+
console.print(f"[red]rsync failed: {shlex.join(command_of(exc))}")
|
|
102
|
+
console.print(f"[yellow]{RESUME_HINT}")
|
|
103
|
+
raise typer.Exit(code=exc.returncode) from exc
|
|
104
|
+
except KeyboardInterrupt as exc:
|
|
105
|
+
console.print(f"[red]Interrupted. {RESUME_HINT}")
|
|
106
|
+
raise typer.Exit(code=130) from exc
|
|
107
|
+
if dry_run:
|
|
108
|
+
console.print(f"[yellow]Dry run: {shlex.join(command)}")
|
|
109
|
+
return
|
|
110
|
+
destination = f"{settings.host}:" if use_ssh(settings) else ""
|
|
111
|
+
console.print(f"[green]Published to {destination}{settings.target}")
|
|
112
|
+
url = settings.resolved_url()
|
|
113
|
+
if url is not None:
|
|
114
|
+
console.print(f"[green]{url}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def command_of(exc: subprocess.CalledProcessError) -> list[str]:
|
|
118
|
+
return [str(arg) for arg in exc.cmd]
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Layered configuration: defaults, user config, project config, and CLI flags.
|
|
2
|
+
|
|
3
|
+
Each layer overrides the one before it. For the login name, a matching host entry in the
|
|
4
|
+
user config overrides the project config, while the CLI takes precedence over both. The
|
|
5
|
+
``USER`` environment variable supplies the fallback. Exact host entries take precedence
|
|
6
|
+
over patterns; otherwise the first matching pattern in file order applies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# cspell:ignore rwxs
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tomllib
|
|
14
|
+
from dataclasses import dataclass, field, fields
|
|
15
|
+
from fnmatch import fnmatchcase
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
from typing import Any, Literal, get_args
|
|
18
|
+
|
|
19
|
+
TOOL_NAME = "userdir-publish"
|
|
20
|
+
|
|
21
|
+
Transport = Literal["auto", "local", "ssh"]
|
|
22
|
+
"""How to reach the web directory, see `Settings.transport`."""
|
|
23
|
+
TRANSPORTS: tuple[Transport, ...] = get_args(Transport)
|
|
24
|
+
|
|
25
|
+
PermissionPreset = Literal["private", "group-writable", "none"]
|
|
26
|
+
"""Named permission settings, see `Settings.permissions`."""
|
|
27
|
+
|
|
28
|
+
PERMISSION_PRESETS: dict[PermissionPreset, str | None] = {
|
|
29
|
+
"private": "Dg-rwx,Do=x,Fg-rwx,Fo=r",
|
|
30
|
+
"group-writable": "Dg=rwxs,Do=x,Fg=rw,Fo=r",
|
|
31
|
+
"none": None,
|
|
32
|
+
}
|
|
33
|
+
"""Named arguments for ``rsync --chmod``, see `Settings.permissions`."""
|
|
34
|
+
|
|
35
|
+
_CHMOD_CLAUSE = r"[DF]?(?:[0-7]{3,4}|[ugoa]*(?:[-+=][rwxXst]*)+)"
|
|
36
|
+
CHMOD_PATTERN = rf"^{_CHMOD_CLAUSE}(?:,{_CHMOD_CLAUSE})*$"
|
|
37
|
+
"""Comma-separated clauses accepted by ``rsync --chmod``, such as ``D2775,Fg-w``.
|
|
38
|
+
|
|
39
|
+
`Settings.permissions` must be a key of `PERMISSION_PRESETS` or match this pattern.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigError(ValueError):
|
|
44
|
+
"""Raised when a configuration value is invalid."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Settings:
|
|
49
|
+
"""Merged and validated configuration for one publication.
|
|
50
|
+
|
|
51
|
+
Create it with `load_settings` rather than directly, so that all configuration
|
|
52
|
+
layers are applied and the values are validated.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
source: str | None = field(
|
|
56
|
+
default=None,
|
|
57
|
+
metadata={"examples": ["docs/_build/html"]},
|
|
58
|
+
)
|
|
59
|
+
"""Directory of the built website, relative to the directory with ``pyproject.toml``.
|
|
60
|
+
|
|
61
|
+
The first command-line argument overrides it.
|
|
62
|
+
"""
|
|
63
|
+
host: str | None = field(default=None, metadata={"examples": ["web.example.edu"]})
|
|
64
|
+
"""SSH host that serves the pages. Without it, only local publishing is possible."""
|
|
65
|
+
web_root: str = "~/public_html"
|
|
66
|
+
"""Web directory on the server. ``{user}`` is replaced by the login name.
|
|
67
|
+
|
|
68
|
+
A leading ``~`` means the home directory of the login on the server.
|
|
69
|
+
"""
|
|
70
|
+
subdirectory: str = ""
|
|
71
|
+
"""Subdirectory of the web root that this project owns. Empty means the web root.
|
|
72
|
+
|
|
73
|
+
Files in it that are not in the build are deleted.
|
|
74
|
+
"""
|
|
75
|
+
keep: tuple[str, ...] = ()
|
|
76
|
+
"""``rsync`` filter patterns, relative to the target, for files that are never deleted.
|
|
77
|
+
|
|
78
|
+
For example, ``/*/`` keeps all top-level directories.
|
|
79
|
+
"""
|
|
80
|
+
permissions: PermissionPreset | str = field(
|
|
81
|
+
default="private",
|
|
82
|
+
metadata={"pattern": CHMOD_PATTERN},
|
|
83
|
+
)
|
|
84
|
+
"""Permissions of the published files, as a preset or a raw ``rsync --chmod`` spec.
|
|
85
|
+
|
|
86
|
+
``private`` hides the files from group members, ``group-writable`` lets the group
|
|
87
|
+
maintain a shared page, and ``none`` keeps the local modes.
|
|
88
|
+
"""
|
|
89
|
+
transport: Transport = "auto"
|
|
90
|
+
"""How to reach the web directory.
|
|
91
|
+
|
|
92
|
+
``auto`` uses SSH if `host` is set and the target does not exist on this machine.
|
|
93
|
+
"""
|
|
94
|
+
htaccess_source: str | None = field(default=None, metadata={"examples": ["docs"]})
|
|
95
|
+
"""Directory, relative to the project root, whose ``.htaccess`` files the build must contain.
|
|
96
|
+
|
|
97
|
+
Publishing is refused if a build lacks one of them or contains an outdated copy.
|
|
98
|
+
"""
|
|
99
|
+
url: str | None = field(
|
|
100
|
+
default=None,
|
|
101
|
+
metadata={"examples": ["https://web.example.edu/~{user}/{subdirectory}/"]},
|
|
102
|
+
)
|
|
103
|
+
"""Address printed after publishing. ``{user}`` and ``{subdirectory}`` are replaced.
|
|
104
|
+
|
|
105
|
+
Defaults to ``https://{host}/~{user}/{subdirectory}/`` if `host` is set.
|
|
106
|
+
"""
|
|
107
|
+
user: str | None = field(default=None, metadata={"examples": ["jdoe"]})
|
|
108
|
+
"""Login name on the host. Defaults to the ``USER`` environment variable."""
|
|
109
|
+
_project_root: Path = field(default=Path(), repr=False)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def login(self) -> str:
|
|
113
|
+
"""`user`, or the ``USER`` environment variable if it is not set.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ConfigError: If neither is set.
|
|
117
|
+
"""
|
|
118
|
+
user = self.user or os.getenv("USER")
|
|
119
|
+
if not user:
|
|
120
|
+
msg = "Could not determine the login name, set it with --user"
|
|
121
|
+
raise ConfigError(msg)
|
|
122
|
+
return user
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def chmod(self) -> str | None:
|
|
126
|
+
"""`permissions` as ``rsync --chmod`` spec, or `None` to leave them as is."""
|
|
127
|
+
return PERMISSION_PRESETS.get(self.permissions, self.permissions)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def source_dir(self) -> Path:
|
|
131
|
+
"""`source` resolved against the project root.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ConfigError: If `source` is not set.
|
|
135
|
+
"""
|
|
136
|
+
if self.source is None:
|
|
137
|
+
msg = "No source directory given, pass it as argument or set source"
|
|
138
|
+
raise ConfigError(msg)
|
|
139
|
+
return self._project_root / self.source
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def htaccess_dir(self) -> Path | None:
|
|
143
|
+
"""`htaccess_source` resolved against the project root."""
|
|
144
|
+
if self.htaccess_source is None:
|
|
145
|
+
return None
|
|
146
|
+
return self._project_root / self.htaccess_source
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def target(self) -> str:
|
|
150
|
+
"""`web_root` with ``{user}`` replaced, followed by `subdirectory`.
|
|
151
|
+
|
|
152
|
+
A leading ``~`` is kept, so the path is valid on the host and on this machine.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ConfigError: If the web root is empty.
|
|
156
|
+
"""
|
|
157
|
+
root = self.web_root.format(user=self.login).rstrip("/")
|
|
158
|
+
if not root:
|
|
159
|
+
msg = "The web root may not be empty"
|
|
160
|
+
raise ConfigError(msg)
|
|
161
|
+
if not self.subdirectory:
|
|
162
|
+
return root
|
|
163
|
+
return f"{root}/{self.subdirectory}"
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def remote_target(self) -> str:
|
|
167
|
+
"""Target path for SSH, where a leading ``~/`` means the login's home."""
|
|
168
|
+
return self.target.removeprefix("~/")
|
|
169
|
+
|
|
170
|
+
def resolved_url(self) -> str | None:
|
|
171
|
+
"""`url` with ``{user}`` and ``{subdirectory}`` replaced.
|
|
172
|
+
|
|
173
|
+
Without `url`, the address is derived from `host`, or `None` if that is unset.
|
|
174
|
+
"""
|
|
175
|
+
if self.url is not None:
|
|
176
|
+
return self.url.format(user=self.login, subdirectory=self.subdirectory)
|
|
177
|
+
if self.host is None:
|
|
178
|
+
return None
|
|
179
|
+
path = "/".join(part for part in (f"~{self.login}", self.subdirectory) if part)
|
|
180
|
+
return f"https://{self.host}/{path}/"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_settings(
|
|
184
|
+
cli: dict[str, Any] | None = None,
|
|
185
|
+
*,
|
|
186
|
+
project_root: Path | None = None,
|
|
187
|
+
user_config: Path | None = None,
|
|
188
|
+
) -> Settings:
|
|
189
|
+
"""Merge all configuration layers into one `Settings` object.
|
|
190
|
+
|
|
191
|
+
Entries of *cli* that are `None` count as not given. *project_root* is the directory
|
|
192
|
+
with the ``pyproject.toml`` that holds the project configuration and defaults to the
|
|
193
|
+
nearest such directory at or above the working directory. *user_config* defaults to
|
|
194
|
+
`user_config_path`.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ConfigError: If an option is unknown or a value is invalid.
|
|
198
|
+
"""
|
|
199
|
+
if project_root is None:
|
|
200
|
+
project_root = _find_project_root(Path.cwd())
|
|
201
|
+
if user_config is None:
|
|
202
|
+
user_config = user_config_path()
|
|
203
|
+
user_table = _read_table(user_config, key=None)
|
|
204
|
+
project_table = _read_table(project_root / "pyproject.toml", key=TOOL_NAME)
|
|
205
|
+
cli = {k: v for k, v in (cli or {}).items() if v is not None}
|
|
206
|
+
hosts = user_table.pop("hosts", {})
|
|
207
|
+
merged = {**user_table, **project_table}
|
|
208
|
+
host = cli.get("host", merged.get("host"))
|
|
209
|
+
if host is not None:
|
|
210
|
+
if host in hosts:
|
|
211
|
+
merged.update(hosts[host])
|
|
212
|
+
else:
|
|
213
|
+
for pattern, options in hosts.items():
|
|
214
|
+
if fnmatchcase(host, pattern):
|
|
215
|
+
merged.update(options)
|
|
216
|
+
break
|
|
217
|
+
merged.update(cli)
|
|
218
|
+
settings = _to_settings(merged, project_root)
|
|
219
|
+
_validate(settings)
|
|
220
|
+
return settings
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _find_project_root(start: Path) -> Path:
|
|
224
|
+
for directory in (start, *start.parents):
|
|
225
|
+
if (directory / "pyproject.toml").is_file():
|
|
226
|
+
return directory
|
|
227
|
+
return start
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def user_config_path() -> Path:
|
|
231
|
+
"""Locate the user configuration file.
|
|
232
|
+
|
|
233
|
+
This is :file:`$XDG_CONFIG_HOME/userdir-publish.toml` if it exists, otherwise
|
|
234
|
+
:file:`$XDG_CONFIG_HOME/userdir-publish/config.toml`. ``XDG_CONFIG_HOME`` defaults
|
|
235
|
+
to :file:`~/.config`.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ConfigError: If both files exist.
|
|
239
|
+
"""
|
|
240
|
+
config_home = Path(os.getenv("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
241
|
+
nested = config_home / TOOL_NAME / "config.toml"
|
|
242
|
+
flat = config_home / f"{TOOL_NAME}.toml"
|
|
243
|
+
if nested.is_file() and flat.is_file():
|
|
244
|
+
msg = f"Found both {nested} and {flat}; keep only one"
|
|
245
|
+
raise ConfigError(msg)
|
|
246
|
+
return flat if flat.is_file() else nested
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _read_table(path: Path, key: str | None) -> dict[str, Any]:
|
|
250
|
+
if not path.is_file():
|
|
251
|
+
return {}
|
|
252
|
+
with path.open("rb") as stream:
|
|
253
|
+
table = tomllib.load(stream)
|
|
254
|
+
if key is not None:
|
|
255
|
+
table = table.get("tool", {}).get(key, {})
|
|
256
|
+
normalized = _normalize_keys(table)
|
|
257
|
+
if "hosts" in normalized:
|
|
258
|
+
normalized["hosts"] = {
|
|
259
|
+
host: _normalize_keys(options)
|
|
260
|
+
for host, options in normalized["hosts"].items()
|
|
261
|
+
}
|
|
262
|
+
return normalized
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _normalize_keys(table: dict[str, Any]) -> dict[str, Any]:
|
|
266
|
+
return {k.replace("-", "_"): v for k, v in table.items()}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _to_settings(table: dict[str, Any], project_root: Path) -> Settings:
|
|
270
|
+
known = {f.name for f in fields(Settings) if not f.name.startswith("_")}
|
|
271
|
+
unknown = sorted(set(table) - known)
|
|
272
|
+
if unknown:
|
|
273
|
+
msg = f"Unknown {TOOL_NAME} options: {', '.join(unknown)}"
|
|
274
|
+
raise ConfigError(msg)
|
|
275
|
+
if "keep" in table:
|
|
276
|
+
table["keep"] = tuple(table["keep"])
|
|
277
|
+
return Settings(**table, _project_root=project_root)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate(settings: Settings) -> None:
|
|
281
|
+
if settings.transport not in TRANSPORTS:
|
|
282
|
+
msg = f"transport must be one of {', '.join(TRANSPORTS)}"
|
|
283
|
+
raise ConfigError(msg)
|
|
284
|
+
if settings.transport == "ssh" and not settings.host:
|
|
285
|
+
msg = "Publishing over SSH requires a host"
|
|
286
|
+
raise ConfigError(msg)
|
|
287
|
+
subdirectory = PurePosixPath(settings.subdirectory)
|
|
288
|
+
if subdirectory.is_absolute() or ".." in subdirectory.parts:
|
|
289
|
+
msg = f"subdirectory must be relative to the web root: {settings.subdirectory}"
|
|
290
|
+
raise ConfigError(msg)
|
|
291
|
+
if settings.permissions not in PERMISSION_PRESETS and not re.match(
|
|
292
|
+
CHMOD_PATTERN, settings.permissions
|
|
293
|
+
):
|
|
294
|
+
msg = f"permissions is neither a preset nor an rsync --chmod spec: {settings.permissions!r}"
|
|
295
|
+
raise ConfigError(msg)
|
|
296
|
+
if any(c.isspace() for c in settings.login) or "@" in settings.login:
|
|
297
|
+
msg = f"Invalid login name: {settings.login!r}"
|
|
298
|
+
raise ConfigError(msg)
|
|
299
|
+
_ = settings.target # raises if the web root is empty
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Validate a built website and copy it to the web directory with rsync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from userdir_publish.config import Settings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SYMLINK_EXIT = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PublishError(RuntimeError):
|
|
23
|
+
"""Raised when the website cannot be published safely."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_build(settings: Settings) -> None:
|
|
27
|
+
"""Check that the build can be published.
|
|
28
|
+
|
|
29
|
+
The build must contain an ``index.html``. If `~.Settings.htaccess_source` is set,
|
|
30
|
+
every ``.htaccess`` file under it must appear unchanged at the same relative path in
|
|
31
|
+
the build. Hidden directories, the build itself, and files ignored by Git are not
|
|
32
|
+
searched.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
PublishError: If a check fails.
|
|
36
|
+
"""
|
|
37
|
+
source = settings.source_dir
|
|
38
|
+
if not (source / "index.html").is_file():
|
|
39
|
+
msg = f"The build in {source} has no index.html"
|
|
40
|
+
raise PublishError(msg)
|
|
41
|
+
rules_dir = settings.htaccess_dir
|
|
42
|
+
if rules_dir is None:
|
|
43
|
+
return
|
|
44
|
+
for rules in _access_rules(rules_dir):
|
|
45
|
+
if rules.is_relative_to(source):
|
|
46
|
+
continue
|
|
47
|
+
built = source / rules.relative_to(rules_dir)
|
|
48
|
+
if not built.is_file() or built.read_bytes() != rules.read_bytes():
|
|
49
|
+
msg = f"The build is missing the current access rules: {built}"
|
|
50
|
+
raise PublishError(msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _access_rules(directory: Path) -> Iterator[Path]:
|
|
54
|
+
"""Find the ``.htaccess`` files under *directory*, skipping hidden directories.
|
|
55
|
+
|
|
56
|
+
In a Git work tree, files that Git ignores are skipped as well.
|
|
57
|
+
"""
|
|
58
|
+
tracked = _git_access_rules(directory)
|
|
59
|
+
if tracked is not None:
|
|
60
|
+
yield from tracked
|
|
61
|
+
return
|
|
62
|
+
for root, dirs, files in os.walk(directory):
|
|
63
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
64
|
+
if ".htaccess" in files:
|
|
65
|
+
yield Path(root, ".htaccess")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _git_access_rules(directory: Path) -> list[Path] | None:
|
|
69
|
+
"""List the ``.htaccess`` files that Git does not ignore.
|
|
70
|
+
|
|
71
|
+
Returns `None` outside a Git work tree, or if Git ignores *directory* itself, such
|
|
72
|
+
as a project inside a home directory that is tracked with Git.
|
|
73
|
+
"""
|
|
74
|
+
git = shutil.which("git")
|
|
75
|
+
if git is None:
|
|
76
|
+
return None
|
|
77
|
+
ignored = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
78
|
+
[git, "check-ignore", "--quiet", "."],
|
|
79
|
+
cwd=directory,
|
|
80
|
+
capture_output=True,
|
|
81
|
+
check=False,
|
|
82
|
+
)
|
|
83
|
+
if ignored.returncode != 1:
|
|
84
|
+
return None
|
|
85
|
+
result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
86
|
+
[
|
|
87
|
+
git,
|
|
88
|
+
"ls-files",
|
|
89
|
+
"-z",
|
|
90
|
+
"--cached",
|
|
91
|
+
"--others",
|
|
92
|
+
"--exclude-standard",
|
|
93
|
+
"--",
|
|
94
|
+
":(glob)**/.htaccess",
|
|
95
|
+
],
|
|
96
|
+
cwd=directory,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
check=False,
|
|
100
|
+
)
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
return None
|
|
103
|
+
return [
|
|
104
|
+
directory / name
|
|
105
|
+
for name in result.stdout.split("\0")
|
|
106
|
+
if name and not any(part.startswith(".") for part in Path(name).parent.parts)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def use_ssh(settings: Settings) -> bool:
|
|
111
|
+
"""Decide whether to publish over SSH.
|
|
112
|
+
|
|
113
|
+
With ``transport = "auto"``, SSH is used when a host is set and the target directory
|
|
114
|
+
does not exist on this machine.
|
|
115
|
+
"""
|
|
116
|
+
if settings.transport == "auto":
|
|
117
|
+
if settings.host is None:
|
|
118
|
+
return False
|
|
119
|
+
return not Path(settings.target).expanduser().is_dir()
|
|
120
|
+
return settings.transport == "ssh"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def rsync_command(
|
|
124
|
+
settings: Settings, *, ssh: bool, dry_run: bool = False, progress: bool = False
|
|
125
|
+
) -> list[str]:
|
|
126
|
+
"""Build the rsync command that mirrors the build to the target.
|
|
127
|
+
|
|
128
|
+
The command passes ``--delete``, so files in the target that are not in the build
|
|
129
|
+
are removed, except those matched by `~.Settings.keep`. Unless *dry_run* is set, a
|
|
130
|
+
local target directory is created, and over SSH the remote target is created by the
|
|
131
|
+
command itself. With *progress*, rsync reports the overall transfer progress.
|
|
132
|
+
"""
|
|
133
|
+
command = [_executable("rsync"), "--recursive", "--links", "--times", "--delete"]
|
|
134
|
+
command.extend(_mkpath_arguments(settings, ssh=ssh, dry_run=dry_run))
|
|
135
|
+
if settings.chmod is not None:
|
|
136
|
+
command.extend(["--perms", f"--chmod={settings.chmod}"])
|
|
137
|
+
command.extend(f"--filter=P {pattern}" for pattern in settings.keep)
|
|
138
|
+
if dry_run:
|
|
139
|
+
command.extend(["--dry-run", "--itemize-changes"])
|
|
140
|
+
elif progress:
|
|
141
|
+
command.append("--info=progress2")
|
|
142
|
+
command.append(f"{settings.source_dir}/")
|
|
143
|
+
if ssh:
|
|
144
|
+
command.append(f"{settings.login}@{settings.host}:{settings.remote_target}/")
|
|
145
|
+
else:
|
|
146
|
+
command.append(f"{Path(settings.target).expanduser()}/")
|
|
147
|
+
return command
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _refuse_symlink(settings: Settings, *, ssh: bool) -> None:
|
|
151
|
+
"""Refuse a symlinked target, because it may point outside the web root."""
|
|
152
|
+
if ssh:
|
|
153
|
+
target = shlex.quote(settings.remote_target)
|
|
154
|
+
result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
155
|
+
[
|
|
156
|
+
_executable("ssh"),
|
|
157
|
+
f"{settings.login}@{settings.host}",
|
|
158
|
+
f"if [ -L {target} ]; then exit {_SYMLINK_EXIT}; fi",
|
|
159
|
+
],
|
|
160
|
+
check=False,
|
|
161
|
+
)
|
|
162
|
+
if result.returncode not in {0, _SYMLINK_EXIT}:
|
|
163
|
+
msg = f"Could not inspect {settings.remote_target} on {settings.host}"
|
|
164
|
+
raise PublishError(msg)
|
|
165
|
+
is_symlink = result.returncode == _SYMLINK_EXIT
|
|
166
|
+
else:
|
|
167
|
+
is_symlink = Path(settings.target).expanduser().is_symlink()
|
|
168
|
+
if is_symlink:
|
|
169
|
+
msg = f"Refusing to publish to a symlink: {settings.target}"
|
|
170
|
+
raise PublishError(msg)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _mkpath_arguments(settings: Settings, *, ssh: bool, dry_run: bool) -> list[str]:
|
|
174
|
+
"""Create the parent directories of the target, like ``rsync --mkpath``.
|
|
175
|
+
|
|
176
|
+
``--mkpath`` requires rsync 3.2.3 or newer on both ends, which older servers lack.
|
|
177
|
+
Over SSH, the directories are therefore created by the remote command that starts
|
|
178
|
+
rsync. Once all servers run rsync 3.2.3 or newer, return ``["--mkpath"]`` instead.
|
|
179
|
+
"""
|
|
180
|
+
if dry_run:
|
|
181
|
+
return []
|
|
182
|
+
if ssh:
|
|
183
|
+
target = shlex.quote(settings.remote_target)
|
|
184
|
+
return [f"--rsync-path=mkdir -p -- {target} && rsync"]
|
|
185
|
+
Path(settings.target).expanduser().mkdir(parents=True, exist_ok=True)
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def publish(
|
|
190
|
+
settings: Settings, *, dry_run: bool = False, progress: bool = False
|
|
191
|
+
) -> list[str]:
|
|
192
|
+
"""Copy the build to the target and return the rsync command that was run.
|
|
193
|
+
|
|
194
|
+
A dry run still validates the build and refuses a symlinked target, but copies
|
|
195
|
+
nothing and creates no directories.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
PublishError: If the build is invalid or the target is a symlink.
|
|
199
|
+
subprocess.CalledProcessError: If rsync fails.
|
|
200
|
+
"""
|
|
201
|
+
validate_build(settings)
|
|
202
|
+
ssh = use_ssh(settings)
|
|
203
|
+
_refuse_symlink(settings, ssh=ssh)
|
|
204
|
+
if settings.chmod is not None and is_openrsync():
|
|
205
|
+
msg = (
|
|
206
|
+
"The rsync in PATH is openrsync, which ignores --chmod, so the published"
|
|
207
|
+
" files would not get the configured permissions. Install GNU rsync, for"
|
|
208
|
+
" instance with 'brew install rsync' on macOS, or set permissions to 'none'."
|
|
209
|
+
)
|
|
210
|
+
raise PublishError(msg)
|
|
211
|
+
command = rsync_command(settings, ssh=ssh, dry_run=dry_run, progress=progress)
|
|
212
|
+
subprocess.run(command, check=True) # ruff: ignore[subprocess-without-shell-equals-true]
|
|
213
|
+
return command
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_openrsync() -> bool:
|
|
217
|
+
"""Check whether the rsync in PATH is openrsync, the default rsync of macOS.
|
|
218
|
+
|
|
219
|
+
openrsync accepts ``--chmod`` but silently leaves the permissions unchanged.
|
|
220
|
+
"""
|
|
221
|
+
result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
|
|
222
|
+
[_executable("rsync"), "--version"],
|
|
223
|
+
capture_output=True,
|
|
224
|
+
check=False,
|
|
225
|
+
text=True,
|
|
226
|
+
)
|
|
227
|
+
return "openrsync" in result.stdout + result.stderr
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _executable(name: str) -> str:
|
|
231
|
+
path = shutil.which(name)
|
|
232
|
+
if path is None:
|
|
233
|
+
msg = f"{name} executable not found in PATH"
|
|
234
|
+
raise PublishError(msg)
|
|
235
|
+
return path
|
userdir_publish/py.typed
ADDED
|
File without changes
|
|
@@ -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 = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: userdir-publish
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Publish a static website to a personal web directory served by Apache's mod_userdir
|
|
5
|
+
Author: Remco de Boer
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Changelog, https://github.com/RUB-EP1/userdir-publish/releases
|
|
8
|
+
Project-URL: Documentation, https://rub-ep1.github.io/userdir-publish
|
|
9
|
+
Project-URL: Homepage, https://rub-ep1.github.io/userdir-publish
|
|
10
|
+
Project-URL: Issues, https://github.com/RUB-EP1/userdir-publish/issues
|
|
11
|
+
Project-URL: Source, https://github.com/RUB-EP1/userdir-publish
|
|
12
|
+
Keywords: apache,mod_userdir,rsync,static-site
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: rich
|
|
28
|
+
Requires-Dist: typer
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
<!-- cspell:ignore pipx -->
|
|
32
|
+
|
|
33
|
+
# userdir-publish
|
|
34
|
+
|
|
35
|
+
[](https://opensource.org/license/MIT)
|
|
36
|
+
[](https://pypi.org/project/userdir-publish)
|
|
37
|
+
[](https://pypi.org/project/userdir-publish)
|
|
38
|
+
[](https://results.pre-commit.ci/latest/github/RUB-EP1/userdir-publish/main)
|
|
39
|
+
[](https://github.com/RUB-EP1/userdir-publish/actions/workflows/ci.yml?query=branch%3Amain)
|
|
40
|
+
[](https://app.codecov.io/gh/RUB-EP1/userdir-publish)
|
|
41
|
+
[](https://github.com/streetsidesoftware/cspell/tree/main/packages/cspell)
|
|
42
|
+
[](https://github.com/prettier/prettier)
|
|
43
|
+
[](https://github.com/astral-sh/uv)
|
|
44
|
+
[](https://github.com/astral-sh/ruff)
|
|
45
|
+
[](https://github.com/astral-sh/ty)
|
|
46
|
+
|
|
47
|
+
`userdir-publish` copies a built static website to a personal web directory, such as `https://web.example.edu/~jdoe/`, that Apache serves with [`mod_userdir`](https://httpd.apache.org/docs/current/mod/mod_userdir.html). It uses [`rsync`](https://rsync.samba.org), either over [SSH](https://www.openssh.org) or to a local path when the web directory is mounted or you are logged in on the server. It removes stale files, sets permissions the web server can read, and refuses to publish a build that lacks `index.html` or the current `.htaccess` rules.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
The tool requires `rsync` on your machine and on the server. On macOS, install GNU `rsync` (for instance with `brew install rsync`), because the built-in `openrsync` ignores the permission settings. Install it from [PyPI](https://pypi.org/project/userdir-publish):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install userdir-publish
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or with `pipx install userdir-publish`. Then run `userdir-publish --help`.
|
|
58
|
+
|
|
59
|
+
## Documentation
|
|
60
|
+
|
|
61
|
+
See [rub-ep1.github.io/userdir-publish](https://rub-ep1.github.io/userdir-publish) for other installation methods, configuration, group pages, and task runner integration.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
userdir_publish/__init__.py,sha256=iWmEy4wmlFcK6nkFfv1jhd6ROgemIa4osLM29fSgcbM,95
|
|
2
|
+
userdir_publish/cli.py,sha256=fdJGeTVqoB8b6rqBSSUejNtPQBy-qjNP4Clhp_BZ1Ec,4017
|
|
3
|
+
userdir_publish/config.py,sha256=wFYU_UUSNV32qwn9Zgqetwi3zRIEwpulJ1lhHbxP0RY,11143
|
|
4
|
+
userdir_publish/publish.py,sha256=ZphRqalDhUMuiKfbRak7oIfeBNCtYmfmC8mtAuiMIaA,8329
|
|
5
|
+
userdir_publish/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
userdir_publish/version.py,sha256=8OsTLsIVB9D0HdPTmt5rVwyVUBe9xTVGkRslXicxzkM,520
|
|
7
|
+
userdir_publish-0.0.1.dist-info/licenses/LICENSE,sha256=1gDxzuKGXePQxIISDLPyw9-YR2_Qd8RCeYjXYeIBKqg,1064
|
|
8
|
+
userdir_publish-0.0.1.dist-info/METADATA,sha256=UqNoQHnNloQzYoKjVHdwM17BwL5kOqJ6cquYMesJHqc,4061
|
|
9
|
+
userdir_publish-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
userdir_publish-0.0.1.dist-info/entry_points.txt,sha256=ggoLf8AxMWOUrl9k_BYsCV-OWKVAjawo65rUCof7H0M,60
|
|
11
|
+
userdir_publish-0.0.1.dist-info/top_level.txt,sha256=0A-W2yAoOg8WfmCsosc840lnvwTntsI8Jt3JoF02fEY,16
|
|
12
|
+
userdir_publish-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RUB-EP1
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
userdir_publish
|