github-forker 1.0.1__tar.gz

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,15 @@
1
+
2
+ # Ignore Sublime Text project files
3
+ *.sublime-*
4
+
5
+ # Added entries (1 items)
6
+ example.py
7
+
8
+ # Added entries (1 items)
9
+ *.log
10
+
11
+ # Added entries (1 items)
12
+ __pycache__/
13
+
14
+ # Added entries (1 items)
15
+ test/
@@ -0,0 +1,40 @@
1
+ # Changelog — github-forker
2
+
3
+ > **Note:** This package was originally named `pygithub-fork` but was renamed
4
+ > to `github-forker` before initial release because PyPI's similarity check
5
+ > flagged `pygithub-fork` as too similar to the existing `PyGithub` package
6
+ > (PyPI normalizes `pygithub-fork` → `pygithubfork` which is too close to
7
+ > `pygithub`). The Python module name `pygithub_fork` is unchanged.
8
+
9
+
10
+ All notable changes to this project will be documented in this file.
11
+
12
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
13
+ This project uses [Semantic Versioning](https://semver.org/).
14
+
15
+ ---
16
+
17
+ ## [1.0.1] — 2026-06-19
18
+
19
+ ### Added
20
+
21
+ - `GitHubForker.fork()` — synchronous fork with idempotency, retry/backoff, and readiness polling
22
+ - `GitHubForker.fork_async()` — fire-and-forget fork returning a `ForkJob` handle
23
+ - `ForkJob` — non-blocking `.done` / `.status` / `.result` + blocking `.wait(timeout)`
24
+ - `GitHubForker.fork_many()` — bulk fork via `ThreadPoolExecutor` (parallel or sequential)
25
+ - `GitHubForker.fork_iter()` — streaming generator yielding results in completion order
26
+ - `ForkRequest` — per-item declarative config for `fork_many`/`fork_iter` batches
27
+ - `ForkerConfig` — centralized, documented configuration dataclass
28
+ - Post-fork `git remote add upstream` support (`add_upstream_remote`, `local_clone_path`)
29
+ - Post-fork GitHub webhook registration (`register_webhook`, `webhook_url`, `webhook_events`, `webhook_secret`)
30
+ - Idempotent webhook de-duplication (checks existing hooks before creating)
31
+ - Idempotent upstream remote (checks before `git remote add`)
32
+ - Secondary rate limit (403 "abuse") distinguished from hard 403 permission errors
33
+ - PyGithub cross-version compatibility (`RateLimitExceededException` vs `RateLimitExceededError`)
34
+ - Full exception hierarchy: `ForkError`, `ForkTimeoutError`, `ForkPermissionError`, `RepositoryNotFoundError`, `WebhookError`, `UpstreamRemoteError`
35
+ - `on_retry` and `on_fork_done` callbacks in `ForkerConfig`
36
+ - Context manager support (`with GitHubForker(gh) as forker:`) for clean pool shutdown
37
+ - MIT license
38
+ - Full README with API reference and usage examples
39
+ - GitHub Actions CI workflow (lint + test on Python 3.9–3.12)
40
+ - GitHub Actions publish workflow (PyPI on tag push)
@@ -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,26 @@
1
+ .PHONY: install test lint build publish clean
2
+
3
+ install:
4
+ pip install -e ".[dev]"
5
+
6
+ test:
7
+ pytest tests/ -v --tb=short --cov=pygithub_fork --cov-report=term-missing
8
+
9
+ lint:
10
+ ruff check src/ tests/
11
+ mypy src/pygithub_fork/
12
+
13
+ build: clean
14
+ python -m build
15
+
16
+ publish: build
17
+ twine check dist/*
18
+ twine upload dist/*
19
+
20
+ # Upload to TestPyPI first to sanity-check before going live:
21
+ publish-test: build
22
+ twine check dist/*
23
+ twine upload --repository testpypi dist/*
24
+
25
+ clean:
26
+ rm -rf dist/ build/ *.egg-info src/*.egg-info .pytest_cache .mypy_cache
@@ -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)