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 +6 -0
- docput/__main__.py +3 -0
- docput/cli/__init__.py +5 -0
- docput/cli/main.py +123 -0
- docput/cli/manifest.py +58 -0
- docput/cli/version.py +183 -0
- docput/config.py +129 -0
- docput/manifest.py +110 -0
- docput/path.py +88 -0
- docput/remote/__init__.py +43 -0
- docput/remote/base.py +134 -0
- docput/remote/filesystem.py +53 -0
- docput/remote/git_pages.py +221 -0
- docput/sphinx_ext/__init__.py +22 -0
- docput/sphinx_ext/_static/docput.js +67 -0
- docput/sphinx_ext/_templates/sidebar/version-switch.html +10 -0
- docput/typing.py +13 -0
- docput-0.1.0.dist-info/METADATA +45 -0
- docput-0.1.0.dist-info/RECORD +25 -0
- docput-0.1.0.dist-info/WHEEL +5 -0
- docput-0.1.0.dist-info/entry_points.txt +2 -0
- docput-0.1.0.dist-info/licenses/LICENSE.md +27 -0
- docput-0.1.0.dist-info/scm_file_list.json +48 -0
- docput-0.1.0.dist-info/scm_version.json +8 -0
- docput-0.1.0.dist-info/top_level.txt +1 -0
docput/__init__.py
ADDED
docput/__main__.py
ADDED
docput/cli/__init__.py
ADDED
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
|