github-backup 0.62.1__tar.gz → 0.64.0__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.
- {github_backup-0.62.1 → github_backup-0.64.0}/CHANGES.rst +127 -1
- {github_backup-0.62.1/github_backup.egg-info → github_backup-0.64.0}/PKG-INFO +1 -1
- github_backup-0.64.0/github_backup/__init__.py +1 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/cli.py +1 -1
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/github_backup.py +91 -53
- {github_backup-0.62.1 → github_backup-0.64.0/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/SOURCES.txt +2 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/release-requirements.txt +3 -3
- github_backup-0.64.0/tests/test_disabled_issues_pulls.py +105 -0
- github_backup-0.64.0/tests/test_logging_subprocess.py +113 -0
- github_backup-0.62.1/github_backup/__init__.py +0 -1
- {github_backup-0.62.1 → github_backup-0.64.0}/LICENSE.txt +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/MANIFEST.in +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/README +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/README.rst +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/__main__.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/graphql_queries.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/entry_points.txt +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/requirements.txt +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/setup.cfg +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/setup.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_all_starred.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_attachments.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_auth.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_case_sensitivity.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_discussions.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_http_451.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_incremental_per_repository.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_json_dump_if_changed.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_pagination.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_pull_incremental_pagination.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_pull_reviews.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_releases.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_retrieve_data.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_skip_assets_on.py +0 -0
- {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_starred_skip_size_over.py +0 -0
|
@@ -1,9 +1,135 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
-
0.
|
|
4
|
+
0.64.0 (2026-07-07)
|
|
5
5
|
-------------------
|
|
6
6
|
------------------------
|
|
7
|
+
|
|
8
|
+
Fix
|
|
9
|
+
~~~
|
|
10
|
+
- Drain subprocess pipes with reader threads to prevent Windows deadlock
|
|
11
|
+
(#519) [Rodos]
|
|
12
|
+
|
|
13
|
+
On Windows the child's stdout/stderr pipes were never drained, so any
|
|
14
|
+
git operation producing more output than the pipe buffer (~8KB) hung
|
|
15
|
+
the backup at the first sizeable clone or fetch. Reader threads now
|
|
16
|
+
drain both pipes on all platforms, replacing the select() loop.
|
|
17
|
+
|
|
18
|
+
- Windows no longer hangs and now gets subprocess logging
|
|
19
|
+
- Log lines no longer carry a stray \r on Windows, and a final line
|
|
20
|
+
without a trailing newline is no longer truncated
|
|
21
|
+
- CPU no longer pinned at 100% during git operations on Windows
|
|
22
|
+
|
|
23
|
+
Adds tests covering the deadlock, log levels, and line handling.
|
|
24
|
+
|
|
25
|
+
Fixes #519
|
|
26
|
+
|
|
27
|
+
Other
|
|
28
|
+
~~~~~
|
|
29
|
+
- Applied diff issuecomment-4900281663. [Peter Gabaldon]
|
|
30
|
+
- Fix template URL for watched repositories backup. [Peter Gabaldon]
|
|
31
|
+
- Chore(deps): bump setuptools in the python-packages group.
|
|
32
|
+
[dependabot[bot]]
|
|
33
|
+
|
|
34
|
+
Bumps the python-packages group with 1 update: [setuptools](https://github.com/pypa/setuptools).
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Updates `setuptools` from 82.0.1 to 83.0.0
|
|
38
|
+
- [Release notes](https://github.com/pypa/setuptools/releases)
|
|
39
|
+
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
|
|
40
|
+
- [Commits](https://github.com/pypa/setuptools/compare/v82.0.1...v83.0.0)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
updated-dependencies:
|
|
44
|
+
- dependency-name: setuptools
|
|
45
|
+
dependency-version: 83.0.0
|
|
46
|
+
dependency-type: direct:production
|
|
47
|
+
update-type: version-update:semver-major
|
|
48
|
+
dependency-group: python-packages
|
|
49
|
+
...
|
|
50
|
+
- Chore(deps): bump actions/checkout from 6 to 7. [dependabot[bot]]
|
|
51
|
+
|
|
52
|
+
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
|
|
53
|
+
- [Release notes](https://github.com/actions/checkout/releases)
|
|
54
|
+
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
|
|
55
|
+
- [Commits](https://github.com/actions/checkout/compare/v6...v7)
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
updated-dependencies:
|
|
59
|
+
- dependency-name: actions/checkout
|
|
60
|
+
dependency-version: '7'
|
|
61
|
+
dependency-type: direct:production
|
|
62
|
+
update-type: version-update:semver-major
|
|
63
|
+
...
|
|
64
|
+
- Chore(deps): bump pytest in the python-packages group.
|
|
65
|
+
[dependabot[bot]]
|
|
66
|
+
|
|
67
|
+
Bumps the python-packages group with 1 update: [pytest](https://github.com/pytest-dev/pytest).
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Updates `pytest` from 9.1.0 to 9.1.1
|
|
71
|
+
- [Release notes](https://github.com/pytest-dev/pytest/releases)
|
|
72
|
+
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
|
|
73
|
+
- [Commits](https://github.com/pytest-dev/pytest/compare/9.1.0...9.1.1)
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
updated-dependencies:
|
|
77
|
+
- dependency-name: pytest
|
|
78
|
+
dependency-version: 9.1.1
|
|
79
|
+
dependency-type: direct:production
|
|
80
|
+
update-type: version-update:semver-patch
|
|
81
|
+
dependency-group: python-packages
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
0.63.0 (2026-06-17)
|
|
86
|
+
-------------------
|
|
87
|
+
- Skip repositories with pull requests disabled (HTTP 404) instead of
|
|
88
|
+
crashing (#511) [Rodos]
|
|
89
|
+
|
|
90
|
+
Also harden the advisories/hooks 404 checks to match HTTPError.code instead of a substring.
|
|
91
|
+
- Chore(deps): bump pytest in the python-packages group.
|
|
92
|
+
[dependabot[bot]]
|
|
93
|
+
|
|
94
|
+
Bumps the python-packages group with 1 update: [pytest](https://github.com/pytest-dev/pytest).
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
Updates `pytest` from 9.0.3 to 9.1.0
|
|
98
|
+
- [Release notes](https://github.com/pytest-dev/pytest/releases)
|
|
99
|
+
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
|
|
100
|
+
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.0)
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
updated-dependencies:
|
|
104
|
+
- dependency-name: pytest
|
|
105
|
+
dependency-version: 9.1.0
|
|
106
|
+
dependency-type: direct:production
|
|
107
|
+
update-type: version-update:semver-minor
|
|
108
|
+
dependency-group: python-packages
|
|
109
|
+
...
|
|
110
|
+
- Chore(deps): bump black in the python-packages group.
|
|
111
|
+
[dependabot[bot]]
|
|
112
|
+
|
|
113
|
+
Bumps the python-packages group with 1 update: [black](https://github.com/psf/black).
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
Updates `black` from 26.3.1 to 26.5.1
|
|
117
|
+
- [Release notes](https://github.com/psf/black/releases)
|
|
118
|
+
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
|
|
119
|
+
- [Commits](https://github.com/psf/black/compare/26.3.1...26.5.1)
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
updated-dependencies:
|
|
123
|
+
- dependency-name: black
|
|
124
|
+
dependency-version: 26.5.1
|
|
125
|
+
dependency-type: direct:production
|
|
126
|
+
update-type: version-update:semver-minor
|
|
127
|
+
dependency-group: python-packages
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
0.62.1 (2026-04-30)
|
|
132
|
+
-------------------
|
|
7
133
|
- Document that nothing is saved by default. [Changaco]
|
|
8
134
|
- Eliminate trailing spaces. [Changaco]
|
|
9
135
|
- Remove pointless and unsafe `export`s in examples. [Changaco]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.64.0"
|
|
@@ -81,7 +81,7 @@ def main():
|
|
|
81
81
|
repositories = retrieve_repositories(args, authenticated_user)
|
|
82
82
|
repositories = filter_repositories(args, repositories)
|
|
83
83
|
backup_repositories(args, output_directory, repositories)
|
|
84
|
-
backup_account(args, output_directory)
|
|
84
|
+
backup_account(args, output_directory, authenticated_user)
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
if __name__ == "__main__":
|
|
@@ -12,11 +12,11 @@ import os
|
|
|
12
12
|
import platform
|
|
13
13
|
import random
|
|
14
14
|
import re
|
|
15
|
-
import select
|
|
16
15
|
import socket
|
|
17
16
|
import ssl
|
|
18
17
|
import subprocess
|
|
19
18
|
import sys
|
|
19
|
+
import threading
|
|
20
20
|
import time
|
|
21
21
|
from collections.abc import Generator
|
|
22
22
|
from datetime import datetime
|
|
@@ -91,32 +91,37 @@ def logging_subprocess(
|
|
|
91
91
|
child = subprocess.Popen(
|
|
92
92
|
popenargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs
|
|
93
93
|
)
|
|
94
|
-
if sys.platform == "win32":
|
|
95
|
-
logger.info(
|
|
96
|
-
"Windows operating system detected - no subprocess logging will be returned"
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
log_level = {child.stdout: stdout_log_level, child.stderr: stderr_log_level}
|
|
100
|
-
|
|
101
|
-
def check_io():
|
|
102
|
-
if sys.platform == "win32":
|
|
103
|
-
return
|
|
104
|
-
ready_to_read = select.select([child.stdout, child.stderr], [], [], 1000)[0]
|
|
105
|
-
for io in ready_to_read:
|
|
106
|
-
line = io.readline()
|
|
107
|
-
if not logger:
|
|
108
|
-
continue
|
|
109
|
-
if not (io == child.stderr and not line):
|
|
110
|
-
logger.log(log_level[io], line[:-1])
|
|
111
|
-
|
|
112
|
-
# keep checking stdout/stderr until the child exits
|
|
113
|
-
while child.poll() is None:
|
|
114
|
-
check_io()
|
|
115
94
|
|
|
116
|
-
|
|
95
|
+
def log_output(pipe, log_level):
|
|
96
|
+
# Drain the pipe from a thread so the child never blocks on a full
|
|
97
|
+
# pipe buffer (issue #519), logging each line as it arrives.
|
|
98
|
+
with pipe:
|
|
99
|
+
for line in iter(pipe.readline, b""):
|
|
100
|
+
try:
|
|
101
|
+
logger.log(log_level, line.rstrip(b"\r\n"))
|
|
102
|
+
except Exception:
|
|
103
|
+
# Keep draining even if logging fails, or the child
|
|
104
|
+
# blocks on a full pipe buffer again
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
threads = [
|
|
108
|
+
threading.Thread(
|
|
109
|
+
target=log_output, args=(child.stdout, stdout_log_level), daemon=True
|
|
110
|
+
),
|
|
111
|
+
threading.Thread(
|
|
112
|
+
target=log_output, args=(child.stderr, stderr_log_level), daemon=True
|
|
113
|
+
),
|
|
114
|
+
]
|
|
115
|
+
for thread in threads:
|
|
116
|
+
thread.start()
|
|
117
117
|
|
|
118
118
|
rc = child.wait()
|
|
119
119
|
|
|
120
|
+
# Timeout in case a grandchild inherited the pipe handles and keeps
|
|
121
|
+
# them open past the child's exit, which would delay EOF indefinitely
|
|
122
|
+
for thread in threads:
|
|
123
|
+
thread.join(timeout=60)
|
|
124
|
+
|
|
120
125
|
if rc != 0:
|
|
121
126
|
print("{} returned {}:".format(popenargs[0], rc), file=sys.stderr)
|
|
122
127
|
print("\t", " ".join(popenargs), file=sys.stderr)
|
|
@@ -2657,10 +2662,20 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
2657
2662
|
def pull_is_due_for_repository_checkpoint(pull):
|
|
2658
2663
|
return not repository_since or pull["updated_at"] > repository_since
|
|
2659
2664
|
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2665
|
+
try:
|
|
2666
|
+
if not args.include_pull_details:
|
|
2667
|
+
pull_states = ["open", "closed"]
|
|
2668
|
+
for pull_state in pull_states:
|
|
2669
|
+
query_args["state"] = pull_state
|
|
2670
|
+
for pull in retrieve_data(
|
|
2671
|
+
args, _pulls_template, query_args=query_args, lazy=True
|
|
2672
|
+
):
|
|
2673
|
+
track_newest_pull_update(pull)
|
|
2674
|
+
if pulls_since and pull["updated_at"] <= pulls_since:
|
|
2675
|
+
break
|
|
2676
|
+
if not pulls_since or pull["updated_at"] > pulls_since:
|
|
2677
|
+
pulls[pull["number"]] = pull
|
|
2678
|
+
else:
|
|
2664
2679
|
for pull in retrieve_data(
|
|
2665
2680
|
args, _pulls_template, query_args=query_args, lazy=True
|
|
2666
2681
|
):
|
|
@@ -2669,22 +2684,29 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
|
|
|
2669
2684
|
break
|
|
2670
2685
|
if not pulls_since or pull["updated_at"] > pulls_since:
|
|
2671
2686
|
pulls[pull["number"]] = pull
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2687
|
+
except HTTPError as e:
|
|
2688
|
+
# Repositories with pull requests disabled return HTTP 404 for the
|
|
2689
|
+
# listing endpoint; treat that as "no pull requests" rather than a
|
|
2690
|
+
# fatal error. retrieve_data is lazy here, so the 404 surfaces during
|
|
2691
|
+
# iteration. Only the listing call is guarded — a 404 on an individual
|
|
2692
|
+
# pull's detail fetch below is a different condition and must propagate.
|
|
2693
|
+
if e.code == 404:
|
|
2694
|
+
logger.info("Pull requests are disabled for this repository, skipping")
|
|
2695
|
+
return
|
|
2696
|
+
raise
|
|
2697
|
+
|
|
2698
|
+
if args.include_pull_details:
|
|
2699
|
+
# Replace the summary payload with the full pull detail. Done outside
|
|
2700
|
+
# the disabled-feature guard above so a 404 on a single pull (e.g. one
|
|
2701
|
+
# deleted between listing and fetch) is not mistaken for the whole
|
|
2702
|
+
# feature being disabled, which would discard every collected pull.
|
|
2703
|
+
for number in list(pulls.keys()):
|
|
2704
|
+
if pull_is_due_for_repository_checkpoint(pulls[number]):
|
|
2705
|
+
pulls[number] = retrieve_data(
|
|
2706
|
+
args,
|
|
2707
|
+
_pulls_template + "/{}".format(number),
|
|
2708
|
+
paginated=False,
|
|
2709
|
+
)[0]
|
|
2688
2710
|
|
|
2689
2711
|
logger.info("Saving {0} pull requests to disk".format(len(list(pulls.keys()))))
|
|
2690
2712
|
# Comments from pulls API are only _review_ comments
|
|
@@ -2813,8 +2835,8 @@ def backup_security_advisories(args, repo_cwd, repository, repos_template):
|
|
|
2813
2835
|
|
|
2814
2836
|
try:
|
|
2815
2837
|
_advisories = retrieve_data(args, template)
|
|
2816
|
-
except
|
|
2817
|
-
if
|
|
2838
|
+
except HTTPError as e:
|
|
2839
|
+
if e.code == 404:
|
|
2818
2840
|
logger.info("Security advisories are not available for this repository, skipping")
|
|
2819
2841
|
return
|
|
2820
2842
|
raise
|
|
@@ -2861,8 +2883,8 @@ def backup_hooks(args, repo_cwd, repository, repos_template):
|
|
|
2861
2883
|
template = "{0}/{1}/hooks".format(repos_template, repository["full_name"])
|
|
2862
2884
|
try:
|
|
2863
2885
|
_backup_data(args, "hooks", template, output_file, hook_cwd)
|
|
2864
|
-
except
|
|
2865
|
-
if
|
|
2886
|
+
except HTTPError as e:
|
|
2887
|
+
if e.code == 404:
|
|
2866
2888
|
logger.info("Unable to read hooks, skipping")
|
|
2867
2889
|
else:
|
|
2868
2890
|
raise e
|
|
@@ -3046,8 +3068,10 @@ def fetch_repository(
|
|
|
3046
3068
|
logging_subprocess(git_command, cwd=local_dir)
|
|
3047
3069
|
|
|
3048
3070
|
|
|
3049
|
-
def backup_account(args, output_directory):
|
|
3071
|
+
def backup_account(args, output_directory, authenticated_user=None):
|
|
3050
3072
|
account_cwd = os.path.join(output_directory, "account")
|
|
3073
|
+
if authenticated_user is None:
|
|
3074
|
+
authenticated_user = {"login": None}
|
|
3051
3075
|
|
|
3052
3076
|
if args.include_starred or args.include_everything:
|
|
3053
3077
|
output_file = "{0}/starred.json".format(account_cwd)
|
|
@@ -3057,11 +3081,25 @@ def backup_account(args, output_directory):
|
|
|
3057
3081
|
_backup_data(args, "starred repositories", template, output_file, account_cwd)
|
|
3058
3082
|
|
|
3059
3083
|
if args.include_watched or args.include_everything:
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3084
|
+
# GitHub deprecated /users/{username}/subscriptions; watched
|
|
3085
|
+
# repositories are only available for the authenticated user via
|
|
3086
|
+
# /user/subscriptions.
|
|
3087
|
+
if (
|
|
3088
|
+
not authenticated_user.get("login")
|
|
3089
|
+
or args.user.lower() != authenticated_user["login"].lower()
|
|
3090
|
+
):
|
|
3091
|
+
logger.warning(
|
|
3092
|
+
"Cannot retrieve watched repositories for '%s'. GitHub only allows access to the authenticated user's watched repositories.",
|
|
3093
|
+
args.user,
|
|
3094
|
+
)
|
|
3095
|
+
else:
|
|
3096
|
+
output_file = "{0}/watched.json".format(account_cwd)
|
|
3097
|
+
template = "https://{0}/user/subscriptions".format(
|
|
3098
|
+
get_github_api_host(args)
|
|
3099
|
+
)
|
|
3100
|
+
_backup_data(
|
|
3101
|
+
args, "watched repositories", template, output_file, account_cwd
|
|
3102
|
+
)
|
|
3065
3103
|
|
|
3066
3104
|
if args.include_followers or args.include_everything:
|
|
3067
3105
|
output_file = "{0}/followers.json".format(account_cwd)
|
|
@@ -21,10 +21,12 @@ tests/test_all_starred.py
|
|
|
21
21
|
tests/test_attachments.py
|
|
22
22
|
tests/test_auth.py
|
|
23
23
|
tests/test_case_sensitivity.py
|
|
24
|
+
tests/test_disabled_issues_pulls.py
|
|
24
25
|
tests/test_discussions.py
|
|
25
26
|
tests/test_http_451.py
|
|
26
27
|
tests/test_incremental_per_repository.py
|
|
27
28
|
tests/test_json_dump_if_changed.py
|
|
29
|
+
tests/test_logging_subprocess.py
|
|
28
30
|
tests/test_pagination.py
|
|
29
31
|
tests/test_pull_incremental_pagination.py
|
|
30
32
|
tests/test_pull_reviews.py
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# Linting & Formatting
|
|
2
2
|
autopep8==2.3.2
|
|
3
|
-
black==26.
|
|
3
|
+
black==26.5.1
|
|
4
4
|
flake8==7.3.0
|
|
5
5
|
|
|
6
6
|
# Testing
|
|
7
|
-
pytest==9.
|
|
7
|
+
pytest==9.1.1
|
|
8
8
|
|
|
9
9
|
# Release & Publishing
|
|
10
10
|
twine==6.2.0
|
|
11
11
|
gitchangelog==3.0.4
|
|
12
|
-
setuptools==
|
|
12
|
+
setuptools==83.0.0
|
|
13
13
|
|
|
14
14
|
# Documentation
|
|
15
15
|
restructuredtext-lint==2.0.2
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for repositories with pull requests disabled (GitHub issue #511).
|
|
2
|
+
|
|
3
|
+
When pull requests are disabled on a repository, the ``/pulls`` endpoint
|
|
4
|
+
responds with HTTP 404. The backup should treat that as "no pull requests"
|
|
5
|
+
and continue, rather than aborting the whole run. (The ``/issues`` endpoint
|
|
6
|
+
returns 200 with an empty list when issues are disabled, so it needs no such
|
|
7
|
+
handling.)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from urllib.error import HTTPError
|
|
12
|
+
|
|
13
|
+
from github_backup import github_backup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _http_error(template, code, reason):
|
|
17
|
+
return HTTPError(template, code, reason, hdrs=None, fp=None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_backup_pulls_skips_on_404(create_args, tmp_path, monkeypatch, caplog):
|
|
21
|
+
args = create_args(include_pulls=True, since=None)
|
|
22
|
+
repository = {"full_name": "owner/repo"}
|
|
23
|
+
|
|
24
|
+
def fake_retrieve_data(passed_args, template, query_args=None, lazy=False, **kwargs):
|
|
25
|
+
# retrieve_data is called lazily for the pull listing; the 404 must
|
|
26
|
+
# surface while iterating the returned generator.
|
|
27
|
+
def gen():
|
|
28
|
+
raise _http_error(template, 404, "Not Found")
|
|
29
|
+
yield # pragma: no cover - generator marker
|
|
30
|
+
|
|
31
|
+
if lazy:
|
|
32
|
+
return gen()
|
|
33
|
+
raise _http_error(template, 404, "Not Found")
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(github_backup, "retrieve_data", fake_retrieve_data)
|
|
36
|
+
|
|
37
|
+
# Should not raise, and should log that it skipped.
|
|
38
|
+
with caplog.at_level(logging.INFO, logger=github_backup.logger.name):
|
|
39
|
+
github_backup.backup_pulls(
|
|
40
|
+
args, tmp_path, repository, "https://api.github.com/repos"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert "Pull requests are disabled for this repository, skipping" in caplog.text
|
|
44
|
+
|
|
45
|
+
pulls_dir = tmp_path / "pulls"
|
|
46
|
+
if pulls_dir.exists():
|
|
47
|
+
assert not [p for p in pulls_dir.iterdir() if p.suffix == ".json"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_backup_pulls_reraises_non_404(create_args, tmp_path, monkeypatch):
|
|
51
|
+
args = create_args(include_pulls=True, since=None)
|
|
52
|
+
repository = {"full_name": "owner/repo"}
|
|
53
|
+
|
|
54
|
+
def fake_retrieve_data(passed_args, template, query_args=None, lazy=False, **kwargs):
|
|
55
|
+
def gen():
|
|
56
|
+
raise _http_error(template, 500, "Server Error")
|
|
57
|
+
yield # pragma: no cover - generator marker
|
|
58
|
+
|
|
59
|
+
if lazy:
|
|
60
|
+
return gen()
|
|
61
|
+
raise _http_error(template, 500, "Server Error")
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr(github_backup, "retrieve_data", fake_retrieve_data)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
github_backup.backup_pulls(
|
|
67
|
+
args, tmp_path, repository, "https://api.github.com/repos"
|
|
68
|
+
)
|
|
69
|
+
except HTTPError as exc:
|
|
70
|
+
assert exc.code == 500
|
|
71
|
+
else:
|
|
72
|
+
raise AssertionError("Expected non-404 error to propagate")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_backup_pulls_details_individual_404_propagates(
|
|
76
|
+
create_args, tmp_path, monkeypatch
|
|
77
|
+
):
|
|
78
|
+
# With --pull-details the listing succeeds (feature is enabled), but an
|
|
79
|
+
# individual pull's detail fetch 404s (e.g. deleted mid-backup). That must
|
|
80
|
+
# propagate, NOT be swallowed as "pull requests are disabled".
|
|
81
|
+
args = create_args(include_pulls=True, include_pull_details=True, since=None)
|
|
82
|
+
repository = {"full_name": "owner/repo"}
|
|
83
|
+
|
|
84
|
+
listing = "https://api.github.com/repos/owner/repo/pulls"
|
|
85
|
+
|
|
86
|
+
def fake_retrieve_data(passed_args, template, query_args=None, lazy=False, **kwargs):
|
|
87
|
+
if template == listing:
|
|
88
|
+
# Listing works fine -> pull requests are clearly not disabled.
|
|
89
|
+
def gen():
|
|
90
|
+
yield {"number": 1, "updated_at": "2026-02-01T00:00:00Z"}
|
|
91
|
+
|
|
92
|
+
return gen()
|
|
93
|
+
# Individual pull detail fetch (.../pulls/1) is missing.
|
|
94
|
+
raise _http_error(template, 404, "Not Found")
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(github_backup, "retrieve_data", fake_retrieve_data)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
github_backup.backup_pulls(
|
|
100
|
+
args, tmp_path, repository, "https://api.github.com/repos"
|
|
101
|
+
)
|
|
102
|
+
except HTTPError as exc:
|
|
103
|
+
assert exc.code == 404
|
|
104
|
+
else:
|
|
105
|
+
raise AssertionError("Expected individual-pull 404 to propagate")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tests for logging_subprocess pipe handling (issue #519)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from github_backup import github_backup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestLoggingSubprocess:
|
|
13
|
+
"""Test suite for logging_subprocess deadlock and logging behavior."""
|
|
14
|
+
|
|
15
|
+
def test_large_stderr_output_does_not_deadlock(self):
|
|
16
|
+
"""Child output larger than the OS pipe buffer must not hang.
|
|
17
|
+
|
|
18
|
+
Regression test for issue #519: on Windows the pipes were never
|
|
19
|
+
drained, so the child blocked once its output exceeded the pipe
|
|
20
|
+
buffer (~8KB) and the parent spun forever on poll().
|
|
21
|
+
"""
|
|
22
|
+
# Write 256KB to stderr, far past any platform's pipe buffer
|
|
23
|
+
child_code = (
|
|
24
|
+
"import sys\n"
|
|
25
|
+
"for _ in range(3200):\n"
|
|
26
|
+
" sys.stderr.write('x' * 79 + '\\n')\n"
|
|
27
|
+
)
|
|
28
|
+
result = {}
|
|
29
|
+
|
|
30
|
+
def run():
|
|
31
|
+
result["rc"] = github_backup.logging_subprocess(
|
|
32
|
+
[sys.executable, "-c", child_code]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
thread = threading.Thread(target=run, daemon=True)
|
|
36
|
+
thread.start()
|
|
37
|
+
thread.join(timeout=30)
|
|
38
|
+
|
|
39
|
+
assert not thread.is_alive(), "logging_subprocess deadlocked on large output"
|
|
40
|
+
assert result["rc"] == 0
|
|
41
|
+
|
|
42
|
+
def test_stdout_logged_at_debug_stderr_at_error(self, caplog):
|
|
43
|
+
"""stdout lines log at DEBUG and stderr lines at ERROR."""
|
|
44
|
+
child_code = (
|
|
45
|
+
"import sys\n"
|
|
46
|
+
"print('to stdout')\n"
|
|
47
|
+
"print('to stderr', file=sys.stderr)\n"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
with caplog.at_level(logging.DEBUG, logger="github_backup.github_backup"):
|
|
51
|
+
rc = github_backup.logging_subprocess([sys.executable, "-c", child_code])
|
|
52
|
+
|
|
53
|
+
assert rc == 0
|
|
54
|
+
records = [
|
|
55
|
+
(r.levelno, r.getMessage())
|
|
56
|
+
for r in caplog.records
|
|
57
|
+
if r.name == "github_backup.github_backup"
|
|
58
|
+
]
|
|
59
|
+
assert (logging.DEBUG, str(b"to stdout")) in records
|
|
60
|
+
assert (logging.ERROR, str(b"to stderr")) in records
|
|
61
|
+
|
|
62
|
+
def test_trailing_newlines_stripped(self, caplog):
|
|
63
|
+
"""Logged lines have trailing \\r\\n stripped, including Windows CRLF."""
|
|
64
|
+
child_code = (
|
|
65
|
+
"import sys\n"
|
|
66
|
+
"sys.stdout.buffer.write(b'crlf line\\r\\n')\n"
|
|
67
|
+
"sys.stdout.buffer.write(b'lf line\\n')\n"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
with caplog.at_level(logging.DEBUG, logger="github_backup.github_backup"):
|
|
71
|
+
rc = github_backup.logging_subprocess([sys.executable, "-c", child_code])
|
|
72
|
+
|
|
73
|
+
assert rc == 0
|
|
74
|
+
messages = [
|
|
75
|
+
r.getMessage()
|
|
76
|
+
for r in caplog.records
|
|
77
|
+
if r.name == "github_backup.github_backup"
|
|
78
|
+
]
|
|
79
|
+
assert str(b"crlf line") in messages
|
|
80
|
+
assert str(b"lf line") in messages
|
|
81
|
+
|
|
82
|
+
def test_final_line_without_newline_not_truncated(self, caplog):
|
|
83
|
+
"""A final line with no trailing newline keeps its last character.
|
|
84
|
+
|
|
85
|
+
The old code stripped the newline with line[:-1], which chopped the
|
|
86
|
+
last character off any line that did not end with a newline.
|
|
87
|
+
"""
|
|
88
|
+
child_code = "import sys\nsys.stdout.write('no newline')\n"
|
|
89
|
+
|
|
90
|
+
with caplog.at_level(logging.DEBUG, logger="github_backup.github_backup"):
|
|
91
|
+
rc = github_backup.logging_subprocess([sys.executable, "-c", child_code])
|
|
92
|
+
|
|
93
|
+
assert rc == 0
|
|
94
|
+
messages = [
|
|
95
|
+
r.getMessage()
|
|
96
|
+
for r in caplog.records
|
|
97
|
+
if r.name == "github_backup.github_backup"
|
|
98
|
+
]
|
|
99
|
+
assert str(b"no newline") in messages
|
|
100
|
+
|
|
101
|
+
def test_returns_child_exit_code(self, capsys):
|
|
102
|
+
"""Non-zero child exit codes are returned and summarized on stderr."""
|
|
103
|
+
rc = github_backup.logging_subprocess(
|
|
104
|
+
[sys.executable, "-c", "import sys; sys.exit(3)"]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
assert rc == 3
|
|
108
|
+
captured = capsys.readouterr()
|
|
109
|
+
assert "returned 3" in captured.err
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
pytest.main([__file__, "-v"])
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.62.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|