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.
@@ -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
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
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/license/MIT)
36
+ [![PyPI package](https://img.shields.io/pypi/v/userdir-publish)](https://pypi.org/project/userdir-publish)
37
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/userdir-publish)](https://pypi.org/project/userdir-publish)
38
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/RUB-EP1/userdir-publish/main.svg)](https://results.pre-commit.ci/latest/github/RUB-EP1/userdir-publish/main)
39
+ [![CI status](https://github.com/RUB-EP1/userdir-publish/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RUB-EP1/userdir-publish/actions/workflows/ci.yml?query=branch%3Amain)
40
+ [![Test coverage](https://codecov.io/gh/RUB-EP1/userdir-publish/branch/main/graph/badge.svg)](https://app.codecov.io/gh/RUB-EP1/userdir-publish)
41
+ [![Spelling checked](https://img.shields.io/badge/cspell-checked-brightgreen.svg)](https://github.com/streetsidesoftware/cspell/tree/main/packages/cspell)
42
+ [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
43
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
44
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
45
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ userdir-publish = userdir_publish.cli:app
@@ -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