git-alternative 0.2.2__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,63 @@
1
+ """
2
+ git-alternative: Python SDK for the Forge Git collaboration platform.
3
+
4
+ Forge is a keyboard-driven, self-hosted Git forge with full REST API support.
5
+ This package provides a clean Python interface for all Forge operations.
6
+ """
7
+
8
+ from git_alternative.client import ForgeClient
9
+ from git_alternative.exceptions import (
10
+ ForgeAuthError,
11
+ ForgeConflictError,
12
+ ForgeError,
13
+ ForgeNotFoundError,
14
+ ForgeValidationError,
15
+ )
16
+ from git_alternative.models import (
17
+ Branch,
18
+ CIConfig,
19
+ Comment,
20
+ CommitSummary,
21
+ CreateRepoRequest,
22
+ Issue,
23
+ IssueState,
24
+ Label,
25
+ MergeStrategy,
26
+ PaginatedResponse,
27
+ Pipeline,
28
+ PipelineState,
29
+ PipelineTrigger,
30
+ PrState,
31
+ PullRequest,
32
+ Repository,
33
+ SshKey,
34
+ User,
35
+ )
36
+
37
+ __version__ = "0.2.2"
38
+ __all__ = [
39
+ "ForgeClient",
40
+ "ForgeError",
41
+ "ForgeAuthError",
42
+ "ForgeNotFoundError",
43
+ "ForgeConflictError",
44
+ "ForgeValidationError",
45
+ "Repository",
46
+ "CreateRepoRequest",
47
+ "Issue",
48
+ "IssueState",
49
+ "PullRequest",
50
+ "PrState",
51
+ "MergeStrategy",
52
+ "Pipeline",
53
+ "PipelineState",
54
+ "PipelineTrigger",
55
+ "Branch",
56
+ "CommitSummary",
57
+ "PaginatedResponse",
58
+ "Comment",
59
+ "Label",
60
+ "SshKey",
61
+ "User",
62
+ "CIConfig",
63
+ ]
git_alternative/ci.py ADDED
@@ -0,0 +1,162 @@
1
+ """CI pipeline configuration parser for .forge/ci.yml files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from git_alternative.exceptions import ForgeValidationError
10
+ from git_alternative.models import CIConfig, CIJobDefinition, CIJobStep, CITrigger
11
+
12
+
13
+ def parse_ci_config(yaml_str: str) -> CIConfig:
14
+ """Parse a ``.forge/ci.yml`` pipeline configuration string.
15
+
16
+ Args:
17
+ yaml_str: YAML content of the CI configuration file.
18
+
19
+ Returns:
20
+ A :class:`~git_alternative.models.CIConfig` instance representing the
21
+ parsed pipeline definition.
22
+
23
+ Raises:
24
+ ForgeValidationError: If the YAML is invalid or required fields are
25
+ missing.
26
+
27
+ Example:
28
+ >>> config = parse_ci_config('''
29
+ ... name: Build and Test
30
+ ... on:
31
+ ... push:
32
+ ... branches: [main]
33
+ ... jobs:
34
+ ... test:
35
+ ... name: Run Tests
36
+ ... runs-on: ubuntu-22.04
37
+ ... steps:
38
+ ... - name: Run tests
39
+ ... run: cargo test
40
+ ... ''')
41
+ >>> config.name
42
+ 'Build and Test'
43
+ >>> "test" in config.jobs
44
+ True
45
+ """
46
+ try:
47
+ data = yaml.safe_load(yaml_str)
48
+ except yaml.YAMLError as exc:
49
+ raise ForgeValidationError(f"Invalid YAML in CI config: {exc}") from exc
50
+
51
+ if not isinstance(data, dict):
52
+ raise ForgeValidationError("CI config must be a YAML mapping")
53
+
54
+ name = data.get("name")
55
+ if not name:
56
+ raise ForgeValidationError("CI config missing required field: 'name'")
57
+
58
+ trigger = _parse_trigger(data.get("on", {}))
59
+ jobs = _parse_jobs(data.get("jobs", {}))
60
+
61
+ return CIConfig(name=str(name), trigger=trigger, jobs=jobs)
62
+
63
+
64
+ def _parse_trigger(on_data: Any) -> CITrigger:
65
+ """Parse the ``on:`` trigger section of a CI config.
66
+
67
+ Args:
68
+ on_data: The value of the ``on`` key in the YAML document.
69
+
70
+ Returns:
71
+ A :class:`~git_alternative.models.CITrigger` instance.
72
+ """
73
+ push_branches: list[str] = []
74
+ pr_branches: list[str] = []
75
+
76
+ if isinstance(on_data, dict):
77
+ push = on_data.get("push", {})
78
+ if isinstance(push, dict):
79
+ push_branches = push.get("branches", [])
80
+
81
+ pr = on_data.get("pull_request", {})
82
+ if isinstance(pr, dict):
83
+ pr_branches = pr.get("branches", [])
84
+
85
+ return CITrigger(push_branches=push_branches, pr_branches=pr_branches)
86
+
87
+
88
+ def _parse_jobs(jobs_data: Any) -> dict[str, CIJobDefinition]:
89
+ """Parse the ``jobs:`` section of a CI config.
90
+
91
+ Args:
92
+ jobs_data: The value of the ``jobs`` key in the YAML document.
93
+
94
+ Returns:
95
+ A mapping of job ID to :class:`~git_alternative.models.CIJobDefinition`.
96
+
97
+ Raises:
98
+ ForgeValidationError: If a job definition is missing required fields.
99
+ """
100
+ if not isinstance(jobs_data, dict):
101
+ return {}
102
+
103
+ result: dict[str, CIJobDefinition] = {}
104
+ for job_id, job_data in jobs_data.items():
105
+ if not isinstance(job_data, dict):
106
+ raise ForgeValidationError(f"Job '{job_id}' must be a mapping")
107
+
108
+ runs_on = job_data.get("runs-on")
109
+ if not runs_on:
110
+ raise ForgeValidationError(f"Job '{job_id}' missing required field: 'runs-on'")
111
+
112
+ job_name = job_data.get("name", job_id)
113
+ needs = job_data.get("needs", [])
114
+ if isinstance(needs, str):
115
+ needs = [needs]
116
+
117
+ steps = _parse_steps(job_id, job_data.get("steps", []))
118
+
119
+ result[job_id] = CIJobDefinition(
120
+ name=str(job_name),
121
+ runs_on=str(runs_on),
122
+ steps=steps,
123
+ needs=list(needs),
124
+ )
125
+
126
+ return result
127
+
128
+
129
+ def _parse_steps(job_id: str, steps_data: Any) -> list[CIJobStep]:
130
+ """Parse the ``steps:`` list within a job definition.
131
+
132
+ Args:
133
+ job_id: The job identifier (used in error messages).
134
+ steps_data: The value of the ``steps`` key for this job.
135
+
136
+ Returns:
137
+ A list of :class:`~git_alternative.models.CIJobStep` instances.
138
+
139
+ Raises:
140
+ ForgeValidationError: If a step is missing a ``name`` field.
141
+ """
142
+ if not isinstance(steps_data, list):
143
+ return []
144
+
145
+ steps: list[CIJobStep] = []
146
+ for i, step in enumerate(steps_data):
147
+ if not isinstance(step, dict):
148
+ raise ForgeValidationError(
149
+ f"Job '{job_id}' step {i} must be a mapping"
150
+ )
151
+ step_name = step.get("name", f"step-{i}")
152
+ with_params = step.get("with", {}) or {}
153
+ steps.append(
154
+ CIJobStep(
155
+ name=str(step_name),
156
+ run=step.get("run"),
157
+ uses=step.get("uses"),
158
+ with_params=dict(with_params),
159
+ )
160
+ )
161
+
162
+ return steps
git_alternative/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ """Minimal CLI entry point for the git-alternative package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+
10
+ def _build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ prog="forge",
13
+ description="forge — keyboard-driven Git collaboration platform CLI",
14
+ )
15
+ parser.add_argument("--version", action="version", version="git-alternative 0.2.1")
16
+ parser.add_argument("--host", default=None, help="Forge instance URL")
17
+ parser.add_argument("--token", default=None, help="Personal access token")
18
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
19
+
20
+ sub = parser.add_subparsers(dest="command")
21
+
22
+ # fingerprint sub-command
23
+ fp_parser = sub.add_parser("fingerprint", help="Compute SSH key fingerprint")
24
+ fp_parser.add_argument("key", help="SSH public key string or path to key file")
25
+
26
+ # ci validate sub-command
27
+ ci_parser = sub.add_parser("ci", help="CI configuration utilities")
28
+ ci_sub = ci_parser.add_subparsers(dest="ci_command")
29
+ validate_parser = ci_sub.add_parser("validate", help="Validate a .forge/ci.yml file")
30
+ validate_parser.add_argument("file", help="Path to ci.yml file")
31
+
32
+ return parser
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ """Entry point for the ``forge`` CLI.
37
+
38
+ Args:
39
+ argv: Argument list (defaults to ``sys.argv[1:]``).
40
+
41
+ Returns:
42
+ Exit code (0 = success, non-zero = error).
43
+ """
44
+ parser = _build_parser()
45
+ args = parser.parse_args(argv)
46
+
47
+ if args.command == "fingerprint":
48
+ return _cmd_fingerprint(args)
49
+ if args.command == "ci":
50
+ return _cmd_ci(args)
51
+
52
+ parser.print_help()
53
+ return 0
54
+
55
+
56
+ def _cmd_fingerprint(args: argparse.Namespace) -> int:
57
+ """Handle the ``forge fingerprint`` sub-command."""
58
+ from git_alternative.utils import compute_key_fingerprint
59
+ from git_alternative.exceptions import ForgeValidationError
60
+
61
+ key_input = args.key
62
+ # If it looks like a file path, try to read it
63
+ if not key_input.startswith("ssh-") and not key_input.startswith("ecdsa-"):
64
+ try:
65
+ with open(key_input) as fh:
66
+ key_input = fh.read().strip()
67
+ except OSError:
68
+ pass # treat as literal key string
69
+
70
+ try:
71
+ fp = compute_key_fingerprint(key_input)
72
+ except ForgeValidationError as exc:
73
+ print(f"Error: {exc}", file=sys.stderr)
74
+ return 1
75
+
76
+ if args.json:
77
+ print(json.dumps({"fingerprint": fp}))
78
+ else:
79
+ print(fp)
80
+ return 0
81
+
82
+
83
+ def _cmd_ci(args: argparse.Namespace) -> int:
84
+ """Handle the ``forge ci`` sub-commands."""
85
+ if args.ci_command == "validate":
86
+ return _cmd_ci_validate(args)
87
+ # No sub-command given
88
+ print("Usage: forge ci <validate>", file=sys.stderr)
89
+ return 1
90
+
91
+
92
+ def _cmd_ci_validate(args: argparse.Namespace) -> int:
93
+ """Handle the ``forge ci validate`` sub-command."""
94
+ from git_alternative.ci import CiConfig
95
+ from git_alternative.exceptions import ForgeValidationError
96
+
97
+ try:
98
+ with open(args.file) as fh:
99
+ content = fh.read()
100
+ except OSError as exc:
101
+ print(f"Error reading file: {exc}", file=sys.stderr)
102
+ return 1
103
+
104
+ try:
105
+ config = CiConfig.from_yaml(content)
106
+ except ForgeValidationError as exc:
107
+ print(f"Invalid CI config: {exc}", file=sys.stderr)
108
+ return 1
109
+
110
+ errors = config.validate()
111
+ if errors:
112
+ for err in errors:
113
+ print(f" - {err}", file=sys.stderr)
114
+ return 1
115
+
116
+ print(f"✓ CI config '{config.name}' is valid ({len(config.jobs)} job(s))")
117
+ return 0
@@ -0,0 +1,72 @@
1
+ """Main ForgeClient entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from git_alternative.http import HttpClient
6
+ from git_alternative.resources.issues import IssuesResource
7
+ from git_alternative.resources.pipelines import PipelinesResource
8
+ from git_alternative.resources.pulls import PullsResource
9
+ from git_alternative.resources.repos import ReposResource
10
+ from git_alternative.resources.users import UsersResource
11
+ from git_alternative.resources.webhooks import WebhooksResource
12
+
13
+
14
+ class ForgeClient:
15
+ """Python SDK client for the Forge Git collaboration platform.
16
+
17
+ Provides access to all Forge API resources through sub-clients.
18
+
19
+ Args:
20
+ host: Base URL of the Forge instance (e.g., ``https://forge.example.com``).
21
+ token: Personal Access Token for authentication. If omitted, only
22
+ public endpoints are accessible.
23
+ timeout: HTTP request timeout in seconds (default: 30).
24
+
25
+ Example:
26
+ >>> client = ForgeClient(
27
+ ... host="https://forge.example.com",
28
+ ... token="pat_your_token",
29
+ ... )
30
+ >>> repos = client.repos.list(owner="alice")
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ host: str,
36
+ token: str | None = None,
37
+ timeout: int = 30,
38
+ ) -> None:
39
+ self._http = HttpClient(host=host, token=token, timeout=timeout)
40
+ self.repos = ReposResource(self._http)
41
+ self.issues = IssuesResource(self._http)
42
+ self.pulls = PullsResource(self._http)
43
+ self.pipelines = PipelinesResource(self._http)
44
+ self.users = UsersResource(self._http)
45
+ self.webhooks = WebhooksResource(self._http)
46
+
47
+ @property
48
+ def host(self) -> str:
49
+ """The base URL of the connected Forge instance."""
50
+ return self._http.host
51
+
52
+ def authenticate(self, username: str, password: str) -> str:
53
+ """Authenticate with username and password, returning a session token.
54
+
55
+ Args:
56
+ username: Forge username.
57
+ password: Account password.
58
+
59
+ Returns:
60
+ A session token string.
61
+
62
+ Raises:
63
+ ForgeAuthError: If credentials are invalid.
64
+ """
65
+ data = self._http.post(
66
+ "/api/v1/auth/login",
67
+ json={"username": username, "password": password},
68
+ )
69
+ token = data.get("token", "")
70
+ self._http.token = token
71
+ self._http._session.headers["Authorization"] = f"Bearer {token}"
72
+ return token
@@ -0,0 +1,43 @@
1
+ """Custom exceptions for the git-alternative SDK."""
2
+
3
+
4
+ class ForgeError(Exception):
5
+ """Base exception for all Forge API errors."""
6
+
7
+ def __init__(self, message: str, status_code: int | None = None) -> None:
8
+ super().__init__(message)
9
+ self.message = message
10
+ self.status_code = status_code
11
+
12
+ def __str__(self) -> str:
13
+ if self.status_code:
14
+ return f"[HTTP {self.status_code}] {self.message}"
15
+ return self.message
16
+
17
+
18
+ class ForgeAuthError(ForgeError):
19
+ """Raised when authentication fails (401 Unauthorized)."""
20
+
21
+
22
+ class ForgeNotFoundError(ForgeError):
23
+ """Raised when a resource is not found (404 Not Found)."""
24
+
25
+
26
+ class ForgeConflictError(ForgeError):
27
+ """Raised when a resource conflict occurs (409 Conflict)."""
28
+
29
+
30
+ class ForgeValidationError(ForgeError):
31
+ """Raised when request validation fails (422 Unprocessable Entity)."""
32
+
33
+
34
+ class ForgeRateLimitError(ForgeError):
35
+ """Raised when the API rate limit is exceeded (429 Too Many Requests)."""
36
+
37
+
38
+ class ForgeServerError(ForgeError):
39
+ """Raised when the server returns a 5xx error."""
40
+
41
+
42
+ class ForgeInvalidSshKeyError(ForgeError):
43
+ """Raised when an SSH public key is malformed or invalid."""
@@ -0,0 +1,137 @@
1
+ """Low-level HTTP client for the Forge REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from git_alternative.exceptions import (
10
+ ForgeAuthError,
11
+ ForgeConflictError,
12
+ ForgeError,
13
+ ForgeNotFoundError,
14
+ ForgeRateLimitError,
15
+ ForgeServerError,
16
+ ForgeValidationError,
17
+ )
18
+
19
+ _DEFAULT_TIMEOUT = 30
20
+
21
+
22
+ class HttpClient:
23
+ """Thin wrapper around :mod:`requests` for Forge API calls.
24
+
25
+ Args:
26
+ host: Base URL of the Forge instance (e.g. ``"https://forge.example.com"``).
27
+ token: Personal Access Token for Bearer authentication.
28
+ timeout: Request timeout in seconds.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ host: str,
34
+ token: str | None = None,
35
+ timeout: int = _DEFAULT_TIMEOUT,
36
+ ) -> None:
37
+ self.host = host.rstrip("/")
38
+ self.token = token
39
+ self.timeout = timeout
40
+ self._session = requests.Session()
41
+ if token:
42
+ self._session.headers["Authorization"] = f"Bearer {token}"
43
+ self._session.headers["Content-Type"] = "application/json"
44
+ self._session.headers["Accept"] = "application/json"
45
+
46
+ def _url(self, path: str) -> str:
47
+ return f"{self.host}{path}"
48
+
49
+ def _raise_for_status(self, response: requests.Response) -> None:
50
+ """Map HTTP error codes to typed Forge exceptions.
51
+
52
+ Args:
53
+ response: The :class:`requests.Response` to inspect.
54
+
55
+ Raises:
56
+ ForgeAuthError: On 401.
57
+ ForgeNotFoundError: On 404.
58
+ ForgeConflictError: On 409.
59
+ ForgeValidationError: On 422.
60
+ ForgeRateLimitError: On 429.
61
+ ForgeServerError: On 5xx.
62
+ ForgeError: On any other 4xx.
63
+ """
64
+ if response.ok:
65
+ return
66
+
67
+ try:
68
+ body = response.json()
69
+ message = body.get("message") or body.get("error") or response.text
70
+ except Exception:
71
+ message = response.text or response.reason
72
+
73
+ code = response.status_code
74
+ if code == 401:
75
+ raise ForgeAuthError(message, status_code=code)
76
+ if code == 404:
77
+ raise ForgeNotFoundError(message, status_code=code)
78
+ if code == 409:
79
+ raise ForgeConflictError(message, status_code=code)
80
+ if code == 422:
81
+ raise ForgeValidationError(message, status_code=code)
82
+ if code == 429:
83
+ raise ForgeRateLimitError(message, status_code=code)
84
+ if code >= 500:
85
+ raise ForgeServerError(message, status_code=code)
86
+ raise ForgeError(message, status_code=code)
87
+
88
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
89
+ """Perform a GET request.
90
+
91
+ Args:
92
+ path: API path (e.g. ``"/api/v1/repos/alice/my-repo"``).
93
+ params: Optional query parameters.
94
+
95
+ Returns:
96
+ Parsed JSON response body.
97
+ """
98
+ resp = self._session.get(self._url(path), params=params, timeout=self.timeout)
99
+ self._raise_for_status(resp)
100
+ return resp.json()
101
+
102
+ def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
103
+ """Perform a POST request.
104
+
105
+ Args:
106
+ path: API path.
107
+ json: Request body as a dictionary.
108
+
109
+ Returns:
110
+ Parsed JSON response body.
111
+ """
112
+ resp = self._session.post(self._url(path), json=json, timeout=self.timeout)
113
+ self._raise_for_status(resp)
114
+ return resp.json()
115
+
116
+ def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
117
+ """Perform a PATCH request.
118
+
119
+ Args:
120
+ path: API path.
121
+ json: Request body as a dictionary.
122
+
123
+ Returns:
124
+ Parsed JSON response body.
125
+ """
126
+ resp = self._session.patch(self._url(path), json=json, timeout=self.timeout)
127
+ self._raise_for_status(resp)
128
+ return resp.json()
129
+
130
+ def delete(self, path: str) -> None:
131
+ """Perform a DELETE request.
132
+
133
+ Args:
134
+ path: API path.
135
+ """
136
+ resp = self._session.delete(self._url(path), timeout=self.timeout)
137
+ self._raise_for_status(resp)
@@ -0,0 +1 @@
1
+ """Resource manager modules for the Forge SDK."""