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.
Files changed (39) hide show
  1. {github_backup-0.62.1 → github_backup-0.64.0}/CHANGES.rst +127 -1
  2. {github_backup-0.62.1/github_backup.egg-info → github_backup-0.64.0}/PKG-INFO +1 -1
  3. github_backup-0.64.0/github_backup/__init__.py +1 -0
  4. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/cli.py +1 -1
  5. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/github_backup.py +91 -53
  6. {github_backup-0.62.1 → github_backup-0.64.0/github_backup.egg-info}/PKG-INFO +1 -1
  7. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/SOURCES.txt +2 -0
  8. {github_backup-0.62.1 → github_backup-0.64.0}/release-requirements.txt +3 -3
  9. github_backup-0.64.0/tests/test_disabled_issues_pulls.py +105 -0
  10. github_backup-0.64.0/tests/test_logging_subprocess.py +113 -0
  11. github_backup-0.62.1/github_backup/__init__.py +0 -1
  12. {github_backup-0.62.1 → github_backup-0.64.0}/LICENSE.txt +0 -0
  13. {github_backup-0.62.1 → github_backup-0.64.0}/MANIFEST.in +0 -0
  14. {github_backup-0.62.1 → github_backup-0.64.0}/README +0 -0
  15. {github_backup-0.62.1 → github_backup-0.64.0}/README.rst +0 -0
  16. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/__main__.py +0 -0
  17. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup/graphql_queries.py +0 -0
  18. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/dependency_links.txt +0 -0
  19. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/entry_points.txt +0 -0
  20. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/top_level.txt +0 -0
  21. {github_backup-0.62.1 → github_backup-0.64.0}/github_backup.egg-info/zip-safe +0 -0
  22. {github_backup-0.62.1 → github_backup-0.64.0}/requirements.txt +0 -0
  23. {github_backup-0.62.1 → github_backup-0.64.0}/setup.cfg +0 -0
  24. {github_backup-0.62.1 → github_backup-0.64.0}/setup.py +0 -0
  25. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_all_starred.py +0 -0
  26. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_attachments.py +0 -0
  27. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_auth.py +0 -0
  28. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_case_sensitivity.py +0 -0
  29. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_discussions.py +0 -0
  30. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_http_451.py +0 -0
  31. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_incremental_per_repository.py +0 -0
  32. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_json_dump_if_changed.py +0 -0
  33. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_pagination.py +0 -0
  34. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_pull_incremental_pagination.py +0 -0
  35. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_pull_reviews.py +0 -0
  36. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_releases.py +0 -0
  37. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_retrieve_data.py +0 -0
  38. {github_backup-0.62.1 → github_backup-0.64.0}/tests/test_skip_assets_on.py +0 -0
  39. {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.62.1 (2026-04-30)
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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.62.1
3
+ Version: 0.64.0
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -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
- check_io() # check again to catch anything after the process exits
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
- if not args.include_pull_details:
2661
- pull_states = ["open", "closed"]
2662
- for pull_state in pull_states:
2663
- query_args["state"] = pull_state
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
- else:
2673
- for pull in retrieve_data(
2674
- args, _pulls_template, query_args=query_args, lazy=True
2675
- ):
2676
- track_newest_pull_update(pull)
2677
- if pulls_since and pull["updated_at"] <= pulls_since:
2678
- break
2679
- if not pulls_since or pull["updated_at"] > pulls_since:
2680
- if pull_is_due_for_repository_checkpoint(pull):
2681
- pulls[pull["number"]] = retrieve_data(
2682
- args,
2683
- _pulls_template + "/{}".format(pull["number"]),
2684
- paginated=False,
2685
- )[0]
2686
- else:
2687
- pulls[pull["number"]] = pull
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 Exception as e:
2817
- if "404" in str(e):
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 Exception as e:
2865
- if "404" in str(e):
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
- output_file = "{0}/watched.json".format(account_cwd)
3061
- template = "https://{0}/users/{1}/subscriptions".format(
3062
- get_github_api_host(args), args.user
3063
- )
3064
- _backup_data(args, "watched repositories", template, output_file, account_cwd)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.62.1
3
+ Version: 0.64.0
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -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.1
3
+ black==26.5.1
4
4
  flake8==7.3.0
5
5
 
6
6
  # Testing
7
- pytest==9.0.3
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==82.0.1
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