python-github-plus 1.1.1__py3-none-any.whl → 2.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.
@@ -1,7 +1,3 @@
1
- from dotenv import load_dotenv
2
-
3
1
  from python_github_plus.github_plus import GitHubClient, GitHubPRStatus, GitHubWorkflowStatus
4
2
 
5
- load_dotenv()
6
-
7
3
  __all__ = ["GitHubPRStatus", "GitHubWorkflowStatus", "GitHubClient"]
@@ -1 +1,7 @@
1
+ from typing import Literal
2
+
1
3
  LOGGER_NAME = "python_github_plus"
4
+
5
+ DEFAULT_BASE_URL = "https://api.github.com"
6
+ MergeMethod = Literal["merge", "squash", "rebase"]
7
+ PRState = Literal["open", "closed", "all"]
@@ -1,21 +1,31 @@
1
+ import base64
1
2
  import builtins
2
3
  import os
3
4
  import time
5
+ from collections.abc import Iterable
4
6
  from enum import Enum
7
+ from itertools import islice
8
+ from typing import TypeVar, get_args
5
9
 
6
10
  from custom_python_logger import get_logger
7
- from github import Auth, Github, GithubException
11
+ from dotenv import load_dotenv
12
+ from github import Auth, Github
8
13
  from github.Branch import Branch
9
14
  from github.ContentFile import ContentFile
15
+ from github.File import File
16
+ from github.GithubObject import NotSet
10
17
  from github.GitRef import GitRef
11
18
  from github.GitTag import GitTag
19
+ from github.NamedUser import NamedUser
12
20
  from github.PullRequest import PullRequest
13
21
  from github.Repository import Repository
14
22
  from github.Tag import Tag
15
23
  from github.Workflow import Workflow
16
24
  from github.WorkflowRun import WorkflowRun
17
25
 
18
- from python_github_plus.consts import LOGGER_NAME
26
+ from python_github_plus.consts import DEFAULT_BASE_URL, LOGGER_NAME, MergeMethod, PRState
27
+
28
+ T = TypeVar("T")
19
29
 
20
30
 
21
31
  class GitHubPRStatus(Enum):
@@ -30,114 +40,153 @@ class GitHubWorkflowStatus(Enum):
30
40
  QUEUED = "queued"
31
41
 
32
42
 
33
- class GitHubProjectService:
43
+ def _take(items: Iterable[T], limit: int | None = None) -> list[T]:
44
+ """Materialize at most `limit` items. PyGithub fetches pages lazily, so a limit stops paging early."""
45
+ if limit is None:
46
+ return list(items)
47
+ if limit < 1:
48
+ raise ValueError(f"limit must be at least 1, got {limit}")
49
+ return list(islice(items, limit))
50
+
51
+
52
+ def _delete_ref(repo: Repository, ref: str) -> None:
53
+ """
54
+ Delete a git ref. A lazy GitRef carries the read URL (`git/ref/...`), but DELETE lives at `git/refs/...`,
55
+ so fetch it first: that sets the canonical URL, and a missing ref raises 404 here.
56
+ """
57
+ git_ref = repo.get_git_ref(ref)
58
+ git_ref.complete()
59
+ git_ref.delete()
60
+
61
+
62
+ class _RepoService:
34
63
  def __init__(self, repo: Repository) -> None:
35
64
  self.logger = get_logger(LOGGER_NAME)
36
65
  self.repo = repo
37
66
 
67
+ def _ref_or_default(self, ref: str | None) -> str:
68
+ return ref or self.repo.default_branch
69
+
70
+
71
+ class GitHubProjectService(_RepoService):
38
72
  def get_info(self) -> Repository:
39
73
  return self.repo
40
74
 
41
- def list_members(self) -> list:
42
- return list(self.repo.get_collaborators())
75
+ def list_members(self, limit: int | None = None) -> list[NamedUser]:
76
+ return _take(self.repo.get_collaborators(), limit)
43
77
 
44
78
  def add_member(self, username: str, permission: str = "push") -> None:
45
- try:
46
- self.repo.add_to_collaborators(username, permission)
47
- self.logger.info(f"✅ Added member '{username}' with '{permission}' permission.")
48
- except GithubException as e:
49
- self.logger.error(f"❌ Failed to add member {username}: {e}")
79
+ self.repo.add_to_collaborators(username, permission)
80
+ self.logger.info(f"✅ Added member '{username}' with '{permission}' permission.")
50
81
 
51
82
  def remove_member(self, username: str) -> None:
52
- try:
53
- self.repo.remove_from_collaborators(username)
54
- self.logger.info(f"✅ Removed member '{username}'.")
55
- except GithubException as e:
56
- self.logger.error(f"❌ Failed to remove member {username}: {e}")
83
+ self.repo.remove_from_collaborators(username)
84
+ self.logger.info(f"✅ Removed member '{username}'.")
57
85
 
58
86
 
59
- class GitHubWorkflowService:
60
- def __init__(self, repo: Repository) -> None:
61
- self.logger = get_logger(LOGGER_NAME)
62
- self.repo = repo
63
-
87
+ class GitHubWorkflowService(_RepoService):
64
88
  def list(self) -> list[Workflow]:
65
89
  """List workflows configured in the repo (.github/workflows)."""
66
90
  return list(self.repo.get_workflows())
67
91
 
