gitacross 1.0.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,170 @@
1
+ """Gitea provider — REST API client for Gitea instances."""
2
+
3
+ import io
4
+ import json
5
+ import logging
6
+ import shutil
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ import uuid
11
+ from pathlib import Path
12
+
13
+ from .base import BaseAPIClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class _MultipartReader:
19
+ """File-like object that streams multipart/form-data without buffering the attachment.
20
+
21
+ Yields: boundary_header_bytes → file_content_chunks → boundary_footer_bytes.
22
+ The caller computes ``Content-Length = len(header) + file_size + len(footer)``
23
+ upfront so HTTP/1.1 chunked-encoding is not required.
24
+ """
25
+
26
+ def __init__(self, header: bytes, file_path, footer: bytes):
27
+ self._parts = [io.BytesIO(header), open(file_path, "rb"), io.BytesIO(footer)]
28
+ self._idx = 0
29
+
30
+ def read(self, size=-1):
31
+ if size == 0:
32
+ return b""
33
+ chunks = []
34
+ remaining = size
35
+ while self._idx < len(self._parts):
36
+ chunk = self._parts[self._idx].read(remaining if remaining > 0 else -1)
37
+ if chunk:
38
+ chunks.append(chunk)
39
+ if remaining > 0:
40
+ remaining -= len(chunk)
41
+ if remaining <= 0:
42
+ break
43
+ else:
44
+ self._idx += 1
45
+ return b"".join(chunks)
46
+
47
+ def close(self):
48
+ for part in self._parts:
49
+ try:
50
+ part.close()
51
+ except Exception:
52
+ pass
53
+
54
+ def __enter__(self):
55
+ return self
56
+
57
+ def __exit__(self, *args):
58
+ self.close()
59
+
60
+
61
+ class GiteaClient(BaseAPIClient):
62
+ """Gitea REST API client.
63
+
64
+ Differences from the base:
65
+ - Auth header uses ``token <tok>`` (not Bearer)
66
+ - Release conflict code is 409 (not 422)
67
+ - Asset upload uses multipart/form-data
68
+ - Asset download falls back to constructing a URL from asset IDs
69
+ """
70
+
71
+ _platform_name = "Gitea"
72
+ _conflict_codes = (409, 422)
73
+ _release_conflict_code = 409
74
+
75
+ def __init__(self, api: str, repo: str, token: str):
76
+ super().__init__(api, repo, token)
77
+ self._headers = {
78
+ "Authorization": f"token {token}",
79
+ "Accept": "application/json",
80
+ }
81
+
82
+ # ------------------------------------------------------------------
83
+ # Asset management
84
+ # ------------------------------------------------------------------
85
+
86
+ def download_asset(self, asset, dest):
87
+ """Download a Gitea release asset to *dest*."""
88
+ url = asset.get("browser_download_url") or ""
89
+ if not url and asset.get("id") and asset.get("release_id"):
90
+ url = (
91
+ f"{self.api}/repos/{self.repo}/releases"
92
+ f"/{asset['release_id']}/assets/{asset['id']}"
93
+ )
94
+ if not url:
95
+ raise ValueError(f"Asset has no download URL: {asset}")
96
+ # Gitea can return a relative URL for self-hosted instances
97
+ if url.startswith("/"):
98
+ host_base = self.api.split("/api")[0]
99
+ url = f"{host_base}{url}"
100
+ headers = dict(self._headers)
101
+ headers["Accept"] = "*/*"
102
+ req = urllib.request.Request(url, headers=headers)
103
+ with urllib.request.urlopen(req) as resp, open(dest, "wb") as f:
104
+ shutil.copyfileobj(resp, f)
105
+
106
+ def upload_asset(self, release_or_id, file_path, name=None, stream=False):
107
+ """Upload an asset to a Gitea release using multipart/form-data.
108
+
109
+ Idempotent — returns ``None`` if the asset already exists (409/422).
110
+
111
+ When *stream* is ``True`` the file content is never fully loaded into
112
+ RAM: a ``_MultipartReader`` wraps the boundary bytes + file handle and
113
+ is passed directly to ``urllib``. ``Content-Length`` is pre-computed
114
+ from ``len(header) + file_size + len(footer)`` so chunked encoding is
115
+ not needed.
116
+ """
117
+ name = name or Path(file_path).name
118
+ rel_id = (
119
+ release_or_id.get("id")
120
+ if isinstance(release_or_id, dict)
121
+ else release_or_id
122
+ )
123
+ url = (
124
+ f"{self.api}/repos/{self.repo}/releases/{rel_id}/assets"
125
+ f"?name={urllib.parse.quote(name)}"
126
+ )
127
+ boundary = f"----GitAcrossBoundary{uuid.uuid4().hex}"
128
+ header_bytes = (
129
+ f"--{boundary}\r\n"
130
+ f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
131
+ f"Content-Type: application/octet-stream\r\n\r\n"
132
+ ).encode("utf-8")
133
+ footer_bytes = f"\r\n--{boundary}--\r\n".encode("utf-8")
134
+
135
+ req_headers = {
136
+ "Authorization": self._headers["Authorization"],
137
+ "Accept": "application/json",
138
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
139
+ }
140
+
141
+ try:
142
+ if stream:
143
+ file_size = Path(file_path).stat().st_size
144
+ content_length = len(header_bytes) + file_size + len(footer_bytes)
145
+ req_headers["Content-Length"] = str(content_length)
146
+ with _MultipartReader(header_bytes, file_path, footer_bytes) as body:
147
+ req = urllib.request.Request(
148
+ url, data=body, headers=req_headers, method="POST"
149
+ )
150
+ with urllib.request.urlopen(req) as resp:
151
+ raw = resp.read()
152
+ return json.loads(raw) if raw else None
153
+ else:
154
+ with open(file_path, "rb") as f:
155
+ file_bytes = f.read()
156
+ body = header_bytes + file_bytes + footer_bytes
157
+ req_headers["Content-Length"] = str(len(body))
158
+ req = urllib.request.Request(
159
+ url, data=body, headers=req_headers, method="POST"
160
+ )
161
+ with urllib.request.urlopen(req) as resp:
162
+ raw = resp.read()
163
+ return json.loads(raw) if raw else None
164
+ except urllib.error.HTTPError as e:
165
+ if e.code in (409, 422):
166
+ logger.info("Asset %s already exists on Gitea (idempotent)", name)
167
+ return None
168
+ body_text = e.read().decode()
169
+ logger.error("Gitea upload asset error %s: %s", e.code, body_text)
170
+ raise
@@ -0,0 +1,120 @@
1
+ """GitHub provider — REST API client for github.com and GitHub Enterprise."""
2
+
3
+ import json
4
+ import logging
5
+ import shutil
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ from .base import BaseAPIClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GitHubClient(BaseAPIClient):
17
+ """GitHub REST API client.
18
+
19
+ Differences from the base:
20
+ - Auth header uses ``Bearer <tok>``
21
+ - ``Accept: application/vnd.github+json``
22
+ - Release pagination uses ``per_page`` (not ``limit``)
23
+ - Release conflict code is 422 (not 409)
24
+ - Asset upload uses raw ``application/octet-stream`` to the upload endpoint
25
+ - Asset download requires ``Accept: application/octet-stream``
26
+ """
27
+
28
+ _platform_name = "GitHub"
29
+ _conflict_codes = (409, 422)
30
+ _release_conflict_code = 422
31
+
32
+ def __init__(self, api: str, repo: str, token: str):
33
+ super().__init__(api, repo, token)
34
+ self._headers = {
35
+ "Authorization": f"Bearer {token}",
36
+ "Accept": "application/vnd.github+json",
37
+ }
38
+
39
+ def list_releases(self, page=1, limit=50):
40
+ """GitHub uses ``per_page`` instead of ``limit``."""
41
+ return self._request("GET", f"releases?page={page}&per_page={limit}")
42
+
43
+ # ------------------------------------------------------------------
44
+ # Asset management
45
+ # ------------------------------------------------------------------
46
+
47
+ def download_asset(self, asset, dest):
48
+ """Download a GitHub release asset to *dest*."""
49
+ url = asset.get("url") or asset.get("browser_download_url")
50
+ if not url:
51
+ raise ValueError(f"Asset has no download URL: {asset}")
52
+ headers = dict(self._headers)
53
+ headers["Accept"] = "application/octet-stream"
54
+ req = urllib.request.Request(url, headers=headers)
55
+ with urllib.request.urlopen(req) as resp, open(dest, "wb") as f:
56
+ shutil.copyfileobj(resp, f)
57
+
58
+ def upload_asset(self, release_or_id, file_path, name=None, stream=False):
59
+ """Upload an asset to a GitHub release as raw ``application/octet-stream``.
60
+
61
+ Idempotent — returns ``None`` if the asset already exists (422).
62
+
63
+ When *stream* is ``True`` the file object is passed directly to
64
+ ``urllib`` so the content is read in chunks rather than all at once.
65
+ ``Content-Length`` is set from ``file.stat().st_size`` so no
66
+ chunked-encoding handshake is needed.
67
+ """
68
+ name = name or Path(file_path).name
69
+ upload_url = None
70
+ if isinstance(release_or_id, dict):
71
+ upload_url = release_or_id.get("upload_url")
72
+ rel_id = release_or_id.get("id")
73
+ else:
74
+ rel_id = release_or_id
75
+
76
+ # Resolve the upload base URL (prefer the API-returned upload_url).
77
+ if upload_url:
78
+ base_url = upload_url.split("{")[0]
79
+ elif self.api == "https://api.github.com":
80
+ base_url = (
81
+ f"https://uploads.github.com/repos/{self.repo}/releases/{rel_id}/assets"
82
+ )
83
+ else:
84
+ base_url = f"{self.api}/repos/{self.repo}/releases/{rel_id}/assets"
85
+
86
+ url = f"{base_url}?name={urllib.parse.quote(name)}"
87
+ req_headers = {
88
+ "Authorization": self._headers["Authorization"],
89
+ "Accept": "application/vnd.github+json",
90
+ "Content-Type": "application/octet-stream",
91
+ }
92
+
93
+ try:
94
+ if stream:
95
+ file_size = Path(file_path).stat().st_size
96
+ req_headers["Content-Length"] = str(file_size)
97
+ with open(file_path, "rb") as f:
98
+ req = urllib.request.Request(
99
+ url, data=f, headers=req_headers, method="POST"
100
+ )
101
+ with urllib.request.urlopen(req) as resp:
102
+ raw = resp.read()
103
+ return json.loads(raw) if raw else None
104
+ else:
105
+ with open(file_path, "rb") as f:
106
+ data = f.read()
107
+ req_headers["Content-Length"] = str(len(data))
108
+ req = urllib.request.Request(
109
+ url, data=data, headers=req_headers, method="POST"
110
+ )
111
+ with urllib.request.urlopen(req) as resp:
112
+ raw = resp.read()
113
+ return json.loads(raw) if raw else None
114
+ except urllib.error.HTTPError as e:
115
+ if e.code == 422:
116
+ logger.info("Asset %s already exists on GitHub (idempotent)", name)
117
+ return None
118
+ body_text = e.read().decode()
119
+ logger.error("GitHub upload asset error %s: %s", e.code, body_text)
120
+ raise
gitacross/renderer.py ADDED
@@ -0,0 +1,195 @@
1
+ import fnmatch
2
+ import logging
3
+ import re
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def apply_operations(work_dir, project):
11
+ """Run the full render pipeline on *work_dir* after source tag overlay.
12
+
13
+ 1. Remove paths in `ignore` (glob list, always first).
14
+ 2. Apply `operations` in declaration order.
15
+ """
16
+ work = Path(work_dir)
17
+
18
+ for pattern in project.renderer.ignore:
19
+ _remove_glob(work, pattern)
20
+
21
+ for op in project.renderer.operations:
22
+ if "remove" in op:
23
+ _op_remove(work, op["remove"])
24
+ elif "rename" in op:
25
+ _op_rename(work, op["rename"])
26
+ elif "replace" in op:
27
+ _op_replace(work, op["replace"])
28
+ elif "add" in op:
29
+ _op_add(work, op["add"])
30
+ elif "validate" in op:
31
+ _op_validate(work, op["validate"])
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Internal helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _remove_glob(root, pattern):
40
+ """Remove all files/dirs matching a glob pattern relative to *root*."""
41
+ paths = sorted(root.rglob(pattern), key=lambda p: len(str(p)), reverse=True)
42
+ for path in paths:
43
+ if not path.exists():
44
+ continue
45
+ if path.is_dir() and not path.is_symlink():
46
+ shutil.rmtree(path)
47
+ else:
48
+ path.unlink()
49
+ _clean_empty_dirs(root)
50
+
51
+
52
+ def _clean_empty_dirs(root):
53
+ for path in sorted(root.rglob("*"), key=lambda p: len(str(p)), reverse=True):
54
+ if path.is_dir() and not any(path.iterdir()):
55
+ path.rmdir()
56
+
57
+
58
+ def _iter_files(root):
59
+ for path in root.rglob("*"):
60
+ if path.is_file():
61
+ yield path
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Operation handlers
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def _op_remove(work, ops):
70
+ """Remove matching files/dirs.
71
+ Each op: {path: str, pattern?: literal|glob|regex}
72
+ """
73
+ for op in ops:
74
+ pat = op.get("path", "")
75
+ mode = op.get("pattern", "literal")
76
+ if mode == "literal":
77
+ target = work / pat
78
+ if target.exists():
79
+ if target.is_dir():
80
+ shutil.rmtree(target)
81
+ else:
82
+ target.unlink()
83
+ elif mode == "glob":
84
+ _remove_glob(work, pat)
85
+ elif mode == "regex":
86
+ for f in _iter_files(work):
87
+ if re.search(pat, str(f.relative_to(work))):
88
+ f.unlink()
89
+ _clean_empty_dirs(work)
90
+
91
+
92
+ def _op_rename(work, ops):
93
+ """Rename files/dirs. Processes deepest paths first to avoid parent conflicts.
94
+
95
+ Each op: {from: str, to: str, pattern?: literal|glob|regex}
96
+ """
97
+ # ponytail: only literal renames are implemented. Glob/regex rename raises.
98
+ for op in sorted(ops, key=lambda o: -len(o.get("from", ""))):
99
+ mode = op.get("pattern", "literal")
100
+ if mode != "literal":
101
+ raise NotImplementedError(
102
+ f"Rename pattern '{mode}' is not yet implemented — use 'literal'"
103
+ )
104
+
105
+ src = work / op["from"]
106
+ dst = work / op["to"]
107
+ if src.exists():
108
+ if dst.exists():
109
+ raise RuntimeError(f"Rename conflict: {op['to']} already exists")
110
+ src.rename(dst)
111
+
112
+
113
+ def _op_replace(work, ops):
114
+ """Search-and-replace in UTF-8 text files.
115
+
116
+ Each op: {search: str, replace: str, pattern?: literal|regex, glob?: str, path?: str}
117
+ """
118
+ for op in ops:
119
+ search = op["search"]
120
+ replace = op["replace"]
121
+ mode = op.get("pattern", "literal")
122
+ path_filter = op.get("path")
123
+ glob_filter = op.get("glob")
124
+
125
+ for f in _iter_files(work):
126
+ rel = f.relative_to(work)
127
+ if path_filter:
128
+ if str(rel) != path_filter:
129
+ continue
130
+ elif glob_filter:
131
+ if not fnmatch.fnmatch(str(rel), glob_filter):
132
+ continue
133
+
134
+ try:
135
+ text = f.read_text("utf-8")
136
+ except (UnicodeDecodeError, ValueError):
137
+ continue
138
+
139
+ new_text = (
140
+ re.sub(search, replace, text)
141
+ if mode == "regex"
142
+ else text.replace(search, replace)
143
+ )
144
+ if new_text != text:
145
+ f.write_text(new_text, encoding="utf-8")
146
+
147
+
148
+ def _op_add(work, ops):
149
+ """Create files in the work tree before commit.
150
+
151
+ Each op: {path: str, content: str}
152
+ Raises if the path already exists (avoids silently overwriting).
153
+ Creates parent directories automatically.
154
+ """
155
+ for op in ops:
156
+ path = op["path"]
157
+ target = work / path
158
+ if target.exists():
159
+ raise RuntimeError(f"Add conflict: '{path}' already exists")
160
+ target.parent.mkdir(parents=True, exist_ok=True)
161
+ target.write_text(op.get("content", ""), encoding="utf-8")
162
+ logger.debug("Added file: %s", path)
163
+
164
+
165
+ def _op_validate(work, ops):
166
+ """Assert conditions on the final tree. Aborts on failure."""
167
+ for op in ops:
168
+ assert_type = op.get("assert", "")
169
+ path = op.get("path", "")
170
+ pattern = op.get("pattern", "")
171
+ target = work / path
172
+
173
+ if assert_type == "file_exists":
174
+ if not target.exists():
175
+ raise RuntimeError(f"Validation failed: '{path}' must exist")
176
+ elif assert_type == "file_absent":
177
+ if target.exists():
178
+ raise RuntimeError(f"Validation failed: '{path}' must not exist")
179
+ elif assert_type == "string_exists":
180
+ if not target.exists():
181
+ raise RuntimeError(
182
+ f"Validation failed: '{path}' not found (for string_exists check)"
183
+ )
184
+ content = target.read_text("utf-8", errors="replace")
185
+ if pattern not in content:
186
+ raise RuntimeError(
187
+ f"Validation failed: '{pattern}' not found in '{path}'"
188
+ )
189
+ elif assert_type == "string_absent":
190
+ if target.exists():
191
+ content = target.read_text("utf-8", errors="replace")
192
+ if pattern in content:
193
+ raise RuntimeError(
194
+ f"Validation failed: '{pattern}' found in '{path}'"
195
+ )
gitacross/retry.py ADDED
@@ -0,0 +1,23 @@
1
+ import logging
2
+ import time
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ def retry(fn, max_attempts=3, backoff_seconds=2, exceptions=(Exception,)):
8
+ """Run fn with exponential backoff. Re-raises the last exception on final failure."""
9
+ for attempt in range(max_attempts):
10
+ try:
11
+ return fn()
12
+ except exceptions as e:
13
+ if attempt == max_attempts - 1:
14
+ raise
15
+ delay = backoff_seconds * (2**attempt)
16
+ logger.warning(
17
+ "Attempt %d/%d failed: %s. Retrying in %.1fs...",
18
+ attempt + 1,
19
+ max_attempts,
20
+ e,
21
+ delay,
22
+ )
23
+ time.sleep(delay)