forge-publish 2.3.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.
- forge_publish/__init__.py +3 -0
- forge_publish/__main__.py +4 -0
- forge_publish/cli.py +211 -0
- forge_publish/client.py +167 -0
- forge_publish/config.py +229 -0
- forge_publish/errors.py +10 -0
- forge_publish/publishers/__init__.py +0 -0
- forge_publish/publishers/deb.py +460 -0
- forge_publish/publishers/generic.py +84 -0
- forge_publish/publishers/npm.py +281 -0
- forge_publish-2.3.0.dist-info/METADATA +188 -0
- forge_publish-2.3.0.dist-info/RECORD +16 -0
- forge_publish-2.3.0.dist-info/WHEEL +5 -0
- forge_publish-2.3.0.dist-info/entry_points.txt +2 -0
- forge_publish-2.3.0.dist-info/licenses/LICENSE +202 -0
- forge_publish-2.3.0.dist-info/top_level.txt +1 -0
forge_publish/cli.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .client import ForgejoClient
|
|
8
|
+
from .config import configure, load_config
|
|
9
|
+
from .errors import ForgePublishError
|
|
10
|
+
from .publishers import deb, generic, npm
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _create_client(
|
|
14
|
+
*,
|
|
15
|
+
dry_run: bool,
|
|
16
|
+
insecure: bool,
|
|
17
|
+
) -> ForgejoClient:
|
|
18
|
+
config = load_config(require_token=not dry_run)
|
|
19
|
+
|
|
20
|
+
if insecure and not dry_run:
|
|
21
|
+
click.echo(
|
|
22
|
+
"WARNING: TLS certificate verification is disabled.",
|
|
23
|
+
err=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return ForgejoClient(
|
|
27
|
+
config,
|
|
28
|
+
verify_tls=not insecure,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
@click.version_option()
|
|
34
|
+
def main():
|
|
35
|
+
"""Publish packages to Forgejo package registries."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@main.command("config")
|
|
39
|
+
@click.option(
|
|
40
|
+
"--url",
|
|
41
|
+
prompt="Forgejo URL",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--owner",
|
|
45
|
+
prompt="Forgejo owner",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--username",
|
|
49
|
+
prompt="Forgejo username",
|
|
50
|
+
)
|
|
51
|
+
def config_command(
|
|
52
|
+
url: str,
|
|
53
|
+
owner: str,
|
|
54
|
+
username: str,
|
|
55
|
+
):
|
|
56
|
+
"""Configure Forgejo credentials."""
|
|
57
|
+
try:
|
|
58
|
+
configure(
|
|
59
|
+
url=url,
|
|
60
|
+
owner=owner,
|
|
61
|
+
username=username,
|
|
62
|
+
)
|
|
63
|
+
except ForgePublishError as exc:
|
|
64
|
+
raise click.ClickException(str(exc)) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command("deb")
|
|
68
|
+
@click.argument(
|
|
69
|
+
"file",
|
|
70
|
+
type=click.Path(
|
|
71
|
+
exists=True,
|
|
72
|
+
dir_okay=False,
|
|
73
|
+
path_type=Path,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
@click.option(
|
|
77
|
+
"--distribution",
|
|
78
|
+
"-d",
|
|
79
|
+
required=True,
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--component",
|
|
83
|
+
"-c",
|
|
84
|
+
required=True,
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--insecure",
|
|
88
|
+
is_flag=True,
|
|
89
|
+
help="Disable TLS certificate verification.",
|
|
90
|
+
)
|
|
91
|
+
@click.option("--dry-run", is_flag=True)
|
|
92
|
+
def deb_command(
|
|
93
|
+
file: Path,
|
|
94
|
+
distribution: str,
|
|
95
|
+
component: str,
|
|
96
|
+
insecure: bool,
|
|
97
|
+
dry_run: bool,
|
|
98
|
+
):
|
|
99
|
+
"""Publish a Debian package."""
|
|
100
|
+
try:
|
|
101
|
+
client = _create_client(
|
|
102
|
+
dry_run=dry_run,
|
|
103
|
+
insecure=insecure,
|
|
104
|
+
)
|
|
105
|
+
deb.publish(
|
|
106
|
+
client=client,
|
|
107
|
+
file=file,
|
|
108
|
+
distribution=distribution,
|
|
109
|
+
component=component,
|
|
110
|
+
dry_run=dry_run,
|
|
111
|
+
)
|
|
112
|
+
except ForgePublishError as exc:
|
|
113
|
+
raise click.ClickException(str(exc)) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@main.command("generic")
|
|
117
|
+
@click.argument(
|
|
118
|
+
"file",
|
|
119
|
+
type=click.Path(
|
|
120
|
+
exists=True,
|
|
121
|
+
dir_okay=False,
|
|
122
|
+
path_type=Path,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
@click.option(
|
|
126
|
+
"--package",
|
|
127
|
+
"package_name",
|
|
128
|
+
required=True,
|
|
129
|
+
help="Generic package name.",
|
|
130
|
+
)
|
|
131
|
+
@click.option(
|
|
132
|
+
"--version",
|
|
133
|
+
required=True,
|
|
134
|
+
help="Package version.",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--filename",
|
|
138
|
+
help="Filename stored in Forgejo.",
|
|
139
|
+
)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--insecure",
|
|
142
|
+
is_flag=True,
|
|
143
|
+
help="Disable TLS certificate verification.",
|
|
144
|
+
)
|
|
145
|
+
@click.option("--dry-run", is_flag=True)
|
|
146
|
+
def generic_command(
|
|
147
|
+
file: Path,
|
|
148
|
+
package_name: str,
|
|
149
|
+
version: str,
|
|
150
|
+
filename: str | None,
|
|
151
|
+
insecure: bool,
|
|
152
|
+
dry_run: bool,
|
|
153
|
+
):
|
|
154
|
+
"""Publish a Generic package."""
|
|
155
|
+
try:
|
|
156
|
+
client = _create_client(
|
|
157
|
+
dry_run=dry_run,
|
|
158
|
+
insecure=insecure,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
generic.publish(
|
|
162
|
+
client=client,
|
|
163
|
+
file=file,
|
|
164
|
+
package_name=package_name,
|
|
165
|
+
version=version,
|
|
166
|
+
filename=filename,
|
|
167
|
+
dry_run=dry_run,
|
|
168
|
+
)
|
|
169
|
+
except ForgePublishError as exc:
|
|
170
|
+
raise click.ClickException(str(exc)) from exc
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command("npm")
|
|
174
|
+
@click.argument(
|
|
175
|
+
"directory",
|
|
176
|
+
type=click.Path(
|
|
177
|
+
exists=True,
|
|
178
|
+
file_okay=False,
|
|
179
|
+
path_type=Path,
|
|
180
|
+
),
|
|
181
|
+
default=".",
|
|
182
|
+
)
|
|
183
|
+
@click.option(
|
|
184
|
+
"--allow-pack-scripts",
|
|
185
|
+
is_flag=True,
|
|
186
|
+
help=(
|
|
187
|
+
"Execute npm pack lifecycle scripts (disabled by default). "
|
|
188
|
+
"Only use with trusted packages: background processes may access "
|
|
189
|
+
"later publication credentials."
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
@click.option("--dry-run", is_flag=True)
|
|
193
|
+
def npm_command(
|
|
194
|
+
directory: Path,
|
|
195
|
+
allow_pack_scripts: bool,
|
|
196
|
+
dry_run: bool,
|
|
197
|
+
):
|
|
198
|
+
"""Publish an NPM package (requires npm 11.0.0 or newer)."""
|
|
199
|
+
try:
|
|
200
|
+
client = _create_client(
|
|
201
|
+
dry_run=dry_run,
|
|
202
|
+
insecure=False,
|
|
203
|
+
)
|
|
204
|
+
npm.publish(
|
|
205
|
+
client=client,
|
|
206
|
+
directory=directory,
|
|
207
|
+
dry_run=dry_run,
|
|
208
|
+
allow_pack_scripts=allow_pack_scripts,
|
|
209
|
+
)
|
|
210
|
+
except ForgePublishError as exc:
|
|
211
|
+
raise click.ClickException(str(exc)) from exc
|
forge_publish/client.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .errors import ForgePublishError
|
|
10
|
+
|
|
11
|
+
REQUEST_TIMEOUT = (10, 300)
|
|
12
|
+
MAX_ERROR_MESSAGE_LENGTH = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ForgejoError(ForgePublishError):
|
|
16
|
+
"""Raised when communication with Forgejo fails."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ForgejoClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
config: Config,
|
|
23
|
+
*,
|
|
24
|
+
verify_tls: bool,
|
|
25
|
+
):
|
|
26
|
+
self.config = config
|
|
27
|
+
self.verify_tls = verify_tls
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
|
|
30
|
+
if config.token:
|
|
31
|
+
self.session.auth = (
|
|
32
|
+
config.username,
|
|
33
|
+
config.token,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def upload(
|
|
37
|
+
self,
|
|
38
|
+
url: str,
|
|
39
|
+
file: Path,
|
|
40
|
+
*,
|
|
41
|
+
dry_run: bool,
|
|
42
|
+
) -> None:
|
|
43
|
+
if dry_run:
|
|
44
|
+
print()
|
|
45
|
+
print("DRY RUN")
|
|
46
|
+
print("-------")
|
|
47
|
+
print(f"PUT : {url}")
|
|
48
|
+
print(f"FILE : {file}")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
self._require_authentication()
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
stream = file.open("rb")
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
raise ForgejoError(f"Unable to read file: {file}") from exc
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with stream:
|
|
60
|
+
response = self.session.put(
|
|
61
|
+
url,
|
|
62
|
+
data=stream,
|
|
63
|
+
timeout=REQUEST_TIMEOUT,
|
|
64
|
+
verify=self.verify_tls,
|
|
65
|
+
allow_redirects=False,
|
|
66
|
+
)
|
|
67
|
+
except requests.RequestException as exc:
|
|
68
|
+
raise ForgejoError(f"HTTP request failed: {exc}") from exc
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
raise ForgejoError(f"Unable to read file: {file}") from exc
|
|
71
|
+
|
|
72
|
+
if 200 <= response.status_code < 300:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
self._raise_error(response)
|
|
76
|
+
|
|
77
|
+
def delete(
|
|
78
|
+
self,
|
|
79
|
+
url: str,
|
|
80
|
+
*,
|
|
81
|
+
dry_run: bool,
|
|
82
|
+
ignore_404: bool,
|
|
83
|
+
) -> None:
|
|
84
|
+
if dry_run:
|
|
85
|
+
print()
|
|
86
|
+
print("DRY RUN")
|
|
87
|
+
print("-------")
|
|
88
|
+
print(f"DELETE : {url}")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
self._require_authentication()
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = self.session.delete(
|
|
95
|
+
url,
|
|
96
|
+
timeout=REQUEST_TIMEOUT,
|
|
97
|
+
verify=self.verify_tls,
|
|
98
|
+
allow_redirects=False,
|
|
99
|
+
)
|
|
100
|
+
except requests.RequestException as exc:
|
|
101
|
+
raise ForgejoError(f"HTTP request failed: {exc}") from exc
|
|
102
|
+
|
|
103
|
+
if response.status_code == 404 and ignore_404:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if not 200 <= response.status_code < 300:
|
|
107
|
+
self._raise_error(response)
|
|
108
|
+
|
|
109
|
+
def _require_authentication(self) -> None:
|
|
110
|
+
if not self.config.token:
|
|
111
|
+
raise ForgejoError("No Forgejo token configured.")
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _response_message(response: requests.Response) -> str:
|
|
115
|
+
message = ""
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
payload: Any = response.json()
|
|
119
|
+
except ValueError:
|
|
120
|
+
payload = None
|
|
121
|
+
|
|
122
|
+
if isinstance(payload, dict):
|
|
123
|
+
for key in ("message", "error", "detail"):
|
|
124
|
+
value = payload.get(key)
|
|
125
|
+
if isinstance(value, str) and value.strip():
|
|
126
|
+
message = value.strip()
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
if not message:
|
|
130
|
+
message = response.text.strip()
|
|
131
|
+
|
|
132
|
+
if len(message) > MAX_ERROR_MESSAGE_LENGTH:
|
|
133
|
+
return message[:MAX_ERROR_MESSAGE_LENGTH] + "..."
|
|
134
|
+
|
|
135
|
+
return message
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _raise_error(cls, response: requests.Response) -> None:
|
|
139
|
+
if 300 <= response.status_code < 400:
|
|
140
|
+
raise ForgejoError(
|
|
141
|
+
f"HTTP {response.status_code}: Forgejo redirect refused. "
|
|
142
|
+
"Configure the canonical Forgejo URL and check proxy authentication."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
reasons = {
|
|
146
|
+
400: "invalid package or request",
|
|
147
|
+
401: "authentication failed",
|
|
148
|
+
403: "permission denied",
|
|
149
|
+
404: "resource not found",
|
|
150
|
+
409: "package/file already exists",
|
|
151
|
+
413: "package/file is too large",
|
|
152
|
+
429: "too many requests",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if response.status_code >= 500:
|
|
156
|
+
reason = "Forgejo server error"
|
|
157
|
+
else:
|
|
158
|
+
reason = reasons.get(
|
|
159
|
+
response.status_code,
|
|
160
|
+
"Forgejo returned an error",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
message = cls._response_message(response)
|
|
164
|
+
if message:
|
|
165
|
+
reason += f": {message}"
|
|
166
|
+
|
|
167
|
+
raise ForgejoError(f"HTTP {response.status_code}: {reason}")
|
forge_publish/config.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from getpass import getpass
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import keyring
|
|
11
|
+
import tomli_w
|
|
12
|
+
from keyring.errors import KeyringError
|
|
13
|
+
from platformdirs import user_config_path
|
|
14
|
+
|
|
15
|
+
from .errors import ConfigurationError
|
|
16
|
+
|
|
17
|
+
APP_NAME = "forge-publish"
|
|
18
|
+
CONFIG_DIR = user_config_path(APP_NAME, appauthor=False)
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
20
|
+
KEYRING_SERVICE = "forge-publish"
|
|
21
|
+
TOKEN_ENV_VAR = "FORGE_PUBLISH_TOKEN"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
url: str
|
|
27
|
+
owner: str
|
|
28
|
+
username: str
|
|
29
|
+
token: str | None = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def credential_name(self) -> str:
|
|
33
|
+
return f"{self.url}|{self.owner}|{self.username}"
|
|
34
|
+
|
|
35
|
+
def save(self) -> None:
|
|
36
|
+
data = {
|
|
37
|
+
"url": self.url,
|
|
38
|
+
"owner": self.owner,
|
|
39
|
+
"username": self.username,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
with CONFIG_FILE.open("wb") as file:
|
|
46
|
+
file.write(tomli_w.dumps(data).encode("utf-8"))
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
f"Unable to write configuration file: {CONFIG_FILE}"
|
|
50
|
+
) from exc
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
54
|
+
except OSError:
|
|
55
|
+
# Permissions are best-effort and vary across platforms.
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_owner(owner: str) -> str:
|
|
60
|
+
normalized = owner.strip()
|
|
61
|
+
|
|
62
|
+
if not normalized:
|
|
63
|
+
raise ConfigurationError("Forgejo owner cannot be empty.")
|
|
64
|
+
|
|
65
|
+
if normalized in {".", ".."}:
|
|
66
|
+
raise ConfigurationError("Forgejo owner cannot be '.' or '..'.")
|
|
67
|
+
|
|
68
|
+
return normalized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_url(url: str) -> str:
|
|
72
|
+
normalized = url.strip().rstrip("/")
|
|
73
|
+
|
|
74
|
+
if "?" in normalized or "#" in normalized:
|
|
75
|
+
raise ConfigurationError(
|
|
76
|
+
"Forgejo URL must be a valid HTTPS URL without "
|
|
77
|
+
"credentials, query parameters, or fragments."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
parsed = urlsplit(normalized)
|
|
82
|
+
_port = parsed.port
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
raise ConfigurationError(
|
|
85
|
+
"Forgejo URL must be a valid HTTPS URL without "
|
|
86
|
+
"credentials, query parameters, or fragments."
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
parsed.scheme != "https"
|
|
91
|
+
or not parsed.netloc
|
|
92
|
+
or not parsed.hostname
|
|
93
|
+
or parsed.username is not None
|
|
94
|
+
or parsed.password is not None
|
|
95
|
+
or any(character.isspace() for character in normalized)
|
|
96
|
+
):
|
|
97
|
+
raise ConfigurationError(
|
|
98
|
+
"Forgejo URL must be a valid HTTPS URL without "
|
|
99
|
+
"credentials, query parameters, or fragments."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _read_config_data() -> dict[str, object]:
|
|
106
|
+
if not CONFIG_FILE.exists():
|
|
107
|
+
raise ConfigurationError(
|
|
108
|
+
f"Configuration file does not exist: {CONFIG_FILE}\n"
|
|
109
|
+
"Run: forge-publish config"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
with CONFIG_FILE.open("rb") as file:
|
|
114
|
+
data = tomllib.load(file)
|
|
115
|
+
except UnicodeDecodeError as exc:
|
|
116
|
+
raise ConfigurationError(f"Invalid UTF-8 configuration: {CONFIG_FILE}") from exc
|
|
117
|
+
except tomllib.TOMLDecodeError as exc:
|
|
118
|
+
raise ConfigurationError(f"Invalid TOML configuration: {CONFIG_FILE}") from exc
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
f"Unable to read configuration file: {CONFIG_FILE}"
|
|
122
|
+
) from exc
|
|
123
|
+
|
|
124
|
+
if not isinstance(data, dict):
|
|
125
|
+
raise ConfigurationError("Invalid configuration format.")
|
|
126
|
+
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_config(data: dict[str, object]) -> Config:
|
|
131
|
+
required = ("url", "owner", "username")
|
|
132
|
+
missing = [
|
|
133
|
+
field
|
|
134
|
+
for field in required
|
|
135
|
+
if not isinstance(data.get(field), str) or not str(data[field]).strip()
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
if missing:
|
|
139
|
+
raise ConfigurationError("Missing configuration fields: " + ", ".join(missing))
|
|
140
|
+
|
|
141
|
+
return Config(
|
|
142
|
+
url=_normalize_url(str(data["url"])),
|
|
143
|
+
owner=_normalize_owner(str(data["owner"])),
|
|
144
|
+
username=str(data["username"]).strip(),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_keyring_token(config: Config) -> str | None:
|
|
149
|
+
try:
|
|
150
|
+
return keyring.get_password(
|
|
151
|
+
KEYRING_SERVICE,
|
|
152
|
+
config.credential_name,
|
|
153
|
+
)
|
|
154
|
+
except KeyringError:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _set_keyring_token(config: Config, token: str) -> None:
|
|
159
|
+
try:
|
|
160
|
+
keyring.set_password(
|
|
161
|
+
KEYRING_SERVICE,
|
|
162
|
+
config.credential_name,
|
|
163
|
+
token,
|
|
164
|
+
)
|
|
165
|
+
except KeyringError as exc:
|
|
166
|
+
raise ConfigurationError(
|
|
167
|
+
"Unable to store the Forgejo token in the system keyring."
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _prompt_token() -> str:
|
|
172
|
+
try:
|
|
173
|
+
token = getpass("Forgejo token: ").strip()
|
|
174
|
+
except EOFError as exc:
|
|
175
|
+
raise ConfigurationError(
|
|
176
|
+
f"No Forgejo token available. Set {TOKEN_ENV_VAR} or run "
|
|
177
|
+
"forge-publish config in an interactive terminal."
|
|
178
|
+
) from exc
|
|
179
|
+
|
|
180
|
+
if not token:
|
|
181
|
+
raise ConfigurationError("Forgejo token cannot be empty.")
|
|
182
|
+
|
|
183
|
+
return token
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_config(*, require_token: bool) -> Config:
|
|
187
|
+
data = _read_config_data()
|
|
188
|
+
config = _build_config(data)
|
|
189
|
+
|
|
190
|
+
if not require_token:
|
|
191
|
+
return config
|
|
192
|
+
|
|
193
|
+
token = os.environ.get(TOKEN_ENV_VAR)
|
|
194
|
+
if not token:
|
|
195
|
+
token = _get_keyring_token(config)
|
|
196
|
+
if not token:
|
|
197
|
+
token = _prompt_token()
|
|
198
|
+
|
|
199
|
+
config.token = token
|
|
200
|
+
return config
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def configure(
|
|
204
|
+
url: str,
|
|
205
|
+
owner: str,
|
|
206
|
+
username: str,
|
|
207
|
+
) -> None:
|
|
208
|
+
config = Config(
|
|
209
|
+
url=_normalize_url(url),
|
|
210
|
+
owner=_normalize_owner(owner),
|
|
211
|
+
username=username.strip(),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if not config.username:
|
|
215
|
+
raise ConfigurationError("Forgejo username cannot be empty.")
|
|
216
|
+
|
|
217
|
+
if os.environ.get(TOKEN_ENV_VAR):
|
|
218
|
+
config.save()
|
|
219
|
+
|
|
220
|
+
print(f"Configuration saved to {CONFIG_FILE}")
|
|
221
|
+
print(f"Forgejo token provided through {TOKEN_ENV_VAR}.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
token = _prompt_token()
|
|
225
|
+
_set_keyring_token(config, token)
|
|
226
|
+
config.save()
|
|
227
|
+
|
|
228
|
+
print(f"Configuration saved to {CONFIG_FILE}")
|
|
229
|
+
print("Forgejo token stored in the system keyring.")
|
forge_publish/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class ForgePublishError(Exception):
|
|
2
|
+
"""Base exception for expected forge-publish errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(ForgePublishError):
|
|
6
|
+
"""Raised when local forge-publish configuration is invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PackageError(ForgePublishError):
|
|
10
|
+
"""Raised when a package cannot be validated or prepared."""
|
|
File without changes
|