68
- def trigger(self, workflow_name: str, branch_name: str = "main", inputs: dict | None = None) -> Workflow:
69
- """
70
- Dispatch a workflow by name on a given branch.
71
- Requires 'workflow_dispatch' defined in the workflow YAML.
92
+ def find(self, workflow: str | int) -> Workflow:
93
+ """Find a workflow by display name, file name (`ci.yml`), path or id. Names fall back to case-insensitive."""
94
+ key = str(workflow)
95
+ workflows = self.list()
96
+ matchers = (
97
+ lambda wf: wf.name == key,
98
+ lambda wf: key in {wf.path, os.path.basename(wf.path)},
99
+ lambda wf: str(wf.id) == key,
100
+ lambda wf: wf.name.casefold() == key.casefold(),
101
+ )
102
+ for matches in matchers:
103
+ found = [wf for wf in workflows if matches(wf)]
104
+ if len(found) == 1:
105
+ return found[0]
106
+ if len(found) > 1:
107
+ raise ValueError(f"❌ Workflow '{key}' is ambiguous: {[wf.path for wf in found]}")
108
+ available = [f"{wf.name} ({os.path.basename(wf.path)})" for wf in workflows]
109
+ raise ValueError(f"❌ Workflow '{key}' not found. Available: {available}")
110
+
111
+ def trigger(
112
+ self, workflow_name: str | int, branch_name: str | None = None, inputs: dict | None = None
113
+ ) -> WorkflowRun | None:
72
114
  """
