docput 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docput/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from pathlib import Path
2
+
3
+ from .sphinx_ext import setup # noqa: F401
4
+
5
+ sphinx_templates = str(Path(__file__).parent / "sphinx_ext/_templates")
6
+ sphinx_static = str(Path(__file__).parent / "sphinx_ext/_static")
docput/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
docput/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .main import main_group as main
2
+ from .manifest import manifest_group as manifest
3
+ from .version import version_group as version
4
+
5
+ __all__ = [main, version, manifest]
docput/cli/main.py ADDED
@@ -0,0 +1,123 @@
1
+ import shutil
2
+ import sys
3
+ from io import Writer
4
+ from pathlib import Path
5
+ from typing import Any, Callable
6
+
7
+ import click
8
+
9
+ from docput.path import URLPath
10
+ from docput.remote import Remote, open_remote
11
+ from docput.typing import Reader
12
+
13
+
14
+ class RemoteParamType(click.ParamType[Remote]):
15
+ name = "URL"
16
+
17
+ def __init__(self, commit_on_close: bool = True):
18
+ self.commit_on_close = commit_on_close
19
+
20
+ def convert(
21
+ self, value: Any, param: click.Parameter | None, ctx: click.Context | None
22
+ ) -> Remote:
23
+ match value:
24
+ case Remote():
25
+ remote = value
26
+ case str(url):
27
+ remote = open_remote(url)
28
+ case _:
29
+ self.fail(f"Unknown value type {type(value)}")
30
+ if self.commit_on_close:
31
+ assert ctx is not None, "Cannot open remote without a context to bind it to"
32
+ ctx.with_resource(remote)
33
+ return remote
34
+
35
+
36
+ def remote_option[FC: Callable[..., Any] | click.Command](
37
+ commit_on_close=True,
38
+ help="Remote to interact with",
39
+ **kwargs,
40
+ ) -> Callable[[FC], FC]:
41
+ return click.option(
42
+ "--remote",
43
+ "-r",
44
+ required=True,
45
+ type=RemoteParamType(commit_on_close),
46
+ envvar="DOCPUT_REMOTE",
47
+ show_envvar=True,
48
+ help=help,
49
+ **kwargs,
50
+ )
51
+
52
+
53
+ @click.group(context_settings={"show_default": True, "max_content_width": 120})
54
+ @click.version_option()
55
+ def main_group(): ...
56
+
57
+
58
+ @main_group.command(short_help="Download a file.")
59
+ @remote_option(help="Remote to download from.")
60
+ @click.argument("source", type=click.Path(path_type=URLPath))
61
+ @click.argument("dest", type=click.File(mode="xb"))
62
+ def download(remote: Remote, source: URLPath, dest: Writer[bytes]):
63
+ """Download SOURCE from the remote and write it to DEST"""
64
+ buff = remote.open_file(source)
65
+ shutil.copyfileobj(buff, dest)
66
+
67
+
68
+ @main_group.command(short_help="Upload a file or directory.") # type: ignore[attr-defined]
69
+ @remote_option(help="Remote to upload to.")
70
+ @click.option(
71
+ "--overwrite/--merge",
72
+ "-o/-m",
73
+ default=False,
74
+ is_flag=True,
75
+ show_default=True,
76
+ help="Merge or overwrite uploaded directories.",
77
+ )
78
+ @click.argument("source", type=click.Path(exists=True, allow_dash=True, path_type=Path))
79
+ @click.argument("dest", type=click.Path(path_type=URLPath))
80
+ def upload(
81
+ remote: Remote,
82
+ overwrite: bool,
83
+ source: Path | Reader[bytes],
84
+ dest: URLPath,
85
+ ):
86
+ """
87
+ Upload a file or directory SOURCE to the remote at DEST.
88
+
89
+ SOURCE is a path to a file or directory to upload. If SOURCE is `-`, its contents is read from standard input.
90
+
91
+ DEST is a path on the destination to write to.
92
+
93
+ If SOURCE is a file, its contents will be placed at DEST on the remote. Unlike tools like `cp` or `rsync`,
94
+ there is no special handling if DEST refers to a directory
95
+ """
96
+ if isinstance(source, Path) and str(source) == "-":
97
+ source = sys.stdin.buffer
98
+ remote.write(source, dest, overwrite)
99
+
100
+
101
+ @main_group.command(short_help="Delete a file or directory.")
102
+ @remote_option(help="Remote to delete from.")
103
+ @click.argument("path", type=click.Path(path_type=URLPath))
104
+ def rm(remote: Remote, path: URLPath):
105
+ """Delete the file or directory at PATH on the remote"""
106
+ remote.delete(path)
107
+
108
+
109
+ @main_group.command(short_help="Create a symlink.")
110
+ @remote_option(help="Remote to create a symlink on.")
111
+ @click.argument("source", type=click.Path(path_type=URLPath))
112
+ @click.argument("dest", type=click.Path(path_type=URLPath))
113
+ def ln(remote: Remote, source: URLPath, dest: URLPath):
114
+ """Create a symlink on the remote at DEST pointing to SOURCE"""
115
+ remote.make_symlink(source, dest)
116
+
117
+
118
+ @main_group.command(short_help="Create a directory.")
119
+ @remote_option(help="Remote to create a directory on.")
120
+ @click.argument("path", type=click.Path(path_type=URLPath))
121
+ def mkdir(remote: Remote, path: URLPath):
122
+ """Create a directory on the remote at PATH. Any existing file or directory at PATH will be deleted"""
123
+ remote.make_dir(path)
docput/cli/manifest.py ADDED
@@ -0,0 +1,58 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+
7
+ import click
8
+ from click import ClickException
9
+
10
+ from docput.config import get_config
11
+ from .main import main_group, remote_option
12
+ from ..manifest import VersionManifest
13
+
14
+
15
+ @main_group.group(help="Edit the version manifest.")
16
+ def manifest_group(): ...
17
+
18
+
19
+ @manifest_group.command(short_help="Open the version manifest in a text editor")
20
+ @remote_option()
21
+ @click.option(
22
+ "--open",
23
+ "-O",
24
+ "editor",
25
+ is_flag=False,
26
+ flag_value=os.environ.get("EDITOR") or "nano",
27
+ help="Open the manifest file in a text editor and write it to the remote on exit.",
28
+ )
29
+ @click.option(
30
+ "--url",
31
+ "root_url",
32
+ metavar="URL",
33
+ help="""
34
+ Set the root URL to a new value. The root URL is the base for all hrefs in the version manifest. This must be absolute (either a path starting
35
+ with "/", or a full URL like "https://example.com/docs/"). If it is not set, it is assumed to be "/".
36
+ """,
37
+ )
38
+ def edit(remote, editor: str | None, root_url: str | None):
39
+ """Edit the version manifest."""
40
+ if editor is not None:
41
+ with TemporaryDirectory() as td:
42
+ tmp_path = Path(td) / get_config().get("manifest_url")
43
+ with open(tmp_path, "wb") as temp:
44
+ shutil.copyfileobj(remote.manifest.dump(), temp)
45
+ while True:
46
+ subprocess.run([editor, tmp_path], check=True)
47
+ try:
48
+ with open(tmp_path, "rb") as temp:
49
+ remote.manifest = VersionManifest(temp)
50
+ return
51
+ except Exception as e:
52
+ retry = click.confirm(f"Error parsing manifest file: {e}. Retry?")
53
+ if not retry:
54
+ raise ClickException(
55
+ f"Aborting after error parsing manifest file: {e}"
56
+ )
57
+ if root_url is not None:
58
+ remote.manifest.root_url = root_url
docput/cli/version.py ADDED
@@ -0,0 +1,183 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Any, Callable
4
+
5
+ import click
6
+ from click import ClickException
7
+
8
+ from .main import main_group, remote_option
9
+ from ..config import get_config
10
+ from ..manifest import VersionRecord
11
+ from ..path import URLPath, reroot
12
+ from ..remote import Remote
13
+
14
+
15
+ def ref_option[FC: Callable[..., Any] | click.Command](**kwargs) -> Callable[[FC], FC]: # type: ignore[name-defined]
16
+ return click.option(
17
+ "--ref",
18
+ "-R",
19
+ envvar=["DOCPUT_VERSION_REF", "FORGEJO_REF", "GITHUB_REF"],
20
+ show_envvar=True,
21
+ required=True,
22
+ **kwargs,
23
+ )
24
+
25
+
26
+ @main_group.group(short_help="Create or edit versions.")
27
+ def version_group(): ...
28
+
29
+
30
+ @version_group.command(short_help="Push a documentation version to the remote.")
31
+ @click.argument("source", type=click.Path(exists=True, file_okay=False, path_type=Path))
32
+ @remote_option(help="Remote to push to.")
33
+ @ref_option(help="Version reference to push to.")
34
+ @click.option(
35
+ "--latest",
36
+ "-l",
37
+ is_flag=True,
38
+ default=False,
39
+ help="Mark this version as the latest release.",
40
+ )
41
+ @click.option(
42
+ "--visible/--hidden",
43
+ "-v/-h",
44
+ default=None,
45
+ help="Show this version in the version menu on the site.",
46
+ )
47
+ @click.option(
48
+ "--url",
49
+ "-u",
50
+ envvar=["DOCPUT_VERSION_URL"],
51
+ show_envvar=True,
52
+ help="""
53
+ Subdirectory on the site to write and link to. If not provided when pushing a new version, use the version ref with any text matching regex in the
54
+ config value `version.url_strip` removed. By default, this removes "refs/" from the start of the reference, so a version with the reference
55
+ `refs/tags/v1.0.0` would be published to `tags/v1.0.0`
56
+ """,
57
+ )
58
+ @click.option(
59
+ "--name",
60
+ "-n",
61
+ envvar=["DOCPUT_VERSION_NAME", "FORGEJO_REF_NAME", "GITHUB_REF_NAME"],
62
+ show_envvar=True,
63
+ help="The name to display for this version in the version menu.",
64
+ )
65
+ def push(
66
+ source: Path,
67
+ remote: Remote,
68
+ ref: str,
69
+ latest: bool,
70
+ visible: bool | None,
71
+ url: str | None,
72
+ name: str | None,
73
+ ):
74
+ """
75
+ Push a documentation version from SOURCE to the remote.
76
+ """
77
+ config = get_config()
78
+ manifest = remote.manifest
79
+ if (version := manifest.versions.get(ref)) is None:
80
+ # version does not exist in the manifest, so we need to make sure some values get filled in
81
+ if url is None:
82
+ url = re.sub(config.get("url_strip_regex"), "", ref)
83
+ version = VersionRecord(ref, {})
84
+ manifest.versions[ref] = version
85
+
86
+ # remove any old deployed versions. Old href may not equal the new href
87
+ if version.url is not None:
88
+ remote.delete(version.url)
89
+
90
+ # update the version properties
91
+ if name is not None:
92
+ version.name = name
93
+ if url is not None:
94
+ version.url = url
95
+ if visible is not None:
96
+ version.visible = visible
97
+
98
+ # root of the version we are deploying to
99
+ if version.url is None:
100
+ raise ClickException(f"Version {ref} has no href and none was provided")
101
+ version_url = URLPath(version.url)
102
+ assert not version_url.is_absolute(), "version URL must be a relative path"
103
+
104
+ # write local version and symlink in the manifest
105
+ remote.write(source, version_url, overwrite=True)
106
+ remote.make_symlink(
107
+ URLPath("/") / config.get("manifest_url"),
108
+ reroot(URLPath("/", version_url), config.get("manifest_url")),
109
+ )
110
+
111
+ # if this is the latest, make a symlink from /latest
112
+ if latest:
113
+ manifest.latest = version
114
+ latest_url = URLPath(config.get("latest_url"))
115
+ assert not latest_url.is_absolute(), "latest URL must be a relative path"
116
+ remote.make_symlink(version_url, latest_url)
117
+
118
+
119
+ @version_group.command()
120
+ @remote_option(help="Remote to delete the version from.")
121
+ @ref_option(help="Version reference to delete.")
122
+ @click.option(
123
+ "--keep",
124
+ is_flag=True,
125
+ default=False,
126
+ help="Keep the corresponding documentation, just delete the manifest record.",
127
+ )
128
+ def delete(remote: Remote, ref, keep):
129
+ """Delete a version from the remote."""
130
+ manifest = remote.manifest
131
+ if (latest := manifest.latest) is not None and latest.ref == ref:
132
+ manifest.latest = None
133
+
134
+ if (version := manifest.versions.pop(ref)) is None:
135
+ raise ClickException(f"Version {ref} does not exist in the manifest")
136
+
137
+ if not keep and version.url is not None:
138
+ remote.delete(version.url)
139
+
140
+
141
+ @version_group.command()
142
+ @remote_option(help="Remote to list versions on.", commit_on_close=False)
143
+ def list_command(remote: Remote):
144
+ """List all versions in the manifest."""
145
+ for version in remote.manifest.versions.values():
146
+ print(version)
147
+
148
+
149
+ @version_group.command()
150
+ @remote_option()
151
+ @ref_option()
152
+ @click.option(
153
+ "--visible/--hidden",
154
+ "-v/-h",
155
+ help="Show this version in the version menu on the site.",
156
+ )
157
+ @click.option(
158
+ "--url",
159
+ "-u",
160
+ envvar=["DOCPUT_VERSION_URL"],
161
+ show_envvar=True,
162
+ help="URL on the site to write and link to. Must be a relative-path reference.",
163
+ )
164
+ @click.option(
165
+ "--name",
166
+ "-n",
167
+ envvar=["DOCPUT_VERSION_NAME"],
168
+ show_envvar=True,
169
+ help="The name to display for this version in the version menu.",
170
+ )
171
+ def edit(
172
+ remote: Remote, ref: str, visible: bool | None, url: str | None, name: str | None
173
+ ):
174
+ """Edit a version in the manifest."""
175
+ if (version := remote.manifest.versions.get(ref)) is None:
176
+ raise ClickException(f"Version {ref} does not exist in the manifest")
177
+
178
+ if visible is not None:
179
+ version.visible = visible
180
+ if url is not None:
181
+ version.url = url
182
+ if name is not None:
183
+ version.name = name
docput/config.py ADDED
@@ -0,0 +1,129 @@
1
+ import os
2
+ import tomllib
3
+ from collections import UserDict
4
+ from functools import cache
5
+ from os import PathLike
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def project_root(start: str | PathLike[str] = os.curdir) -> Path:
11
+ """
12
+ Search for the project root, starting at the passed start path (default is the current directory).
13
+ Stops when it reaches a directory containing a ``pyproject.toml`` file, a ``.git`` directory, or a ``.hg`` directory, or the filesystem root.
14
+ This matches `the behavior of Black`_.
15
+
16
+ .. _the behavior of Black: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#where-black-looks-for-the-file
17
+ """
18
+ path = Path(start)
19
+ if not path.is_dir():
20
+ path = path.parent
21
+ for test_path in (path, *reversed(path.parents)):
22
+ for marker, is_dir in [
23
+ ("pyproject.toml", False),
24
+ (".git", True),
25
+ (".hg", True),
26
+ ]:
27
+ marker_path = test_path / marker
28
+ if marker_path.exists() and marker_path.is_dir() == is_dir:
29
+ return path
30
+ return path
31
+
32
+
33
+ default_config = {
34
+ "manifest_url": "versions.json",
35
+ "latest_url": "latest",
36
+ "url_strip_regex": r"^refs/",
37
+ }
38
+
39
+
40
+ def _read_config(path: PathLike[str] | str | None) -> dict[str, Any]:
41
+ """
42
+ Read the tool config from ``pyproject.toml``. If pyproject.toml cannot be auto-resolved or does not contain the ``tool.docput`` table, an
43
+ empty dict is returned.
44
+ """
45
+ if path is None:
46
+ path = project_root() / "pyproject.toml"
47
+ if not path.exists():
48
+ # cannot locate pyproject.toml automatically
49
+ return {}
50
+ else:
51
+ path = Path(path)
52
+
53
+ tool_identifier = "docput"
54
+ with open(path, "rb") as fd:
55
+ toml = tomllib.load(fd)
56
+ if (tool_block := toml.get("tool")) is None:
57
+ return {}
58
+ if (settings_block := tool_block.get(tool_identifier)) is None:
59
+ return {}
60
+ assert isinstance(settings_block, dict)
61
+ return settings_block
62
+
63
+
64
+ class _Sentinel: ...
65
+
66
+
67
+ class DictConfig(UserDict):
68
+
69
+ def get(self, key: str, default=None):
70
+
71
+ def get_recursive(data, key, *rest):
72
+
73
+ try:
74
+ value = data[key]
75
+ except KeyError:
76
+ return default
77
+ match (value, *rest):
78
+ case [value]:
79
+ return value
80
+ case [{**inner}, *rest]:
81
+ return get_recursive(inner, *rest)
82
+ case [_, *rest]:
83
+ raise KeyError(f"Unconsumed keys encountering non-dict: {rest}")
84
+
85
+ return default
86
+
87
+ return get_recursive(self.data, *key.split("."))
88
+
89
+
90
+ class EnvConfig:
91
+ def __init__(self, prefix):
92
+ self.prefix = prefix.upper()
93
+
94
+ def get(self, key: str, default=None):
95
+ env_key = self.prefix + "_" + key.replace(".", "_").upper()
96
+ return os.environ.get(env_key, default)
97
+
98
+
99
+ class CompoundConfig:
100
+
101
+ def __init__(self, configs):
102
+ self.configs = configs
103
+
104
+ def get(self, key: str, default=None):
105
+ _unset = _Sentinel()
106
+ for config in self.configs:
107
+ value = config.get(key, _unset)
108
+ if value is not _unset:
109
+ return value
110
+ return default
111
+
112
+ def __getitem__(self, key, /):
113
+ _unset = _Sentinel()
114
+ value = self.get(key, _unset)
115
+ if value is _unset:
116
+ raise KeyError(key)
117
+ return value
118
+
119
+
120
+ @cache
121
+ def get_config() -> CompoundConfig:
122
+ path = os.environ.get("DOCPUT_CONFIG_PATH")
123
+ return CompoundConfig(
124
+ [
125
+ EnvConfig("DOCPUT"),
126
+ DictConfig(_read_config(path)),
127
+ DictConfig(default_config),
128
+ ]
129
+ )
docput/manifest.py ADDED
@@ -0,0 +1,110 @@
1
+ import json
2
+ from io import BytesIO, TextIOWrapper
3
+ from typing import Any
4
+
5
+ from docput.typing import Reader
6
+
7
+
8
+ class VersionManifest:
9
+ _data: dict[str, Any]
10
+ versions: dict[str, VersionRecord]
11
+
12
+ def __init__(self, fileobj: Reader[bytes] | None):
13
+ if fileobj is None:
14
+ self._data = {}
15
+ else:
16
+ self._data = json.load(fileobj)
17
+ version_data = self._data.get("versions", {})
18
+ assert isinstance(version_data, dict)
19
+ self.versions = {
20
+ ref: VersionRecord(ref, data) for ref, data in version_data.items()
21
+ }
22
+
23
+ @property
24
+ def root_url(self) -> str | None:
25
+ return self._data.get("root_url")
26
+
27
+ @root_url.setter
28
+ def root_url(self, value: str | None):
29
+ if value is None:
30
+ if "root_url" in self._data:
31
+ del self._data["root_url"]
32
+ else:
33
+ self._data["root_url"] = str(value)
34
+
35
+ @property
36
+ def latest(self) -> VersionRecord | None:
37
+ if (latest_ref := self._data.get("latest")) is not None:
38
+ return self.versions[latest_ref]
39
+ return None
40
+
41
+ @latest.setter
42
+ def latest(self, value: VersionRecord | None):
43
+ if value is None:
44
+ if "latest" in self._data:
45
+ del self._data["latest"]
46
+ else:
47
+ self._data["latest"] = value.ref
48
+
49
+ def as_json(self):
50
+ self._data["versions"] = {
51
+ ref: record.as_json() for ref, record in self.versions.items()
52
+ }
53
+ return self._data
54
+
55
+ def dump(self) -> BytesIO:
56
+ fileobj = TextIOWrapper(BytesIO(), "utf-8")
57
+ json.dump(self.as_json(), fileobj, indent=4)
58
+ fileobj.seek(0)
59
+ return fileobj.detach()
60
+
61
+
62
+ class VersionRecord:
63
+ _ref: str
64
+ _data: dict[str, Any]
65
+
66
+ def __init__(self, ref: str, data: dict[str, Any]):
67
+ self._ref = ref
68
+ self._data = data
69
+
70
+ def __str__(self):
71
+ lines = [self.ref + ":"]
72
+ for key in ["name", "url", "visible"]:
73
+ if (value := self._data.get(key)) is not None:
74
+ lines.append(f" {key}: {value}")
75
+
76
+ return "\n".join(lines)
77
+
78
+ def __repr__(self):
79
+ return self.as_json().__repr__()
80
+
81
+ @property
82
+ def ref(self):
83
+ return self._ref
84
+
85
+ @property
86
+ def name(self) -> str:
87
+ return self._data.get("name", self._ref)
88
+
89
+ @name.setter
90
+ def name(self, value: str):
91
+ self._data["name"] = value
92
+
93
+ @property
94
+ def url(self) -> str | None:
95
+ return self._data.get("url", None)
96
+
97
+ @url.setter
98
+ def url(self, value: str):
99
+ self._data["url"] = value
100
+
101
+ @property
102
+ def visible(self):
103
+ return self._data.get("visible", False)
104
+
105
+ @visible.setter
106
+ def visible(self, value: bool):
107
+ self._data["visible"] = value
108
+
109
+ def as_json(self):
110
+ return self._data