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.
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("forge-publish")
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
@@ -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}")
@@ -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.")
@@ -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