73
- workflows = {wf.name: wf for wf in self.repo.get_workflows()}
74
- if workflow_name not in workflows:
75
- raise ValueError(f"❌ Workflow '{workflow_name}' not found.")
115
+ Dispatch a workflow on a branch (the repo's default branch if not given).
116
+ Requires 'workflow_dispatch' in the workflow YAML. Raises GithubException if GitHub rejects the dispatch.
76
117
 
77
- workflow = workflows[workflow_name]
78
- workflow.create_dispatch(ref=branch_name, inputs=inputs or {})
79
- self.logger.info(f"🚀 Triggered workflow '{workflow_name}' on branch '{branch_name}'")
80
-
81
- # Note: This returns immediately; to track status, use wait_until_finished
82
- return workflow
118
+ Returns the created run when GitHub reports it (github.com does); otherwise None,
119
+ and the run can be found with list_runs.
120
+ """
121
+ workflow = self.find(workflow_name)
122
+ branch_name = self._ref_or_default(branch_name)
123
+ result = workflow.create_dispatch(ref=branch_name, inputs=inputs or {}, throw=True, return_run_details=True)
124
+ self.logger.info(f"🚀 Triggered workflow '{workflow.name}' on branch '{branch_name}'")
125
+ return result if isinstance(result, WorkflowRun) else None
83
126
 
84
127
  def get_workflow_by_id(self, workflow_id: int) -> Workflow:
85
128
  return self.repo.get_workflow(workflow_id)
86
129
 
87
- def list_runs(self, workflow_name: str, branch: str | None = None) -> builtins.list[WorkflowRun]:
88
- workflows = {wf.name: wf for wf in self.repo.get_workflows()}
89
- if workflow_name not in workflows:
90
- raise ValueError(f"❌ Workflow '{workflow_name}' not found.")
130
+ def list_runs(
131
+ self, workflow_name: str | int, branch: str | None = None, limit: int | None = None
132
+ ) -> builtins.list[WorkflowRun]:
133
+ """Runs of a workflow, newest first."""
134
+ workflow = self.find(workflow_name)
135
+ runs = workflow.get_runs(branch=branch) if branch else workflow.get_runs()
136
+ return _take(runs, limit)
91
137
 
92
- runs = workflows[workflow_name].get_runs(branch=branch) if branch else workflows[workflow_name].get_runs()
93
- return list(runs)
138
+ def get_run(self, run_id: int) -> WorkflowRun:
139
+ return self.repo.get_workflow_run(run_id)
94
140
 
95
- def last_run_by_id(self, workflow_id: int) -> WorkflowRun:
96
- return self.repo.get_workflow(workflow_id).get_runs()[0]
141
+ def last_run_by_id(self, workflow_id: int) -> WorkflowRun | None:
142
+ """The newest run of a workflow, or None if it never ran."""
143
+ return next(iter(self.repo.get_workflow(workflow_id).get_runs()), None)
97
144
 
98
145
  def status(self, run_id: int) -> str:
99
146
  """Get the current status of a workflow run. run_id is the ID of the run, not the workflow."""
100
- run = self.repo.get_workflow_run(run_id)
101
- return run.status # "queued", "in_progress", "completed"
147
+ return self.get_run(run_id).status # "queued", "in_progress", "completed"
102
148
 
103
149
  def conclusion(self, run_id: int) -> str | None:
104
150
  """
105
151
  Get the conclusion of a completed workflow run.
106
152
  None if still running. run_id is the ID of the run, not the workflow.
107
153
  """
108
- run = self.repo.get_workflow_run(run_id)
109
- return run.conclusion # "success", "failure", "cancelled", None if still running
110
-
111
- def wait_until_finished(self, run_id: int, check_interval: int = 10, timeout: int = 10) -> str:
112
- start_time = time.time()
113
- while (time.time() - start_time) >= timeout: # pylint: disable=W0149
114
- run = self.repo.get_workflow_run(run_id)
115
- if run.status == "completed":
154
+ return self.get_run(run_id).conclusion # "success", "failure", "cancelled", None if still running
155
+
156
+ def wait_until_finished(self, run_id: int, check_interval: float = 10, timeout: float = 600) -> str | None:
157
+ """Poll a run until it completes and return its conclusion. Raises TimeoutError after `timeout` seconds."""
158
+ if check_interval < 0 or timeout < 0:
159
+ raise ValueError("check_interval and timeout must not be negative")
160
+ deadline = time.monotonic() + timeout
161
+ while True: # pylint: disable=W0149
162
+ run = self.get_run(run_id)
163
+ if run.status == GitHubWorkflowStatus.COMPLETED.value:
116
164
  self.logger.info(f"✅ Workflow run {run_id} finished with conclusion = {run.conclusion}")
117
165
  return run.conclusion
166
+ if (remaining := deadline - time.monotonic()) <= 0:
167
+ raise TimeoutError(f"⏰ Workflow run {run_id} did not complete within {timeout} seconds.")
118
168
  self.logger.debug(f"⏳ Workflow run {run_id} still {run.status}...")
119
- time.sleep(check_interval)
120
- raise TimeoutError(f"⏰ Workflow run {run_id} did not complete within {timeout} seconds.")
169
+ time.sleep(min(check_interval, remaining))
121
170
 
122
171
 
123
- class GitHubBranchService:
124
- def __init__(self, repo: Repository) -> None:
125
- self.logger = get_logger(LOGGER_NAME)
126
- self.repo = repo
172
+ class GitHubBranchService(_RepoService):
173
+ def get(self, branch_name: str) -> Branch:
174
+ return self.repo.get_branch(branch_name)
127
175
 
128
- def create(self, branch_name: str, from_branch: str) -> GitRef:
176
+ def create(self, branch_name: str, from_branch: str | None = None) -> GitRef:
177
+ """Create a branch from another branch (the repo's default branch if not given)."""
178
+ from_branch = self._ref_or_default(from_branch)
129
179
  ref = self.repo.get_git_ref(f"heads/{from_branch}")
130
180
  new_ref = self.repo.create_git_ref(ref=f"refs/heads/{branch_name}", sha=ref.object.sha)
131
181
  self.logger.info(f"✅ Branch '{branch_name}' created from '{from_branch}'")
132
182
  return new_ref
133
183
 
134
184
  def delete(self, branch_name: str) -> None:
135
- ref = self.repo.get_git_ref(f"heads/{branch_name}")
136
- ref.delete()
185
+ _delete_ref(self.repo, f"heads/{branch_name}")
137
186
  self.logger.info(f"✅ Branch '{branch_name}' deleted")
138
187
 
139
- def list(self) -> list[Branch]:
140
- return list(self.repo.get_branches())
188
+ def list(self, limit: int | None = None) -> list[Branch]:
189
+ return _take(self.repo.get_branches(), limit)
141
190
 
142
191
  def protect(
143
192
  self,
@@ -146,70 +195,87 @@ class GitHubBranchService:
146
195
  require_pull_request_reviews: bool = True,
147
196
  dismiss_stale_reviews: bool = True,
148
197
  required_approving_review_count: int = 1,
198
+ require_code_owner_reviews: bool = False,
149
199
  require_status_checks: bool = False,
150
200
  status_check_contexts: builtins.list[str] | None = None,
201
+ strict_status_checks: bool = False,
151
202
  ) -> None:
152
203
  """
153
204
  Protect a branch with common settings.
205
+ Review settings apply only when `require_pull_request_reviews` is True,
206
+ status check settings only when `require_status_checks` is True.
154
207
  """
155
- branch = self.repo.get_branch(branch_name)
156
- branch.edit_protection(
157
- required_approving_review_count=required_approving_review_count,
158
- enforce_admins=enforce_admins,
159
- dismiss_stale_reviews=dismiss_stale_reviews,
160
- require_code_owner_reviews=require_pull_request_reviews,
161
- contexts=status_check_contexts if require_status_checks else None,
162
- )
208
+ kwargs: dict = {"enforce_admins": enforce_admins}
209
+ if require_pull_request_reviews:
210
+ kwargs |= {
211
+ "dismiss_stale_reviews": dismiss_stale_reviews,
212
+ "required_approving_review_count": required_approving_review_count,
213
+ "require_code_owner_reviews": require_code_owner_reviews,
214
+ }
215
+ if require_status_checks:
216
+ kwargs |= {"contexts": status_check_contexts or [], "strict": strict_status_checks}
217
+ self.get(branch_name).edit_protection(**kwargs)
163
218
  self.logger.info(f"✅ Branch '{branch_name}' protected")
164
219
 
165
220
  def unprotect(self, branch_name: str) -> None:
166
- branch = self.repo.get_branch(branch_name)
167
- branch.remove_protection()
221
+ self.get(branch_name).remove_protection()
168
222
  self.logger.info(f"✅ Branch '{branch_name}' unprotected")
169
223
 
170
224
 
171
- class GitHubTagService:
172
- def __init__(self, repo: Repository) -> None:
173
- self.logger = get_logger(LOGGER_NAME)
174
- self.repo = repo
175
-
225
+ class GitHubTagService(_RepoService):
176
226
  def create_from_sha(self, tag_name: str, sha: str, message: str | None = None) -> GitTag:
177
- tag = self.repo.create_git_tag(tag=tag_name, message=message or tag_name, object="main", type="commit")
227
+ """Create an annotated tag pointing at a commit."""
228
+ tag = self.repo.create_git_tag(tag=tag_name, message=message or tag_name, object=sha, type="commit")
178
229
  self.repo.create_git_ref(ref=f"refs/tags/{tag_name}", sha=tag.sha)
179
230
  self.logger.info(f"✅ Tag '{tag_name}' created at commit {sha}")
180
231
  return tag
181
232
 
182
- def create(self, tag_name: str, from_branch: str, message: str | None = None) -> GitTag:
183
- branch = self.repo.get_branch(from_branch)
184
- sha = branch.commit.sha
233
+ def create(self, tag_name: str, from_branch: str | None = None, message: str | None = None) -> GitTag:
234
+ """Tag the head of a branch (the repo's default branch if not given)."""
235
+ sha = self.repo.get_branch(self._ref_or_default(from_branch)).commit.sha
185
236
  return self.create_from_sha(tag_name=tag_name, sha=sha, message=message)
186
237
 
187
238
  def delete(self, tag_name: str) -> None:
188
- ref = self.repo.get_git_ref(f"tags/{tag_name}")
189
- ref.delete()
239
+ _delete_ref(self.repo, f"tags/{tag_name}")
190
240
  self.logger.info(f"✅ Tag '{tag_name}' deleted")
191
241
 
192
- def list(self) -> list[Tag]:
193
- return list(self.repo.get_tags())
194
-
242
+ def list(self, limit: int | None = None) -> list[Tag]:
243
+ return _take(self.repo.get_tags(), limit)
195
244
 
196
- class GitHubPRService:
197
- def __init__(self, repo: Repository) -> None:
198
- self.logger = get_logger(LOGGER_NAME)
199
- self.repo = repo
200
245
 
246
+ class GitHubPRService(_RepoService):
201
247
  def get_info(self, number: int) -> PullRequest:
202
248
  return self.repo.get_pull(number)
203
249
 
204
- def create(self, title: str, from_branch: str, target: str) -> PullRequest:
205
- pr = self.repo.create_pull(title=title, head=from_branch, base=target)
250
+ def list(
251
+ self,
252
+ state: PRState = "open",
253
+ base: str | None = None,
254
+ head: str | None = None,
255
+ limit: int | None = None,
256
+ ) -> list[PullRequest]:
257
+ """Pull requests, newest first. `head` is `owner:branch`, as the GitHub API expects."""
258
+ pulls = self.repo.get_pulls(state=state, base=base or NotSet, head=head or NotSet)
259
+ return _take(pulls, limit)
260
+
261
+ def files(self, number: int, limit: int | None = None) -> builtins.list[File]:
262
+ """Files changed by a pull request, with their patches."""
263
+ return _take(self.repo.get_pull(number).get_files(), limit)
264
+
265
+ def create(
266
+ self, title: str, from_branch: str, target: str | None = None, body: str = "", draft: bool = False
267
+ ) -> PullRequest:
268
+ """Open a pull request into `target` (the repo's default branch if not given)."""
269
+ pr = self.repo.create_pull(
270
+ title=title, head=from_branch, base=self._ref_or_default(target), body=body, draft=draft
271
+ )
206
272
  self.logger.info(f"✅ PR '{title}' created: #{pr.number}")
207
273
  return pr
208
274
 
209
- def assign(self, pr_number: int, assignees: str) -> None:
210
- issue = self.repo.get_issue(pr_number) # fetch the same PR as an issue
211
- issue.add_to_assignees(assignees) # or multiple users
212
- self.logger.info(f"✅ PR #{pr_number} assigned to {assignees}")
275
+ def assign(self, pr_number: int, assignees: str | builtins.list[str]) -> None:
276
+ names = [assignees] if isinstance(assignees, str) else assignees
277
+ self.repo.get_issue(pr_number).add_to_assignees(*names) # a PR is also an issue
278
+ self.logger.info(f"✅ PR #{pr_number} assigned to {names}")
213
279
 
214
280
  def status(self, number: int) -> str:
215
281
  pr = self.repo.get_pull(number)
@@ -217,57 +283,61 @@ class GitHubPRService:
217
283
  return GitHubPRStatus.MERGED.value
218
284
  return pr.state
219
285
 
220
- def merge(self, number: int) -> None:
221
- pr = self.repo.get_pull(number)
222
- pr.merge()
223
- self.logger.info(f"✅ PR #{number} merged.")
286
+ def merge(
287
+ self,
288
+ number: int,
289
+ merge_method: MergeMethod = "merge",
290
+ commit_title: str | None = None,
291
+ commit_message: str | None = None,
292
+ ) -> str:
293
+ """Merge a pull request and return the merge commit sha. Raises ValueError if GitHub did not merge it."""
294
+ if merge_method not in get_args(MergeMethod):
295
+ raise ValueError(f"merge_method must be one of {get_args(MergeMethod)}, got '{merge_method}'")
296
+ result = self.repo.get_pull(number).merge(
297
+ merge_method=merge_method, commit_title=commit_title or NotSet, commit_message=commit_message or NotSet
298
+ )
299
+ if not result.merged:
300
+ raise ValueError(f"❌ PR #{number} was not merged: {result.message}")
301
+ self.logger.info(f"✅ PR #{number} merged ({merge_method}).")
302
+ return result.sha
224
303
 
225
304
  def close(self, number: int) -> None:
226
- pr = self.repo.get_pull(number)
227
- pr.edit(state="closed")
305
+ self.repo.get_pull(number).edit(state="closed")
228
306
  self.logger.info(f"✅ PR #{number} closed.")
229
307
 
230
308
  def reopen(self, number: int) -> None:
231
- pr = self.repo.get_pull(number)
232
- pr.edit(state="open")
309
+ self.repo.get_pull(number).edit(state="open")
233
310
  self.logger.info(f"✅ PR #{number} reopened.")
234
311
 
235
312
  def add_comment(self, number: int, comment: str) -> None:
236
- pr = self.repo.get_pull(number)
237
- pr.create_issue_comment(comment)
313
+ self.repo.get_pull(number).create_issue_comment(comment)
238
314
  self.logger.info(f"✅ Comment added to PR #{number}.")
239
315
 
240
316
  def approve(self, number: int, body: str = "LGTM ✅") -> None:
241
- pr = self.repo.get_pull(number)
242
- pr.create_review(event="APPROVE", body=body)
317
+ self.repo.get_pull(number).create_review(event="APPROVE", body=body)
243
318
  self.logger.info(f"✅ PR #{number} approved.")
244
319
 
245
320
 
246
- class GitHubFileService:
247
- def __init__(self, repo: Repository) -> None:
248
- self.logger = get_logger(LOGGER_NAME)
249
- self.repo = repo
250
-
251
- def get(self, path: str, ref: str = "main") -> ContentFile:
252
- return self.repo.get_contents(path, ref=ref)
321
+ class GitHubFileService(_RepoService):
322
+ def get(self, path: str, ref: str | None = None) -> ContentFile | list[ContentFile]:
323
+ """A file, or a directory listing. `ref` defaults to the repo's default branch."""
324
+ return self.repo.get_contents(path, ref=ref or NotSet)
253
325
 
254
- def fetch_content(self, file_path: str, ref: str = "main") -> str:
255
- file = self.repo.get_contents(file_path, ref=ref)
256
- return file.decoded_content.decode("utf-8")
326
+ def list_dir(self, path: str = "", ref: str | None = None) -> list[ContentFile]:
327
+ contents = self.get(path, ref)
328
+ if not isinstance(contents, list):
329
+ raise ValueError(f"❌ '{path}' is a file, not a directory; use fetch_content")
330
+ return contents
257
331
 
258
- # def update(self, path: str, branch: str, content: str, message: str) -> None:
259
- # file = self.repo.get_contents(path, ref=branch)
260
- # self.repo.update_file(path, message, content, file.sha, branch=branch)
261
- # self.logger.info(f"✅ File '{path}' updated on branch '{branch}'")
262
- #
263
- # def create(self, path: str, branch: str, content: str, message: str) -> None:
264
- # self.repo.create_file(path, message, content, branch=branch)
265
- # self.logger.info(f"✅ File '{path}' created on branch '{branch}'")
266
- #
267
- # def delete(self, path: str, branch: str, message: str) -> None:
268
- # file = self.repo.get_contents(path, ref=branch)
269
- # self.repo.delete_file(path, message, file.sha, branch=branch)
270
- # self.logger.info(f"✅ File '{path}' deleted on branch '{branch}'")
332
+ def fetch_content(self, file_path: str, ref: str | None = None) -> str:
333
+ """Text content of a file, decoded as UTF-8. Files over 1 MB are read through the git blob API."""
334
+ file = self.get(file_path, ref)
335
+ if isinstance(file, list):
336
+ raise ValueError(f"❌ '{file_path}' is a directory; use list_dir")
337
+ if file.encoding == "base64":
338
+ return file.decoded_content.decode("utf-8")
339
+ blob = self.repo.get_git_blob(file.sha) # the contents API omits content for files over 1 MB
340
+ return base64.b64decode(blob.content).decode("utf-8")
271
341
 
272
342
 
273
343
  # ----------------------- #
@@ -276,12 +346,27 @@ class GitHubFileService:
276
346
 
277
347
 
278
348
  class GitHubClient:
279
- def __init__(self, repo_full_name: str, access_token: str | None = None) -> None:
349
+ """
350
+ A client for one repository. Construction makes no API calls: the repo is fetched on first use.
351
+
352
+ Use `for_repo` to work with more repositories over the same connection.
353
+ A `github` instance passed in is used as is; create it with `lazy=True` to keep construction free of API calls.
354
+ """
355
+
356
+ def __init__(
357
+ self,
358
+ repo_full_name: str,
359
+ access_token: str | None = None,
360
+ base_url: str | None = None,
361
+ github: Github | None = None,
362
+ ) -> None:
280
363
  self.logger = get_logger(LOGGER_NAME)
281
- self.github_access_token = access_token or os.environ.get("GITHUB_ACCESS_TOKEN")
364
+ owner, _, name = repo_full_name.partition("/")
365
+ if not owner or not name or "/" in name:
366
+ raise ValueError(f"repo_full_name must be 'owner/name', got '{repo_full_name}'")
282
367
 
283
- self.github = Github(auth=Auth.Token(self.github_access_token))
284
- self.is_connected(raise_if_not_connected=True)
368
+ self.repo_full_name = repo_full_name
369
+ self.github = github or self._connect(access_token, base_url)
285
370
 
286
371
  repo = self.github.get_repo(repo_full_name)
287
372
  self.project = GitHubProjectService(repo)
@@ -291,6 +376,22 @@ class GitHubClient:
291
376
  self.pull_request = GitHubPRService(repo)
292
377
  self.file = GitHubFileService(repo)
293
378
 
379
+ @staticmethod
380
+ def _connect(access_token: str | None, base_url: str | None) -> Github:
381
+ if not access_token or not base_url:
382
+ load_dotenv()
383
+ if not (token := access_token or os.environ.get("GITHUB_ACCESS_TOKEN")):
384
+ raise ValueError("A GitHub token is required: pass access_token or set GITHUB_ACCESS_TOKEN")
385
+ url = base_url or os.environ.get("GITHUB_BASE_URL") or DEFAULT_BASE_URL
386
+ if "://" not in url:
387
+ url = f"https://{url}"
388
+ # lazy: objects (the repo included) are fetched on first use, and every repo shares this requester
389
+ return Github(auth=Auth.Token(token), base_url=url.rstrip("/"), lazy=True)
390
+
391
+ def for_repo(self, repo_full_name: str) -> "GitHubClient":
392
+ """A client for another repository, sharing this client's connection."""
393
+ return GitHubClient(repo_full_name, github=self.github)
394
+
294
395
  def is_connected(self, raise_if_not_connected: bool = False) -> bool:
295
396
  try:
296
397
  user = self.github.get_user().login
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-github-plus
3
+ Version: 2.0.0
4
+ Summary: Enhanced Python GitHub client.
5
+ Author: Avi Zaguri
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/aviz92/python-github-plus
8
+ Project-URL: Repository, https://github.com/aviz92/python-github-plus
9
+ Project-URL: Issues, https://github.com/aviz92/python-github-plus/issues
10
+ Keywords: development,github,client,api,python
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: custom-python-logger>=4.0.0
20
+ Requires-Dist: pygithub>=2.10.0
21
+ Requires-Dist: python-dotenv>=1.2.1
22
+ Dynamic: license-file
23
+
24
+ ![PyPI version](https://img.shields.io/pypi/v/python-github-plus)
25
+ ![Python](https://img.shields.io/badge/python->=3.12-blue)
26
+ ![Development Status](https://img.shields.io/badge/status-stable-green)
27
+ ![Maintenance](https://img.shields.io/maintenance/yes/2026)
28
+ ![PyPI](https://img.shields.io/pypi/dm/python-github-plus)
29
+ ![License](https://img.shields.io/pypi/l/python-github-plus)
30
+
31
+ ---
32
+
33
+ # Python GitHub Plus
34
+ An enhanced Python client for GitHub built on [PyGithub](https://github.com/PyGithub/PyGithub): one object per repository,
35
+ grouped services, sensible defaults and errors you can act on.
36
+
37
+ ---
38
+
39
+ ## Features
40
+ - ✅ **Service-based architecture**: `project`, `branch`, `pull_request`, `workflow`, `tag`, `file`
41
+ - ✅ **Lazy connection**: creating a client makes no API calls; one connection serves many repositories (`for_repo`)
42
+ - ✅ **GitHub.com and GitHub Enterprise Server** (`base_url`)
43
+ - ✅ **Errors are raised, not logged and swallowed**: `GithubException` from the API, `ValueError` for bad input
44
+ - ✅ **Default branch aware**: branch, tag, PR, workflow and file calls default to the repo's real default branch
45
+ - ✅ **Bounded listing**: every list method takes `limit` and stops paging once it is reached
46
+ - ✅ **Pull requests**: list, create (body, draft), files, assign, comment, approve, merge (merge/squash/rebase), close
47
+ - ✅ **Workflows**: find by name, file name or id, trigger and get the run back, wait for completion
48
+ - ✅ **Branches and tags**: create, delete, list, protect / unprotect
49
+ - ✅ **Files**: read text files (including files over 1 MB) and list directories
50
+
51
+ ---
52
+
53
+ ## Installation
54
+ ```bash
55
+ pip install python-github-plus
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Configuration
61
+ Pass the token directly, or set environment variables. A `.env` file is loaded only when an argument is missing.
62
+
63
+ ```bash
64
+ GITHUB_ACCESS_TOKEN=your_github_access_token
65
+ GITHUB_BASE_URL=https://github.example.com/api/v3 # optional, GitHub Enterprise Server only
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Examples
71
+
72
+ ### Basic Setup and Connection
73
+ ```python
74
+ from python_github_plus import GitHubClient
75
+
76
+ github_client = GitHubClient(
77
+ repo_full_name="your-repo-owner/your-repo-name",
78
+ access_token="your_access_token", # or GITHUB_ACCESS_TOKEN
79
+ # base_url="https://github.example.com/api/v3", # GitHub Enterprise Server
80
+ )
81
+
82
+ # Creating the client makes no API calls; check the token explicitly if you want to fail fast
83
+ github_client.is_connected(raise_if_not_connected=True)
84
+
85
+ # Another repository over the same connection
86
+ other_repo = github_client.for_repo("your-repo-owner/another-repo")
87
+
88
+ # Services
89
+ github_client.project
90
+ github_client.branch
91
+ github_client.pull_request
92
+ github_client.workflow
93
+ github_client.tag
94
+ github_client.file
95
+ ```
96
+
97
+ ### Branch Management
98
+ ```python
99
+ # Create a branch from the default branch (or pass from_branch="develop")
100
+ branch_ref = github_client.branch.create(branch_name="feature/new-feature")
101
+ print(f"Created branch: {branch_ref.ref}")
102
+
103
+ # List the first 50 branches
104
+ for branch in github_client.branch.list(limit=50):
105
+ print(f"Branch: {branch.name}")
106
+
107
+ # Protect a branch: 2 approvals, code owners, required CI check
108
+ github_client.branch.protect(
109
+ "main",
110
+ required_approving_review_count=2,
111
+ require_code_owner_reviews=True,
112
+ require_status_checks=True,
113
+ status_check_contexts=["CI"],
114
+ )
115
+
116
+ # Delete a branch
117
+ github_client.branch.delete("feature/old-feature")
118
+ ```
119
+
120
+ ### Pull Request Management
121
+ ```python
122
+ # Open a draft PR into the default branch
123
+ pr = github_client.pull_request.create(
124
+ title="Add new feature",
125
+ from_branch="feature/new-feature",
126
+ body="What changed and why",
127
+ draft=True,
128
+ )
129
+ print(f"Created PR: #{pr.number}")
130
+
131
+ # List open PRs into main, and the files a PR changes
132
+ for open_pr in github_client.pull_request.list(state="open", base="main", limit=20):
133
+ print(f"#{open_pr.number} {open_pr.title}")
134
+ for changed in github_client.pull_request.files(pr.number):
135
+ print(f"{changed.status}: {changed.filename} (+{changed.additions}/-{changed.deletions})")
136
+
137
+ github_client.pull_request.assign(pr.number, ["username"])
138
+ github_client.pull_request.add_comment(pr.number, "Great work!")
139
+ github_client.pull_request.approve(pr.number)
140
+
141
+ # Merge; raises ValueError if GitHub did not merge it
142
+ merge_sha = github_client.pull_request.merge(pr.number, merge_method="squash")
143
+ ```
144
+
145
+ ### Workflow Operations
146
+ ```python
147
+ # Find a workflow by display name, file name or id, and trigger it on the default branch
148
+ run = github_client.workflow.trigger("ci.yml", inputs={"debug": "true"})
149
+
150
+ # github.com returns the new run; on servers that don't, take the newest run instead
151
+ if run is None:
152
+ run = github_client.workflow.list_runs("ci.yml", limit=1)[0]
153
+
154
+ print(f"Run {run.id}: {github_client.workflow.status(run.id)}")
155
+
156
+ # Poll until the run completes (raises TimeoutError after `timeout` seconds)
157
+ conclusion = github_client.workflow.wait_until_finished(run_id=run.id, check_interval=30, timeout=3600)
158
+ print(f"Workflow run finished: {conclusion}")
159
+ ```
160
+
161
+ ### File Operations
162
+ ```python
163
+ # Read a file from the default branch (or pass ref="v1.0.0")
164
+ content = github_client.file.fetch_content("README.md")
165
+ print(content[:100])
166
+
167
+ # List a directory
168
+ for entry in github_client.file.list_dir("python_github_plus"):
169
+ print(f"{entry.type}: {entry.path}")
170
+ ```
171
+
172
+ ### Tag Management
173
+ ```python
174
+ # Annotated tag on the head of the default branch, or on a specific commit
175
+ tag = github_client.tag.create(tag_name="v1.0.0", message="Release version 1.0.0")
176
+ github_client.tag.create_from_sha(tag_name="v1.0.1", sha="abc123...", message="Hotfix")
177
+
178
+ for repo_tag in github_client.tag.list(limit=10):
179
+ print(f"Tag: {repo_tag.name}")
180
+
181
+ github_client.tag.delete("v0.9.0")
182
+ ```
183
+
184
+ ### Project Management
185
+ ```python
186
+ project_info = github_client.project.get_info()
187
+ print(f"Project: {project_info.full_name} (default branch: {project_info.default_branch})")
188
+
189
+ for member in github_client.project.list_members(limit=100):
190
+ print(f"Member: {member.login}")
191
+
192
+ github_client.project.add_member("newuser", permission="push") # pull, triage, push, maintain or admin
193
+ github_client.project.remove_member("olduser")
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Upgrading from 1.x
199
+ See [CHANGELOG.md](CHANGELOG.md). In short: errors now raise, defaults follow the repo's default branch,
200
+ `workflow.trigger` returns the run, and `pull_request.merge` returns the merge commit sha.
201
+
202
+ ---
203
+
204
+ ## 🤝 Contributing
205
+ If you have a helpful tool, pattern, or improvement to suggest:
206
+ Fork the repo <br>
207
+ Create a new branch <br>
208
+ Submit a pull request <br>
209
+ I welcome additions that promote clean, productive, and maintainable development. <br>
210
+
211
+ ---
212
+
213
+ ## 🙏 Thanks
214
+ Thanks for exploring this repository! <br>
215
+ Happy coding! <br>
@@ -0,0 +1,8 @@
1
+ python_github_plus/__init__.py,sha256=srIfJYUg3QU_utYCy4Pm3WqWvApSP3SGQ2wRqQK1Yvw,164
2
+ python_github_plus/consts.py,sha256=eCsgLF2Wo-l7REjNBPU6P9SvKTBAv4bfgzCp0oVS4qQ,202
3
+ python_github_plus/github_plus.py,sha256=n9NHY3l7Zqq51vANKaszASpZi2UQjU3u-RB8i5gbUR8,17597
4
+ python_github_plus-2.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
5
+ python_github_plus-2.0.0.dist-info/METADATA,sha256=N94vEl1duoOqcipsHQYpxLpVoEKwk5sgZIu8Su9USsQ,7512
6
+ python_github_plus-2.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ python_github_plus-2.0.0.dist-info/top_level.txt,sha256=lm_rQAamcpxcN00h3Thamd05_z5PdTfy-RVSuCpN_xA,19
8
+ python_github_plus-2.0.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,266 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-github-plus
3
- Version: 1.1.1
4
- Summary: Enhanced Python GitHub client.
5
- Author: Avi Zaguri
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/aviz92/python-github-plus
8
- Project-URL: Repository, https://github.com/aviz92/python-github-plus
9
- Project-URL: Issues, https://github.com/aviz92/python-github-plus/issues
10
- Keywords: development,github,client,api,python
11
- Classifier: Development Status :: 5 - Production/Stable
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
- Requires-Python: >=3.12
17
- Description-Content-Type: text/markdown
18
- License-File: LICENSE
19
- Requires-Dist: custom-python-logger>=4.0.0
20
- Requires-Dist: pre-commit>=4.5.0
21
- Requires-Dist: pygithub>=2.8.1
22
- Requires-Dist: pytest>=9.0.1
23
- Requires-Dist: python-dotenv>=1.2.1
24
- Requires-Dist: setuptools>=80.9.0
25
- Requires-Dist: wheel>=0.45.1
26
- Dynamic: license-file
27
-
28
- ![PyPI version](https://img.shields.io/pypi/v/python-github-plus)
29
- ![Python](https://img.shields.io/badge/python->=3.12-blue)
30
- ![Development Status](https://img.shields.io/badge/status-stable-green)
31
- ![Maintenance](https://img.shields.io/maintenance/yes/2026)
32
- ![PyPI](https://img.shields.io/pypi/dm/python-github-plus)
33
- ![License](https://img.shields.io/pypi/l/python-github-plus)
34
-
35
- ---
36
-
37
- # Python GitHub Plus
38
- An enhanced Python client for GitHub that extends the functionality of the official `pygithub` package, providing better error handling, merge request management, branch operations, and more.
39
-
40
- ---
41
-
42
- ## Features
43
- - ✅ Simplified connection to GitHub instances
44
- - ✅ Robust error handling with comprehensive logging
45
- - ✅ **Service-based architecture** for organized functionality
46
- - ✅ **Project management** (members, info, etc.)
47
- - ✅ **Branch operations** (create, delete, protect, list, etc.)
48
- - ✅ **Pull Request management** (create, merge, approve, assign, comment, etc.)
49
- - ✅ **Workflow operations** (trigger, monitor, wait for completion, etc.)
50
- - ✅ **Tag management** (create, delete, list, etc.)
51
- - ✅ **File operations** (read, create, update, delete, etc.)
52
-
53
- ---
54
-
55
- ## Installation
56
- ```bash
57
- pip install python-github-plus
58
- ```
59
-
60
- ---
61
-
62
- ## Configuration
63
- The package uses environment variables for authentication and configuration:
64
-
65
- ```bash
66
- # Required environment variables
67
- GitHub_ACCESS_TOKEN=your_github_access_token
68
- GitHub_URL=https://github.com # Your GitHub instance URL (default: github.com)
69
- ```
70
-
71
- ## Examples
72
-
73
- ### Basic Setup and Connection
74
- ```python
75
- from python_github_plus import GitHubClient
76
-
77
- # Initialize GitLab client with service-based architecture
78
- github_client = GitHubClient(
79
- access_token="your_access_token", # Note: parameter name changed
80
- repo_full_name="your-repo-owner/your-repo-name",
81
- )
82
-
83
- # Access different services
84
- project_service = github_client.project
85
- branch_service = github_client.branch
86
- pr_service = github_client.pull_request
87
- workflows_service = github_client.workflows
88
- tag_service = github_client.tag
89
- file_service = github_client.file
90
- ```
91
-
92
- ### Branch Management
93
- ```python
94
- from python_github_plus import GitHubClient
95
-
96
- github_client = GitHubClient(
97
- access_token="your_access_token", # Note: parameter name changed
98
- repo_full_name="your-repo-owner/your-repo-name",
99
- )
100
-
101
- # Create a new branch
102
- branch = github_client.branch.create(
103
- branch_name="feature/new-feature",
104
- from_branch="main"
105
- )
106
- print(f"Created branch: {branch.ref}")
107
-
108
- # List branches
109
- branches = github_client.branch.list()
110
- for branch in branches:
111
- print(f"Branch: {branch.name}")
112
-
113
- # Protect a branch
114
- github_client.branch.protect("main")
115
-
116
- # Delete a branch
117
- github_client.branch.delete("feature/old-feature")
118
- ```
119
-
120
- ### Pull Request Management
121
- ```python
122
- from python_github_plus import GitHubClient
123
-
124
- github_client = GitHubClient(
125
- access_token="your_access_token", # Note: parameter name changed
126
- repo_full_name="your-repo-owner/your-repo-name",
127
- )
128
-
129
- # Create a pull request
130
- pr = github_client.pull_request.create(
131
- title="Add new feature",
132
- from_branch="feature/new-feature",
133
- target="main"
134
- )
135
- print(f"Created PR: !{pr.number}")
136
-
137
- # Assign PR to a user
138
- github_client.pull_request.assign(pr.number, "username")
139
-
140
- # Add a comment
141
- github_client.pull_request.add_comment(pr.number, "Great work!")
142
-
143
- # Approve the PR
144
- github_client.pull_request.approve(pr.number)
145
-
146
- # Merge the PR
147
- github_client.pull_request.merge(pr.number)
148
- ```
149
-
150
- ### Workflow Operations
151
-
152
- ```python
153
- import time
154
- from python_github_plus import GitHubClient
155
-
156
- github_client = GitHubClient(
157
- access_token="your_access_token", # Note: parameter name changed
158
- repo_full_name="your-repo-owner/your-repo-name",
159
- )
160
-
161
- # Trigger a workflows
162
- workflows = github_client.workflow.trigger(
163
- workflow_name="CI",
164
- branch_name="main",
165
- )
166
- print(f"Workflow triggered: {workflows.id}")
167
-
168
- time.sleep(5) # wait a bit for the workflow to register
169
- run_id = github_client.workflow.last_run_by_id(workflow_id=workflows.id)
170
-
171
- # Check workflow run status
172
- status = github_client.workflow.status(run_id=run_id.id)
173
- print(f"Workflow run status: {status}")
174
-
175
- # Wait for workflows completion
176
- final_status = github_client.workflow.wait_until_finished(
177
- run_id=run_id.id,
178
- check_interval=30,
179
- timeout=3600
180
- )
181
- print(f"Workflow run completed with status: {final_status}")
182
- ```
183
-
184
- ### File Operations
185
- ```python
186
- from python_github_plus import GitHubClient
187
-
188
- github_client = GitHubClient(
189
- access_token="your_access_token", # Note: parameter name changed
190
- repo_full_name="your-repo-owner/your-repo-name",
191
- )
192
-
193
- # Read file content
194
- file_content = github_client.file.fetch_content(
195
- file_path="README.md",
196
- ref="main"
197
- )
198
- print(f"File content: {file_content[:100]}...")
199
- ```
200
-
201
- ### Tag Management
202
- ```python
203
- from python_github_plus import GitHubClient
204
-
205
- github_client = GitHubClient(
206
- access_token="your_access_token", # Note: parameter name changed
207
- repo_full_name="your-repo-owner/your-repo-name",
208
- )
209
-
210
- # Create a tag
211
- tag = github_client.tag.create(
212
- tag_name="v1.0.0",
213
- from_branch="main",
214
- message="Release version 1.0.0"
215
- )
216
- print(f"Created tag: {tag.name}")
217
-
218
- # List tags
219
- tags = github_client.tag.list()
220
- for tag in tags:
221
- print(f"Tag: {tag.name}")
222
-
223
- # Delete a tag
224
- github_client.tag.delete("v0.9.0")
225
- ```
226
-
227
- ### Project Management
228
- ```python
229
- from python_github_plus import GitHubClient
230
-
231
- github_client = GitHubClient(
232
- access_token="your_access_token", # Note: parameter name changed
233
- repo_full_name="your-repo-owner/your-repo-name",
234
- )
235
-
236
- # Get project information
237
- project_info = github_client.project.get_info()
238
- print(f"Project: {project_info.name}")
239
- print(f"Description: {project_info.description}")
240
-
241
- # List project members
242
- members = github_client.project.list_members()
243
- for member in members:
244
- print(f"Member: {member.username}")
245
-
246
- # Add a member
247
- github_client.project.add_member("newuser", 30) # 30 = Developer access level
248
-
249
- # Remove a member
250
- github_client.project.remove_member("olduser")
251
- ```
252
-
253
- ---
254
-
255
- ## 🤝 Contributing
256
- If you have a helpful tool, pattern, or improvement to suggest:
257
- Fork the repo <br>
258
- Create a new branch <br>
259
- Submit a pull request <br>
260
- I welcome additions that promote clean, productive, and maintainable development. <br>
261
-
262
- ---
263
-
264
- ## 🙏 Thanks
265
- Thanks for exploring this repository! <br>
266
- Happy coding! <br>
@@ -1,8 +0,0 @@
1
- python_github_plus/__init__.py,sha256=X9i7u5Or-dmMMWc2R9Vq982MjK-BGpPbvSs3yL7i_ew,211
2
- python_github_plus/consts.py,sha256=SvRLMrYP-bH7ywxU-9YUPDNjMdcP2FUBtzVLigEJZBM,35
3
- python_github_plus/github_plus.py,sha256=x9dzJFLSlcPQGRcwJRn3zRozIno3VD1mknVvWCO1dIg,12096
4
- python_github_plus-1.1.1.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
5
- python_github_plus-1.1.1.dist-info/METADATA,sha256=LG2mEuN5ak4zTLXttyvvOY4gcCyKnoKZmt_bbZS6BYE,7311
6
- python_github_plus-1.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
- python_github_plus-1.1.1.dist-info/top_level.txt,sha256=lm_rQAamcpxcN00h3Thamd05_z5PdTfy-RVSuCpN_xA,19
8
- python_github_plus-1.1.1.dist-info/RECORD,,