ghapi 2.1.3__tar.gz → 2.1.5__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.
- {ghapi-2.1.3/ghapi.egg-info → ghapi-2.1.5}/PKG-INFO +7 -6
- {ghapi-2.1.3 → ghapi-2.1.5}/README.md +1 -1
- ghapi-2.1.5/ghapi/__init__.py +8 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/_modidx.py +5 -7
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/core.py +124 -69
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/gh_spec.py +972 -77
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/gql_spec.py +44 -36
- ghapi-2.1.5/ghapi/graphql.py +47 -0
- ghapi-2.1.5/ghapi/skill.py +75 -0
- {ghapi-2.1.3 → ghapi-2.1.5/ghapi.egg-info}/PKG-INFO +7 -6
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/requires.txt +4 -3
- {ghapi-2.1.3 → ghapi-2.1.5}/pyproject.toml +5 -5
- ghapi-2.1.3/ghapi/__init__.py +0 -8
- ghapi-2.1.3/ghapi/graphql.py +0 -39
- ghapi-2.1.3/ghapi/skill.py +0 -136
- {ghapi-2.1.3 → ghapi-2.1.5}/CONTRIBUTING.md +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/LICENSE +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/MANIFEST.in +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/actions.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/all.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/auth.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/build_lib.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/cli.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/event.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/page.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/py.typed +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi/templates.py +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/SOURCES.txt +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/dependency_links.txt +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/entry_points.txt +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/ghapi.egg-info/top_level.txt +0 -0
- {ghapi-2.1.3 → ghapi-2.1.5}/setup.cfg +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghapi
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.5
|
|
4
4
|
Summary: A python client for the GitHub API
|
|
5
5
|
Author-email: Jeremy Howard <info@fast.ai>
|
|
6
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Repository, https://github.com/fastai/ghapi
|
|
8
8
|
Project-URL: Documentation, https://ghapi.fast.ai/
|
|
9
9
|
Keywords: github,api
|
|
@@ -15,8 +15,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: fastcore>=2.2.
|
|
19
|
-
Requires-Dist: fastspec>=0.2.
|
|
18
|
+
Requires-Dist: fastcore>=2.2.23
|
|
19
|
+
Requires-Dist: fastspec>=0.2.5
|
|
20
20
|
Requires-Dist: fasttransport>=0.0.2
|
|
21
21
|
Requires-Dist: tomli; python_version < "3.11"
|
|
22
22
|
Provides-Extra: dev
|
|
@@ -27,7 +27,8 @@ Requires-Dist: nbdev; extra == "dev"
|
|
|
27
27
|
Requires-Dist: pyyaml; extra == "dev"
|
|
28
28
|
Requires-Dist: pysym2md>=0.0.6; extra == "dev"
|
|
29
29
|
Requires-Dist: llms-txt>=0.0.6; extra == "dev"
|
|
30
|
-
Requires-Dist: pyskills; extra == "dev"
|
|
30
|
+
Requires-Dist: pyskills>=0.0.30; extra == "dev"
|
|
31
|
+
Requires-Dist: pycachy>=0.0.21; extra == "dev"
|
|
31
32
|
Dynamic: license-file
|
|
32
33
|
|
|
33
34
|
# ghapi
|
|
@@ -154,7 +155,7 @@ api.git
|
|
|
154
155
|
- [git.create_tree](https://docs.github.com/rest/git/trees#create-a-tree)(owner, repo, tree, base_tree): *Create a tree*
|
|
155
156
|
- [git.get_tree](https://docs.github.com/rest/git/trees#get-a-tree)(owner, repo, tree_sha, recursive): *Get a tree*
|
|
156
157
|
|
|
157
|
-
|
|
158
|
+
Read an endpoint’s full documentation on the generated object. Its display includes parameter descriptions, bound defaults, request controls, and async usage. With pyskills, use `doc(api.git.get_ref)` for the same documentation, or `xdir(api.git, 'ref')` to search names. Construction and discovery send no requests:
|
|
158
159
|
|
|
159
160
|
``` python
|
|
160
161
|
api.git.get_ref
|
|
@@ -122,7 +122,7 @@ api.git
|
|
|
122
122
|
- [git.create_tree](https://docs.github.com/rest/git/trees#create-a-tree)(owner, repo, tree, base_tree): *Create a tree*
|
|
123
123
|
- [git.get_tree](https://docs.github.com/rest/git/trees#get-a-tree)(owner, repo, tree_sha, recursive): *Get a tree*
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
Read an endpoint’s full documentation on the generated object. Its display includes parameter descriptions, bound defaults, request controls, and async usage. With pyskills, use `doc(api.git.get_ref)` for the same documentation, or `xdir(api.git, 'ref')` to search names. Construction and discovery send no requests:
|
|
126
126
|
|
|
127
127
|
``` python
|
|
128
128
|
api.git.get_ref
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""A python client for the GitHub API
|
|
2
|
+
|
|
3
|
+
Modules:
|
|
4
|
+
|
|
5
|
+
- `ghapi.graphql`: Query GitHub's GraphQL API with schema-aware chaining, batched requests and raw GraphQL
|
|
6
|
+
- `ghapi.skill`: GitHub REST and GraphQL access, plus local git operations. Read before working with GitHub issues, PRs, CI, releases, branches, or gists, and before repo-local git operations."""
|
|
7
|
+
|
|
8
|
+
__version__ = "2.1.5"
|
|
@@ -22,7 +22,6 @@ d = { 'settings': { 'branch': 'main',
|
|
|
22
22
|
'ghapi.actions.github_token': ('actions.html#github_token', 'ghapi/actions.py'),
|
|
23
23
|
'ghapi.actions.set_git_user': ('actions.html#set_git_user', 'ghapi/actions.py'),
|
|
24
24
|
'ghapi.actions.user_repo': ('actions.html#user_repo', 'ghapi/actions.py')},
|
|
25
|
-
'ghapi.all': {},
|
|
26
25
|
'ghapi.auth': { 'ghapi.auth.GhDeviceAuth': ('auth.html#ghdeviceauth', 'ghapi/auth.py'),
|
|
27
26
|
'ghapi.auth.GhDeviceAuth.__init__': ('auth.html#ghdeviceauth.__init__', 'ghapi/auth.py'),
|
|
28
27
|
'ghapi.auth.GhDeviceAuth._getattr': ('auth.html#ghdeviceauth._getattr', 'ghapi/auth.py'),
|
|
@@ -90,14 +89,17 @@ d = { 'settings': { 'branch': 'main',
|
|
|
90
89
|
'ghapi.core._age': ('core.html#_age', 'ghapi/core.py'),
|
|
91
90
|
'ghapi.core._dur': ('core.html#_dur', 'ghapi/core.py'),
|
|
92
91
|
'ghapi.core._filter_diff': ('core.html#_filter_diff', 'ghapi/core.py'),
|
|
93
|
-
'ghapi.core._find_matches': ('core.html#_find_matches', 'ghapi/core.py'),
|
|
94
92
|
'ghapi.core._fmt_replies': ('core.html#_fmt_replies', 'ghapi/core.py'),
|
|
93
|
+
'ghapi.core._fold_groups': ('core.html#_fold_groups', 'ghapi/core.py'),
|
|
95
94
|
'ghapi.core._include': ('core.html#_include', 'ghapi/core.py'),
|
|
96
95
|
'ghapi.core._listed': ('core.html#_listed', 'ghapi/core.py'),
|
|
96
|
+
'ghapi.core._load_tmpl': ('core.html#_load_tmpl', 'ghapi/core.py'),
|
|
97
|
+
'ghapi.core._parse_section': ('core.html#_parse_section', 'ghapi/core.py'),
|
|
97
98
|
'ghapi.core._parse_tmpl': ('core.html#_parse_tmpl', 'ghapi/core.py'),
|
|
98
99
|
'ghapi.core._pr_mark': ('core.html#_pr_mark', 'ghapi/core.py'),
|
|
99
100
|
'ghapi.core._reduce_ctx': ('core.html#_reduce_ctx', 'ghapi/core.py'),
|
|
100
101
|
'ghapi.core._run_subp': ('core.html#_run_subp', 'ghapi/core.py'),
|
|
102
|
+
'ghapi.core._step_lines': ('core.html#_step_lines', 'ghapi/core.py'),
|
|
101
103
|
'ghapi.core._transdeps': ('core.html#_transdeps', 'ghapi/core.py'),
|
|
102
104
|
'ghapi.core.call_gh': ('core.html#call_gh', 'ghapi/core.py'),
|
|
103
105
|
'ghapi.core.check_status': ('core.html#check_status', 'ghapi/core.py'),
|
|
@@ -152,8 +154,6 @@ d = { 'settings': { 'branch': 'main',
|
|
|
152
154
|
'ghapi.event._want_evt': ('event.html#_want_evt', 'ghapi/event.py'),
|
|
153
155
|
'ghapi.event.load_sample_events': ('event.html#load_sample_events', 'ghapi/event.py'),
|
|
154
156
|
'ghapi.event.save_sample_events': ('event.html#save_sample_events', 'ghapi/event.py')},
|
|
155
|
-
'ghapi.gh_spec': {},
|
|
156
|
-
'ghapi.gql_spec': {},
|
|
157
157
|
'ghapi.graphql': { 'ghapi.graphql.GhGql': ('graphql.html#ghgql', 'ghapi/graphql.py'),
|
|
158
158
|
'ghapi.graphql.GhGql.__init__': ('graphql.html#ghgql.__init__', 'ghapi/graphql.py'),
|
|
159
159
|
'ghapi.graphql.GhGql.repo': ('graphql.html#ghgql.repo', 'ghapi/graphql.py')},
|
|
@@ -174,6 +174,4 @@ d = { 'settings': { 'branch': 'main',
|
|
|
174
174
|
'ghapi.page.pages': ('page.html#pages', 'ghapi/page.py'),
|
|
175
175
|
'ghapi.page.parse_link_hdr': ('page.html#parse_link_hdr', 'ghapi/page.py'),
|
|
176
176
|
'ghapi.page.sync_paged': ('page.html#sync_paged', 'ghapi/page.py'),
|
|
177
|
-
'ghapi.page.sync_pages': ('page.html#sync_pages', 'ghapi/page.py')}
|
|
178
|
-
'ghapi.skill': {},
|
|
179
|
-
'ghapi.templates': {}}}
|
|
177
|
+
'ghapi.page.sync_pages': ('page.html#sync_pages', 'ghapi/page.py')}}}
|
|
@@ -5,8 +5,8 @@ Docs: https://ghapi.fast.ai/core.html.md"""
|
|
|
5
5
|
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
|
|
6
6
|
|
|
7
7
|
# %% auto #0
|
|
8
|
-
__all__ = ['GH_HOST', 'pspec', 'img_md_pat', 'EMPTY_TREE_SHA', 'MAXLEN', '
|
|
9
|
-
'gh_patch', 'GhApi', 'call_gh', 'date2gh', 'gh2date', 'GhRows', '
|
|
8
|
+
__all__ = ['GH_HOST', 'pspec', 'img_md_pat', 'EMPTY_TREE_SHA', 'MAXLEN', 'print_summary', 'GhTransport', 'GhSyncTransport',
|
|
9
|
+
'gh_patch', 'GhApi', 'call_gh', 'date2gh', 'gh2date', 'GhRows', 'create_release', 'delete_release',
|
|
10
10
|
'list_tags', 'list_branches', 'create_branch_empty', 'delete_tag', 'delete_branch', 'get_branch',
|
|
11
11
|
'commit_tree', 'list_files', 'get_content', 'create_or_update_file', 'create_file', 'delete_file',
|
|
12
12
|
'update_contents', 'enable_pages', 'read_issue', 'read_pr', 'pr_file_diff', 'PullRow', 'PullRows',
|
|
@@ -42,9 +42,17 @@ _all_ = ['APIError']
|
|
|
42
42
|
GH_HOST = os.getenv('GH_HOST', "https://api.github.com")
|
|
43
43
|
_DOC_URL = 'https://docs.github.com/'
|
|
44
44
|
|
|
45
|
+
_docroot = 'https://docs.github.com/rest/reference/'
|
|
46
|
+
|
|
45
47
|
# %% ../nbs/00_core.ipynb #ba730c65
|
|
46
48
|
pspec = SpecParser.from_dict(spec)
|
|
47
49
|
|
|
50
|
+
# %% ../nbs/00_core.ipynb #f361159b
|
|
51
|
+
def print_summary(method, url, kwargs):
|
|
52
|
+
"Debug callback for `GhApi(debug=...)`: print each request with the token (if any) removed"
|
|
53
|
+
hdrs = {k:v for k,v in (kwargs.get('headers') or {}).items() if k.lower()!='authorization'}
|
|
54
|
+
print(method, url, {**kwargs, 'headers':hdrs})
|
|
55
|
+
|
|
48
56
|
# %% ../nbs/00_core.ipynb #d4a51c1a
|
|
49
57
|
_binary_cts = ('octet-stream', 'zip', 'gzip', 'tar', 'image/', 'audio/', 'video/')
|
|
50
58
|
|
|
@@ -89,15 +97,6 @@ class GhSyncTransport(SyncTransport, GhTransport):
|
|
|
89
97
|
self._pre(method, url, kwargs)
|
|
90
98
|
return self._post(SyncTransport.request(self, method, url, raw=True, **kwargs), raw)
|
|
91
99
|
|
|
92
|
-
# %% ../nbs/00_core.ipynb #531aee7d
|
|
93
|
-
_docroot = 'https://docs.github.com/rest/reference/'
|
|
94
|
-
|
|
95
|
-
# %% ../nbs/00_core.ipynb #f361159b
|
|
96
|
-
def print_summary(method, url, kwargs):
|
|
97
|
-
"Debug callback for `GhApi(debug=...)`: print each request with the token (if any) removed"
|
|
98
|
-
hdrs = {k:v for k,v in (kwargs.get('headers') or {}).items() if k.lower()!='authorization'}
|
|
99
|
-
print(method, url, {**kwargs, 'headers':hdrs})
|
|
100
|
-
|
|
101
100
|
# %% ../nbs/00_core.ipynb #cecd0177
|
|
102
101
|
_gh_override = ContextVar('_gh_override', default={})
|
|
103
102
|
|
|
@@ -120,8 +119,20 @@ def gh_patch(fn):
|
|
|
120
119
|
# %% ../nbs/00_core.ipynb #83e8a9ce
|
|
121
120
|
class GhApi(OpenAPIClient):
|
|
122
121
|
"GitHub API client. Endpoint groups (`issues`, `pulls`, ...) are generated per-instance from GitHub's OpenAPI metadata, so the class shows only convenience methods -- inspect a live instance, e.g. `doc(GhApi())`, to see the full API."
|
|
123
|
-
def __init__(self,
|
|
124
|
-
|
|
122
|
+
def __init__(self,
|
|
123
|
+
owner=None, # Default repository owner; endpoint calls can override it
|
|
124
|
+
repo=None, # Default repository name; endpoint calls can override it
|
|
125
|
+
token=None, # Personal access token; otherwise GITHUB_TOKEN
|
|
126
|
+
jwt_token=None, # JWT; otherwise GITHUB_JWT_TOKEN; takes precedence over token
|
|
127
|
+
debug=None, # Request callback (method, url, kwargs); print_summary omits authorization
|
|
128
|
+
limit_cb=None, # Callback (remaining, quota) when rate-limit headers change
|
|
129
|
+
gh_host=None, # API base URL; otherwise GH_HOST
|
|
130
|
+
authenticate=True, # False skips credentials; missing credentials otherwise warn and remain unauthenticated
|
|
131
|
+
timeout=60.0, # Request timeout in seconds
|
|
132
|
+
sync=False, # Generated endpoints block instead of returning awaitables; async convenience methods still need an async client
|
|
133
|
+
**kwargs # Additional default route/query parameters
|
|
134
|
+
):
|
|
135
|
+
"Construct the generated API locally; no request is made until an operation is called"
|
|
125
136
|
self.headers = { 'Accept': 'application/vnd.github.v3+json' }
|
|
126
137
|
if authenticate:
|
|
127
138
|
token = token or os.getenv('GITHUB_TOKEN', None)
|
|
@@ -139,14 +150,6 @@ class GhApi(OpenAPIClient):
|
|
|
139
150
|
self.groups = mk_groups(self.ops)
|
|
140
151
|
for k,v in self.groups.items(): setattr(self, k, v)
|
|
141
152
|
|
|
142
|
-
def __call__(self, path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data=None):
|
|
143
|
-
"Call a fully specified `path` (or full URL) using HTTP `verb` directly (returns an awaitable on an async client)"
|
|
144
|
-
if verb is None: verb = 'POST' if data else 'GET'
|
|
145
|
-
if route: path = path.format(**{k:quote(str(v), safe='') for k,v in route.items()})
|
|
146
|
-
if not path.startswith(('http://', 'https://')): path = self.gh_host + path
|
|
147
|
-
kw = dict(content=data) if isinstance(data, (bytes,str)) else dict(json=data)
|
|
148
|
-
return self.transport.request(verb, path, headers=headers, params=query, **kw)
|
|
149
|
-
|
|
150
153
|
@property
|
|
151
154
|
def debug(self): return self.transport.debug
|
|
152
155
|
@debug.setter
|
|
@@ -158,10 +161,24 @@ class GhApi(OpenAPIClient):
|
|
|
158
161
|
|
|
159
162
|
def _repr_markdown_(self): return "\n".join(f"- [{o}]({_docroot + o.replace('_', '-')})" for o in sorted(self.groups))
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
|
|
165
|
+
# %% ../nbs/00_core.ipynb #5ba617de
|
|
166
|
+
@patch
|
|
167
|
+
def __call__(self:GhApi, path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data=None):
|
|
168
|
+
"Call a fully specified `path` (or full URL) using HTTP `verb` directly (returns an awaitable on an async client)"
|
|
169
|
+
if verb is None: verb = 'POST' if data else 'GET'
|
|
170
|
+
if route: path = path.format(**{k:quote(str(v), safe='') for k,v in route.items()})
|
|
171
|
+
if not path.startswith(('http://', 'https://')): path = self.gh_host + path
|
|
172
|
+
kw = dict(content=data) if isinstance(data, (bytes,str)) else dict(json=data)
|
|
173
|
+
return self.transport.request(verb, path, headers=headers, params=query, **kw)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# %% ../nbs/00_core.ipynb #2f980e70
|
|
177
|
+
@patch
|
|
178
|
+
def __getitem__(self:GhApi, k):
|
|
179
|
+
"Lookup an endpoint by path and verb (which defaults to 'GET')"
|
|
180
|
+
a,b = k if isinstance(k,tuple) else (k,'GET')
|
|
181
|
+
return self.func_dict[f'{a}:{b.upper()}']
|
|
165
182
|
|
|
166
183
|
# %% ../nbs/00_core.ipynb #562eee39
|
|
167
184
|
def call_gh(op:str, *args, token=None, **kwargs):
|
|
@@ -172,12 +189,13 @@ def call_gh(op:str, *args, token=None, **kwargs):
|
|
|
172
189
|
|
|
173
190
|
# %% ../nbs/00_core.ipynb #05cbdf91
|
|
174
191
|
def date2gh(dt:datetime)->str:
|
|
175
|
-
"Convert `dt`
|
|
176
|
-
|
|
192
|
+
"Convert `dt` to GitHub's UTC timestamp format; naive datetimes are assumed UTC"
|
|
193
|
+
if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc)
|
|
194
|
+
return f'{dt.replace(tzinfo=None, microsecond=0).isoformat()}Z'
|
|
177
195
|
|
|
178
196
|
# %% ../nbs/00_core.ipynb #3f4c8b27
|
|
179
197
|
def gh2date(dtstr:str)->datetime:
|
|
180
|
-
"Convert
|
|
198
|
+
"Convert a GitHub UTC date string to a naive UTC `datetime`"
|
|
181
199
|
return datetime.fromisoformat(dtstr.replace('Z', ''))
|
|
182
200
|
|
|
183
201
|
# %% ../nbs/00_core.ipynb #7876622c
|
|
@@ -214,9 +232,7 @@ async def create_gist(self:GhApi, description, content, filename='gist.txt', pub
|
|
|
214
232
|
@patch
|
|
215
233
|
def load_gist(self:GhApi, gist_id:str):
|
|
216
234
|
"Retrieve a gist by id, or by `user/id` (as it appears in gist URLs); coro if async client"
|
|
217
|
-
|
|
218
|
-
else: user = None
|
|
219
|
-
return self.gists.get(gist_id, user=user)
|
|
235
|
+
return self.gists.get(gist_id.split('/')[-1])
|
|
220
236
|
|
|
221
237
|
@patch
|
|
222
238
|
def gist_file(self:GhApi, gist_id:str):
|
|
@@ -231,13 +247,6 @@ async def update_gist(self:GhApi, gist_id:str, content:str):
|
|
|
231
247
|
fname = first(gist.files.keys())
|
|
232
248
|
return (await self.gists.update(gist_id, files={fname: {'content': content}})).html_url
|
|
233
249
|
|
|
234
|
-
# %% ../nbs/00_core.ipynb #4b7a278c
|
|
235
|
-
@gh_patch
|
|
236
|
-
async def delete_release(self:GhApi, release):
|
|
237
|
-
"Delete a release and its associated tag"
|
|
238
|
-
await self.repos.delete_release(release.id)
|
|
239
|
-
await self.git.delete_ref(f'tags/{release.tag_name}')
|
|
240
|
-
|
|
241
250
|
# %% ../nbs/00_core.ipynb #b2bf7e22
|
|
242
251
|
@patch
|
|
243
252
|
async def upload_file(self:GhApi, rel, fn):
|
|
@@ -247,17 +256,23 @@ async def upload_file(self:GhApi, rel, fn):
|
|
|
247
256
|
mime = mimetypes.guess_type(fn, False)[0] or 'application/octet-stream'
|
|
248
257
|
return await self(url, 'POST', headers={'Content-Type':mime}, query = {'name':fn.name}, data=fn.read_bytes())
|
|
249
258
|
|
|
250
|
-
# %% ../nbs/00_core.ipynb #3cad71a4
|
|
251
259
|
@gh_patch
|
|
252
260
|
async def create_release(self:GhApi, tag_name, branch=None, name=None, body='',
|
|
253
|
-
draft=False, prerelease=False, files=None, make_latest=UNSET):
|
|
261
|
+
draft=False, prerelease=False, files=None, make_latest=UNSET, generate_release_notes=UNSET):
|
|
254
262
|
"Wrapper for `GhApi.repos.create_release` which also uploads `files`; `branch` defaults to the repo's default branch"
|
|
255
263
|
if name is None: name = 'v'+tag_name
|
|
256
264
|
rel = await self.repos.create_release(tag_name, target_commitish=branch or UNSET, name=name, body=body,
|
|
257
|
-
draft=draft, prerelease=prerelease, make_latest=make_latest)
|
|
265
|
+
draft=draft, prerelease=prerelease, make_latest=make_latest, generate_release_notes=generate_release_notes)
|
|
258
266
|
for file in listify(files): await self.upload_file(rel, file)
|
|
259
267
|
return rel
|
|
260
268
|
|
|
269
|
+
# %% ../nbs/00_core.ipynb #4b7a278c
|
|
270
|
+
@gh_patch
|
|
271
|
+
async def delete_release(self:GhApi, release):
|
|
272
|
+
"Delete a release and its associated tag"
|
|
273
|
+
await self.repos.delete_release(release.id)
|
|
274
|
+
await self.git.delete_ref(f'tags/{release.tag_name}')
|
|
275
|
+
|
|
261
276
|
# %% ../nbs/00_core.ipynb #2be73ae0
|
|
262
277
|
@gh_patch
|
|
263
278
|
async def list_tags(self:GhApi, prefix:str=''):
|
|
@@ -341,7 +356,6 @@ async def create_or_update_file(self:GhApi, path, message, committer, author, co
|
|
|
341
356
|
return await self.repos.create_or_update_file_contents(path, message, content=content,
|
|
342
357
|
branch=branch, committer=committer or {}, author=author or {}, **kwargs)
|
|
343
358
|
|
|
344
|
-
# %% ../nbs/00_core.ipynb #5044445d
|
|
345
359
|
@gh_patch
|
|
346
360
|
async def create_file(self:GhApi, path, message, committer, author, content=None, branch=None):
|
|
347
361
|
if not branch: branch = (await self.repos.get())['default_branch']
|
|
@@ -351,21 +365,16 @@ async def create_file(self:GhApi, path, message, committer, author, content=None
|
|
|
351
365
|
@gh_patch
|
|
352
366
|
async def delete_file(self:GhApi, path, message, committer, author, sha=None, branch=None):
|
|
353
367
|
if not branch: branch = (await self.repos.get())['default_branch']
|
|
354
|
-
if sha is None: sha = (await self.list_files())[path].sha
|
|
368
|
+
if sha is None: sha = (await self.list_files(branch))[path].sha
|
|
355
369
|
return await self.repos.delete_file(path, message=message, sha=sha, branch=branch, committer=committer, author=author)
|
|
356
370
|
|
|
357
371
|
# %% ../nbs/00_core.ipynb #93f6b559
|
|
358
372
|
@gh_patch
|
|
359
373
|
async def update_contents(self:GhApi, path, message, committer, author, content, sha=None, branch=None):
|
|
360
374
|
if not branch: branch = (await self.repos.get())['default_branch']
|
|
361
|
-
if sha is None: sha = (await self.list_files())[path].sha
|
|
375
|
+
if sha is None: sha = (await self.list_files(branch))[path].sha
|
|
362
376
|
return await self.create_or_update_file(path, message, committer=committer, author=author, content=content, sha=sha, branch=branch)
|
|
363
377
|
|
|
364
|
-
# %% ../nbs/00_core.ipynb #444784ce
|
|
365
|
-
def _find_matches(path, pats):
|
|
366
|
-
"Returns matched patterns"
|
|
367
|
-
return L(pats).filter(lambda p: fnmatch.fnmatch(path, p))
|
|
368
|
-
|
|
369
378
|
# %% ../nbs/00_core.ipynb #9ffb3b8f
|
|
370
379
|
def _include(path, include, exclude):
|
|
371
380
|
"Returns True if path matches include patterns (if any) and doesn't match any exclude pattern."
|
|
@@ -391,7 +400,6 @@ async def get_file_content(self:GhApi, path, owner, repo, branch="main"):
|
|
|
391
400
|
o['content_decoded'] = base64.b64decode(o.content).decode('utf-8')
|
|
392
401
|
return o
|
|
393
402
|
|
|
394
|
-
# %% ../nbs/00_core.ipynb #d02d1bab
|
|
395
403
|
@patch
|
|
396
404
|
@delegates(GhApi.get_repo_files)
|
|
397
405
|
async def get_repo_contents(self:GhApi, owner, repo, branch='main',
|
|
@@ -410,9 +418,10 @@ async def enable_pages(self:GhApi, branch=None, path="/"):
|
|
|
410
418
|
r = await self.repos.get()
|
|
411
419
|
branch = branch or r.default_branch
|
|
412
420
|
source = {"branch": branch, "path": path}
|
|
413
|
-
if r.has_pages: return # await self.repos.update_information_about_pages_site(source=source)
|
|
414
421
|
if len(await self.list_branches(branch))==0: await self.create_branch_empty(branch)
|
|
415
|
-
return await self.repos.create_pages_site(source=source)
|
|
422
|
+
if not r.has_pages: return await self.repos.create_pages_site(source=source)
|
|
423
|
+
await self.repos.update_information_about_pages_site(source=source)
|
|
424
|
+
return await self.repos.get_pages()
|
|
416
425
|
|
|
417
426
|
# %% ../nbs/00_core.ipynb #519ec71c
|
|
418
427
|
class _IssueInfo(AttrDict):
|
|
@@ -427,12 +436,15 @@ class _IssueInfo(AttrDict):
|
|
|
427
436
|
|
|
428
437
|
@gh_patch
|
|
429
438
|
async def read_issue(self:GhApi, issue_number:int):
|
|
430
|
-
"Fetch an issue or PR
|
|
439
|
+
"""Fetch an issue or PR as structured data with a compact display.
|
|
440
|
+
|
|
441
|
+
Fields include `title`, `body`, `is_pr`, and general `comments`. PRs also have the full `diff`, inline `review_comments`, and `reviews`. Display the result bare for the summary; inspect those fields for detail. General PR comments use the Issues API, not the Pulls review API.
|
|
442
|
+
"""
|
|
431
443
|
try: pr = await self.pulls.get(issue_number)
|
|
432
444
|
except APIError: pr = None
|
|
433
445
|
iss = await self.issues.get(issue_number)
|
|
434
|
-
res = dict(title=iss.title, body=iss.body or '', is_pr=pr is not None,
|
|
435
|
-
|
|
446
|
+
res = dict(title=iss.title, body=iss.body or '', is_pr=pr is not None,
|
|
447
|
+
comments=await self.issues.list_comments(issue_number))
|
|
436
448
|
if pr is not None:
|
|
437
449
|
res['diff'] = await self.pulls.get(issue_number, headers_={'Accept': 'application/vnd.github.v3.diff'})
|
|
438
450
|
res['review_comments'] = await self.pulls.list_review_comments(issue_number)
|
|
@@ -447,9 +459,9 @@ async def read_issue(self:GhApi, issue_number:int):
|
|
|
447
459
|
def _filter_diff(diff, folder='', skip_files=('_modidx.py',)):
|
|
448
460
|
"Filter unified diff to only include files under `folder`, skipping `skip_files`"
|
|
449
461
|
sections = re.split(r'(?=^diff --git )', diff, flags=re.MULTILINE)
|
|
450
|
-
return ''.join(s for s in sections
|
|
451
|
-
|
|
452
|
-
|
|
462
|
+
return ''.join(s for s in sections
|
|
463
|
+
if s.startswith(f'diff --git a/{folder}')
|
|
464
|
+
and not any(f'/{f} ' in s.split('\n')[0] for f in skip_files))
|
|
453
465
|
|
|
454
466
|
def _reduce_ctx(diff):
|
|
455
467
|
"Keep only diff headers and changed lines"
|
|
@@ -527,6 +539,7 @@ class PullRow(AttrDict):
|
|
|
527
539
|
def line(self, maxlen=MAXLEN): return f'#{self.number} {_age(self.updated_at)} {_pr_mark(self)}{truncstr(self.title, maxlen)}'
|
|
528
540
|
__repr__ = line
|
|
529
541
|
|
|
542
|
+
# %% ../nbs/00_core.ipynb #21b48014
|
|
530
543
|
class PullRows(GhRows):
|
|
531
544
|
"Pull request rows, displayed with titles truncated to `maxlen`"
|
|
532
545
|
def __init__(self, items, maxlen=MAXLEN):
|
|
@@ -546,18 +559,26 @@ async def list_prs(
|
|
|
546
559
|
prs = await self.pulls.list(state=state, sort='updated', direction='desc', per_page=per_page, page=page)
|
|
547
560
|
return PullRows([PullRow(o) for o in prs], maxlen=maxlen)
|
|
548
561
|
|
|
562
|
+
# %% ../nbs/00_core.ipynb #c74057c8
|
|
563
|
+
def _parse_section(b):
|
|
564
|
+
attrs = b['attributes']
|
|
565
|
+
options = [o['label'] if isinstance(o, dict) else o for o in attrs.get('options', [])]
|
|
566
|
+
return dict(label=attrs['label'], type=b['type'], required=bool(nested_idx(b, 'validations', 'required')), options=options)
|
|
567
|
+
|
|
568
|
+
|
|
549
569
|
# %% ../nbs/00_core.ipynb #55a976ea
|
|
550
570
|
def _parse_tmpl(name, txt):
|
|
551
571
|
"Parse issue template `txt`: yml forms into section labels for `issue_body`; md templates kept as `raw`"
|
|
552
572
|
if not name.endswith(('.yml','.yaml')): return dict2obj(dict(name=name, raw=txt))
|
|
553
573
|
import yaml
|
|
554
574
|
d = yaml.safe_load(txt)
|
|
555
|
-
secs = [
|
|
556
|
-
required=bool(nested_idx(b, 'validations', 'required')),
|
|
557
|
-
options=[o['label'] if isinstance(o, dict) else o for o in b['attributes'].get('options', [])])
|
|
558
|
-
for b in d.get('body', []) if b['type']!='markdown']
|
|
575
|
+
secs = [_parse_section(b) for b in d.get('body', []) if b['type']!='markdown']
|
|
559
576
|
return dict2obj(dict(name=name, title=d.get('name'), description=d.get('description'), sections=secs))
|
|
560
577
|
|
|
578
|
+
async def _load_tmpl(api, f, **kw):
|
|
579
|
+
txt = (await api.get_content(f.path, **kw)).decode()
|
|
580
|
+
return _parse_tmpl(f.name, txt)
|
|
581
|
+
|
|
561
582
|
@gh_patch
|
|
562
583
|
async def issue_template(self:GhApi):
|
|
563
584
|
"Parsed issue templates for the repo (or the owner's `.github` repo as fallback); pass one to `issue_body`"
|
|
@@ -567,8 +588,8 @@ async def issue_template(self:GhApi):
|
|
|
567
588
|
except APIError as e:
|
|
568
589
|
if e.status_code != 404: raise
|
|
569
590
|
continue
|
|
570
|
-
return L([
|
|
571
|
-
|
|
591
|
+
return L([await _load_tmpl(self, f, **kw) for f in fs
|
|
592
|
+
if f.name.endswith(('.md','.yml','.yaml')) and f.name != 'config.yml'])
|
|
572
593
|
return L()
|
|
573
594
|
|
|
574
595
|
# %% ../nbs/00_core.ipynb #173fc534
|
|
@@ -598,6 +619,7 @@ class CheckRun(AttrDict):
|
|
|
598
619
|
class CommitStatus(AttrDict):
|
|
599
620
|
def __repr__(self): return f'{self.context}: {self.state}'
|
|
600
621
|
|
|
622
|
+
# %% ../nbs/00_core.ipynb #6a9289a9
|
|
601
623
|
class _CheckStatus(AttrDict):
|
|
602
624
|
def _repr_markdown_(self):
|
|
603
625
|
runs = self.check_runs
|
|
@@ -610,7 +632,10 @@ class _CheckStatus(AttrDict):
|
|
|
610
632
|
|
|
611
633
|
@gh_patch
|
|
612
634
|
async def check_status(self:GhApi, ref:str):
|
|
613
|
-
"Combined commit status and check-run results for `ref` (a SHA, branch, or tag)
|
|
635
|
+
"""Combined commit status and check-run results for `ref` (a SHA, branch, or tag).
|
|
636
|
+
|
|
637
|
+
Display the result bare for the check-run verdict and all run/status rows. `.state` comes only from legacy commit statuses and can be pending even when every Actions check passed. `.check_runs` and `.statuses` retain the structured rows.
|
|
638
|
+
"""
|
|
614
639
|
combined = await self.repos.get_combined_status_for_ref(ref)
|
|
615
640
|
checks = await self.checks.list_for_ref(ref)
|
|
616
641
|
return _CheckStatus(dict(state=combined.state, statuses=GhRows(CommitStatus(o) for o in combined.statuses),
|
|
@@ -621,17 +646,47 @@ async def pr_status(self:GhApi, pull_number:int):
|
|
|
621
646
|
"Combined status and check-run results for a PR's head commit"
|
|
622
647
|
return await self.check_status((await self.pulls.get(pull_number)).head.sha)
|
|
623
648
|
|
|
649
|
+
# %% ../nbs/00_core.ipynb #40f5c88a
|
|
650
|
+
def _step_lines(zf, job, s):
|
|
651
|
+
"Timestamped log lines for step `s` of `job`. They come from the step's own archive file, or else from the job's combined log, cut to the step's time window and its last `##[error]` line."
|
|
652
|
+
names = zf.namelist()
|
|
653
|
+
if name := first(n for n in names if n.startswith(f'{job.name}/{s.number}_')): return zf.read(name).decode().splitlines()
|
|
654
|
+
name = first(n for n in names if re.fullmatch(rf'\d+_{re.escape(job.name)}\.txt', n))
|
|
655
|
+
lines = [l for l in zf.read(name).decode('utf-8-sig').splitlines() if s.started_at[:19] <= l[:19] <= s.completed_at[:19]]
|
|
656
|
+
last = max((i for i,l in enumerate(lines) if l.split(' ', 1)[-1].startswith('##[error]')), default=len(lines)-1)
|
|
657
|
+
return lines[:last+1]
|
|
658
|
+
|
|
659
|
+
# %% ../nbs/00_core.ipynb #fd5e2942
|
|
660
|
+
def _fold_groups(lines, n=20):
|
|
661
|
+
"Fold each finished `##[group]` of more than `n` lines, unless it holds an error or warning. An unfinished group stays whole, since the step died inside it."
|
|
662
|
+
res, start = [], None
|
|
663
|
+
for l in lines:
|
|
664
|
+
res.append(l)
|
|
665
|
+
if l.startswith('##[group]'): start = len(res)
|
|
666
|
+
elif l.startswith('##[endgroup]') and start is not None:
|
|
667
|
+
body = res[start:-1]
|
|
668
|
+
if len(body) > n and not any(o.startswith(('##[error]', '##[warning]')) for o in body): res[start:-1] = [f'… {len(body)} lines folded']
|
|
669
|
+
start = None
|
|
670
|
+
return res
|
|
671
|
+
|
|
624
672
|
# %% ../nbs/00_core.ipynb #569864f5
|
|
625
673
|
@gh_patch
|
|
626
|
-
async def failed_step_log(
|
|
627
|
-
|
|
674
|
+
async def failed_step_log(
|
|
675
|
+
self:GhApi,
|
|
676
|
+
job_id:int, # Workflow job id, which is also its check run id
|
|
677
|
+
fold:bool=True, # Fold long finished `##[group]` blocks that hold no error or warning?
|
|
678
|
+
):
|
|
679
|
+
"""Return the failed steps' log sections, each headed by its step name.
|
|
680
|
+
|
|
681
|
+
An Actions check run's id is its job id. Pass that id from `check_status` or `pr_status`. The returned text's display is truncated at 8,000 characters. Long finished `##[group]` blocks with no error or warning are folded to a line count. GitHub can expire or delete stored logs.
|
|
682
|
+
"""
|
|
628
683
|
job = await self.actions.get_job_for_workflow_run(job_id=job_id)
|
|
629
684
|
zf = zipfile.ZipFile(io.BytesIO(await self.actions.download_workflow_run_logs(run_id=job.run_id)))
|
|
630
685
|
res = []
|
|
631
686
|
for s in job.steps:
|
|
632
687
|
if s.conclusion!='failure': continue
|
|
633
|
-
|
|
634
|
-
lines =
|
|
688
|
+
lines = [strip_ansi(l.split(' ', 1)[-1]) for l in _step_lines(zf, job, s)]
|
|
689
|
+
if fold: lines = _fold_groups(lines)
|
|
635
690
|
res.append(f'# {s.name}\n' + '\n'.join(lines))
|
|
636
691
|
return TruncatedString('\n\n'.join(res), 8_000)
|
|
637
692
|
|