github-forker 1.0.1__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,384 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-forker
3
+ Version: 1.0.1
4
+ Summary: Production-ready GitHub repository forking built on PyGithub — retry, backoff, readiness polling, thread pool, background jobs, upstream remotes, and webhooks.
5
+ Project-URL: Homepage, https://github.com/cumulus13/pygithub-fork
6
+ Project-URL: Repository, https://github.com/cumulus13/pygithub-fork
7
+ Project-URL: Bug Tracker, https://github.com/cumulus13/pygithub-fork/issues
8
+ Project-URL: Changelog, https://github.com/cumulus13/pygithub-fork/blob/main/CHANGELOG.md
9
+ Author-email: Hadi Cahyadi <cumulus13@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,automation,devops,fork,git,github,pygithub,repository
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Software Development :: Version Control :: Git
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: pygithub>=1.55
26
+ Provides-Extra: dev
27
+ Requires-Dist: build; extra == 'dev'
28
+ Requires-Dist: mypy; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0; extra == 'dev'
31
+ Requires-Dist: ruff; extra == 'dev'
32
+ Requires-Dist: twine; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # github-forker
36
+
37
+ [![PyPI version](https://badge.fury.io/py/pygithub-fork.svg)](https://pypi.org/project/pygithub-fork/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/pygithub-fork)](https://pypi.org/project/pygithub-fork/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ Production-ready GitHub repository forking built on [PyGithub](https://github.com/PyGithub/PyGithub).
42
+
43
+ A bare `repo.create_fork()` call returns immediately but the fork is not actually usable yet — GitHub builds the copy asynchronously in the background. `github-forker` handles everything you need for real-world use:
44
+
45
+ - **Idempotency** — detects pre-existing forks so re-runs never crash
46
+ - **Retry + exponential backoff with jitter** — survives 5xx, timeouts, rate limits, and GitHub's secondary ("abuse") rate limit
47
+ - **Fork-readiness polling** — waits until the fork is actually populated before returning
48
+ - **Thread pool** — `fork_many()` runs up to N forks concurrently
49
+ - **Background / fire-and-forget** — `fork_async()` returns a `ForkJob` you can query or wait on from any thread
50
+ - **Streaming generator** — `fork_iter()` yields results as each fork completes
51
+ - **Post-fork upstream remote** — runs `git remote add upstream <url>` in your local clone
52
+ - **Post-fork webhook** — registers GitHub push/fork (or any) events on the new fork
53
+
54
+ ---
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install github-forker
60
+ ```
61
+
62
+ Requires Python ≥ 3.9 and PyGithub ≥ 1.55.
63
+
64
+ ---
65
+
66
+ ## Quick start
67
+
68
+ ```python
69
+ from github import Github
70
+ from pygithub_fork import GitHubForker
71
+
72
+ gh = Github("ghp_your_token")
73
+ forker = GitHubForker(gh)
74
+
75
+ result = forker.fork("octocat/Hello-World")
76
+ print(result.status) # ForkStatus.READY
77
+ print(result.clone_url) # https://github.com/you/Hello-World.git
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Usage
83
+
84
+ ### 1. `fork()` — synchronous, blocking
85
+
86
+ Forks one repo and blocks until it is confirmed ready on GitHub's side.
87
+
88
+ ```python
89
+ from pygithub_fork import GitHubForker, ForkerConfig
90
+
91
+ forker = GitHubForker(gh)
92
+
93
+ result = forker.fork("octocat/Hello-World")
94
+ # result.status → ForkStatus.READY
95
+ # result.fork → github.Repository.Repository
96
+ # result.clone_url
97
+ # result.ssh_url
98
+ # result.already_existed → False (or True on re-run)
99
+ # result.elapsed_seconds
100
+ ```
101
+
102
+ Fork into an **organization** with a custom **name**:
103
+
104
+ ```python
105
+ result = forker.fork(
106
+ "octocat/Hello-World",
107
+ organization="my-org",
108
+ name="hello-world-internal",
109
+ default_branch_only=True,
110
+ )
111
+ ```
112
+
113
+ ---
114
+
115
+ ### 2. `fork_async()` — fire-and-forget, separated process
116
+
117
+ Submit a fork to the background thread pool and **return immediately**.
118
+ Query the `ForkJob` handle from anywhere — the caller is never blocked.
119
+
120
+ ```python
121
+ job = forker.fork_async("octocat/Hello-World")
122
+
123
+ # --- do other things in the meantime ---
124
+
125
+ # Non-blocking status check:
126
+ print(job.done) # True / False
127
+ print(job.status) # ForkStatus.PENDING | CREATED | READY | FAILED …
128
+
129
+ # Access result without blocking (returns None if still running):
130
+ result = job.result # ForkResult | None
131
+
132
+ # Block when you actually need the answer:
133
+ result = job.wait() # blocks until done, returns ForkResult
134
+ result = job.wait(timeout=30) # TimeoutError after 30s if not done
135
+ ```
136
+
137
+ This is the answer to **"fork then get status in a separate process"** — submit with `fork_async()` and poll `job.done` / `job.status` from any thread at any time without blocking.
138
+
139
+ **Concrete pattern — submit all, poll separately:**
140
+
141
+ ```python
142
+ jobs = [forker.fork_async(repo) for repo in ["owner/a", "owner/b", "owner/c"]]
143
+
144
+ # ... do other work ...
145
+
146
+ # Later, collect all results:
147
+ results = [job.wait() for job in jobs]
148
+
149
+ # Or poll individually without waiting:
150
+ for job in jobs:
151
+ if job.done:
152
+ print(job.source_full_name, job.status)
153
+ else:
154
+ print(job.source_full_name, "still running")
155
+ ```
156
+
157
+ ---
158
+
159
+ ### 3. `fork_many()` — bulk fork with thread pool
160
+
161
+ Fork a list in parallel (default) or sequentially:
162
+
163
+ ```python
164
+ results = forker.fork_many([
165
+ "owner/repo-a",
166
+ "owner/repo-b",
167
+ "owner/repo-c",
168
+ ])
169
+
170
+ for r in results:
171
+ print(r.source_full_name, r.status, r.succeeded)
172
+ ```
173
+
174
+ **Parallel vs sequential:**
175
+
176
+ ```python
177
+ # Parallel (default) — up to config.pool_workers concurrent forks
178
+ results = forker.fork_many(repos, parallel=True)
179
+
180
+ # Sequential — one at a time, guaranteed order, easier to debug
181
+ results = forker.fork_many(repos, parallel=False)
182
+ ```
183
+
184
+ **Per-item control with `ForkRequest`:**
185
+
186
+ ```python
187
+ from pygithub_fork import ForkRequest
188
+
189
+ requests = [
190
+ ForkRequest("owner/public-repo", organization="my-org"),
191
+ ForkRequest("owner/private-repo", name="private-fork", default_branch_only=True),
192
+ ForkRequest("owner/widget", organization="other-org", register_webhook=True,
193
+ webhook_url="https://ci.example.com/hooks/github"),
194
+ ]
195
+ results = forker.fork_many(requests)
196
+ ```
197
+
198
+ **Stop on first failure:**
199
+
200
+ ```python
201
+ results = forker.fork_many(repos, stop_on_error=True)
202
+ ```
203
+
204
+ ---
205
+
206
+ ### 4. `fork_iter()` — streaming results (completion order)
207
+
208
+ Yields each `ForkResult` as soon as it finishes — useful for large batches
209
+ where you want to start processing early:
210
+
211
+ ```python
212
+ for result in forker.fork_iter(["owner/a", "owner/b", "owner/c"]):
213
+ # results arrive in completion order, not submission order
214
+ print(result.source_full_name, result.status)
215
+ ```
216
+
217
+ ---
218
+
219
+ ### 5. Post-fork: upstream remote
220
+
221
+ After forking, automatically run `git remote add upstream <source_url>` in a
222
+ local clone:
223
+
224
+ ```python
225
+ from pygithub_fork import ForkerConfig
226
+
227
+ config = ForkerConfig(
228
+ add_upstream_remote=True,
229
+ local_clone_path="/path/to/your/local/clone",
230
+ )
231
+ forker = GitHubForker(gh, config)
232
+ result = forker.fork("octocat/Hello-World")
233
+
234
+ print(result.upstream_remote_added) # True
235
+ # Now: git remote -v shows `upstream → https://github.com/octocat/Hello-World.git`
236
+ ```
237
+
238
+ Override per-call:
239
+
240
+ ```python
241
+ result = forker.fork(
242
+ "octocat/Hello-World",
243
+ add_upstream_remote=True,
244
+ local_path="/path/to/clone",
245
+ )
246
+ ```
247
+
248
+ ---
249
+
250
+ ### 6. Post-fork: webhook registration
251
+
252
+ Register a GitHub webhook on the new fork immediately after creation:
253
+
254
+ ```python
255
+ config = ForkerConfig(
256
+ register_webhook=True,
257
+ webhook_url="https://ci.example.com/hooks/github",
258
+ webhook_events=["push", "pull_request", "fork"],
259
+ webhook_secret="s3cr3t",
260
+ )
261
+ forker = GitHubForker(gh, config)
262
+ result = forker.fork("octocat/Hello-World")
263
+
264
+ print(result.webhook_id) # GitHub hook ID
265
+ ```
266
+
267
+ Override per-call:
268
+
269
+ ```python
270
+ result = forker.fork(
271
+ "octocat/Hello-World",
272
+ register_webhook=True,
273
+ webhook_url="https://ci.example.com/hooks/github",
274
+ webhook_events=["push"],
275
+ )
276
+ ```
277
+
278
+ ---
279
+
280
+ ### 7. Advanced configuration
281
+
282
+ ```python
283
+ from pygithub_fork import ForkerConfig
284
+
285
+ config = ForkerConfig(
286
+ # Retry
287
+ max_retries=8,
288
+ base_backoff_seconds=2.0,
289
+ max_backoff_seconds=120.0,
290
+
291
+ # Readiness polling
292
+ wait_for_ready=True,
293
+ ready_timeout_seconds=120.0,
294
+ ready_poll_interval_seconds=3.0,
295
+
296
+ # Thread pool size (keep ≤ 4 to avoid GitHub secondary rate limits)
297
+ pool_workers=4,
298
+
299
+ # Post-fork actions
300
+ add_upstream_remote=True,
301
+ local_clone_path="/repos/my-clone",
302
+ register_webhook=True,
303
+ webhook_url="https://ci.example.com/hooks/github",
304
+ webhook_events=["push", "fork"],
305
+ webhook_secret="s3cr3t",
306
+
307
+ # Callbacks
308
+ on_retry=lambda attempt, exc, sleep: print(f"retry {attempt}: {exc}"),
309
+ on_fork_done=lambda result: print(f"done: {result.source_full_name}"),
310
+ )
311
+
312
+ forker = GitHubForker(gh, config)
313
+ ```
314
+
315
+ ---
316
+
317
+ ### 8. Context manager (pool cleanup)
318
+
319
+ ```python
320
+ with GitHubForker(gh) as forker:
321
+ results = forker.fork_many(repos)
322
+ # Thread pool is shut down cleanly here
323
+ ```
324
+
325
+ ---
326
+
327
+ ## ForkResult fields
328
+
329
+ | Field | Type | Description |
330
+ |---|---|---|
331
+ | `source_full_name` | `str` | `"owner/repo"` of the source |
332
+ | `fork` | `Repository \| None` | The forked repo object (PyGithub) |
333
+ | `status` | `ForkStatus` | `READY`, `CREATED`, `ALREADY_EXISTED`, `TIMED_OUT_WAITING`, `FAILED` |
334
+ | `already_existed` | `bool` | True if the fork pre-existed |
335
+ | `attempts` | `int` | How many API attempts were made |
336
+ | `elapsed_seconds` | `float` | Wall time from call to return |
337
+ | `clone_url` | `str \| None` | HTTPS clone URL of the fork |
338
+ | `ssh_url` | `str \| None` | SSH URL of the fork |
339
+ | `upstream_remote_added` | `bool` | Whether `git remote add upstream` ran |
340
+ | `webhook_id` | `int \| None` | GitHub hook ID if registered |
341
+ | `error` | `Exception \| None` | Set on failure; None on success |
342
+ | `succeeded` | `bool` | `fork is not None and error is None` |
343
+
344
+ ---
345
+
346
+ ## ForkJob fields / methods (fork_async)
347
+
348
+ | | Description |
349
+ |---|---|
350
+ | `.done` | `bool` — non-blocking check |
351
+ | `.status` | `ForkStatus` — `PENDING` while running, real status when done |
352
+ | `.result` | `ForkResult \| None` — non-blocking; `None` if still running |
353
+ | `.wait(timeout=None)` | Block and return `ForkResult`; raises `ForkError` on failure |
354
+ | `.source_full_name` | The `"owner/repo"` string passed in |
355
+
356
+ ---
357
+
358
+ ## Exception hierarchy
359
+
360
+ ```
361
+ ForkError
362
+ ├── ForkTimeoutError # readiness timeout
363
+ ├── ForkPermissionError # 401/403 (distinct from secondary rate limit)
364
+ ├── RepositoryNotFoundError
365
+ ├── WebhookError # webhook registration failed
366
+ └── UpstreamRemoteError # git remote add upstream failed
367
+ ```
368
+
369
+ ---
370
+
371
+ ## License
372
+
373
+ MIT © [Hadi Cahyadi](https://github.com/cumulus13)
374
+
375
+ ## 👤 Author
376
+
377
+ [Hadi Cahyadi](mailto:cumulus13@gmail.com)
378
+
379
+
380
+ [![Buy Me a Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/cumulus13)
381
+
382
+ [![Donate via Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/cumulus13)
383
+
384
+ [Support me on Patreon](https://www.patreon.com/cumulus13)
@@ -0,0 +1,8 @@
1
+ pygithub_fork/__init__.py,sha256=CwTISZLcqH55ii2vo2i674Eq77tORggKCIo6lvqiCsM,1442
2
+ pygithub_fork/exceptions.py,sha256=9oiHHP8ZTqbVmWJQSwQfsycS3EAqw3lx6SM6s4XcCzs,1157
3
+ pygithub_fork/forker.py,sha256=VPCXSTQSQyPcwe4PG2y8Q60MtWFe_3B405K6wEkVVGE,37767
4
+ pygithub_fork/models.py,sha256=_WW4qUT95jazQI_MjLPqfSYQMRi-Ul7ndQ9NTgyCoqQ,4460
5
+ github_forker-1.0.1.dist-info/METADATA,sha256=t3Ah2AS2ZIGlJflYL0VDPSbCQNCeETkLACoWUEgd3LA,11375
6
+ github_forker-1.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ github_forker-1.0.1.dist-info/licenses/LICENSE,sha256=YQuU6KUODZoDEEhHRhydl6yGSqwBX4PXAkqkSm2M6Qo,1091
8
+ github_forker-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hadi Cahyadi <cumulus13@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ """
2
+ pygithub-fork
3
+ ~~~~~~~~~~~~~
4
+ Production-ready GitHub repository forking built on PyGithub.
5
+
6
+ Quick start::
7
+
8
+ from github import Github
9
+ from pygithub_fork import GitHubForker, ForkerConfig
10
+
11
+ gh = Github("ghp_your_token")
12
+ forker = GitHubForker(gh)
13
+
14
+ # Synchronous — blocks until fork is ready
15
+ result = forker.fork("octocat/Hello-World")
16
+ print(result.status, result.clone_url)
17
+
18
+ # Fire-and-forget — check / wait from anywhere
19
+ job = forker.fork_async("PyGithub/PyGithub")
20
+ print(job.done, job.status) # non-blocking
21
+ result = job.wait() # block when you need the answer
22
+
23
+ # Bulk fork with thread pool
24
+ results = forker.fork_many(["owner/a", "owner/b", "owner/c"])
25
+ """
26
+
27
+ from .forker import ForkJob, GitHubForker
28
+ from .models import ForkRequest, ForkResult, ForkStatus, ForkerConfig
29
+ from .exceptions import (
30
+ ForkError,
31
+ ForkPermissionError,
32
+ ForkTimeoutError,
33
+ RepositoryNotFoundError,
34
+ UpstreamRemoteError,
35
+ WebhookError,
36
+ )
37
+
38
+ __version__ = "1.0.0"
39
+ __author__ = "Hadi Cahyadi"
40
+ __email__ = "cumulus13@gmail.com"
41
+
42
+ __all__ = [
43
+ # Main class + async job handle
44
+ "GitHubForker",
45
+ "ForkJob",
46
+ # Data models
47
+ "ForkRequest",
48
+ "ForkResult",
49
+ "ForkStatus",
50
+ "ForkerConfig",
51
+ # Exceptions
52
+ "ForkError",
53
+ "ForkPermissionError",
54
+ "ForkTimeoutError",
55
+ "RepositoryNotFoundError",
56
+ "UpstreamRemoteError",
57
+ "WebhookError",
58
+ ]
@@ -0,0 +1,39 @@
1
+ """
2
+ pygithub_fork.exceptions
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~
4
+ All custom exceptions raised by pygithub-fork.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ class ForkError(Exception):
10
+ """Base class for all pygithub-fork errors."""
11
+
12
+
13
+ class ForkTimeoutError(ForkError):
14
+ """Fork was created but did not become ready within the allotted time."""
15
+
16
+ def __init__(self, repo_full_name: str, waited_seconds: float) -> None:
17
+ self.repo_full_name = repo_full_name
18
+ self.waited_seconds = waited_seconds
19
+ super().__init__(
20
+ f"Fork of '{repo_full_name}' did not become ready after "
21
+ f"{waited_seconds:.1f}s. It may still finish on GitHub's side — "
22
+ f"increase ready_timeout_seconds or use wait_for_ready=False."
23
+ )
24
+
25
+
26
+ class ForkPermissionError(ForkError):
27
+ """The authenticated token lacks rights to fork into the target."""
28
+
29
+
30
+ class RepositoryNotFoundError(ForkError):
31
+ """The source repository does not exist or is inaccessible."""
32
+
33
+
34
+ class WebhookError(ForkError):
35
+ """Webhook registration or removal failed."""
36
+
37
+
38
+ class UpstreamRemoteError(ForkError):
39
+ """git remote add upstream step failed."""