pygitpub 0.0.62__tar.gz → 0.0.64__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.
@@ -38,6 +38,10 @@ lib*.so.*
38
38
  *.gcda
39
39
  *.map
40
40
  *.bin
41
+ # cmake in-source build output; builds belong in build/ (ignored above)
42
+ CMakeFiles/
43
+ CMakeCache.txt
44
+ cmake_install.cmake
41
45
 
42
46
  # kernel stuff
43
47
  *.ko
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pygitpub
3
- Version: 0.0.62
3
+ Version: 0.0.64
4
4
  Summary: Pygitpub will help you work with github
5
5
  Project-URL: Homepage, https://github.com/veltzer/pygitpub
6
6
  Project-URL: Bug Tracker, https://github.com/veltzer/pygitpub/issues
@@ -41,9 +41,7 @@ project website: https://veltzer.github.io/pygitpub
41
41
 
42
42
  author: Mark Veltzer
43
43
 
44
- version: 0.0.62
45
-
46
- ![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)
44
+ version: 0.0.64
47
45
 
48
46
  ## github
49
47
 
@@ -69,8 +67,6 @@ version: 0.0.62
69
67
 
70
68
  ## contact me
71
69
 
72
- [mailto](mailto:mark.veltzer@gmail.com)
73
- ![discord](https://img.shields.io/discord/719336281624281119)
74
- ![discord](https://img.shields.io/discord/719336282194444302)
70
+ [mark.veltzer@gmail.com](mailto:mark.veltzer@gmail.com)
75
71
 
76
72
  Mark Veltzer, Copyright © 2022, 2023, 2024, 2025, 2026
@@ -9,9 +9,7 @@ project website: https://veltzer.github.io/pygitpub
9
9
 
10
10
  author: Mark Veltzer
11
11
 
12
- version: 0.0.62
13
-
14
- ![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)
12
+ version: 0.0.64
15
13
 
16
14
  ## github
17
15
 
@@ -37,8 +35,6 @@ version: 0.0.62
37
35
 
38
36
  ## contact me
39
37
 
40
- [mailto](mailto:mark.veltzer@gmail.com)
41
- ![discord](https://img.shields.io/discord/719336281624281119)
42
- ![discord](https://img.shields.io/discord/719336282194444302)
38
+ [mark.veltzer@gmail.com](mailto:mark.veltzer@gmail.com)
43
39
 
44
40
  Mark Veltzer, Copyright © 2022, 2023, 2024, 2025, 2026
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pygitpub"
7
- version = "0.0.62"
7
+ version = "0.0.64"
8
8
  requires-python = ">=3.14"
9
9
  authors = [
10
10
  { name = "Mark Veltzer", email = "mark.veltzer@gmail.com" }
@@ -41,8 +41,7 @@ dependencies = [
41
41
 
42
42
  [dependency-groups]
43
43
  dev = [
44
- "mypy",
45
- "pylint",
44
+ "pyrefly",
46
45
  "pytest",
47
46
  "ruff",
48
47
  "sphinx",
@@ -83,24 +82,29 @@ exclude = [
83
82
  "*.md",
84
83
  ]
85
84
 
86
- [tool.mypy]
87
- python_version = "3.14"
88
- warn_return_any = true
89
- warn_unused_configs = true
90
- mypy_path = "src:python:scripts"
91
- explicit_package_bases = true
92
- namespace_packages = true
93
-
94
- # Third-party libraries without type stubs; `foo.*` also matches `foo` itself.
95
- [[tool.mypy.overrides]]
96
- module = [
85
+ [tool.pyrefly]
86
+ # Type checking (rsconstruct's pyrefly processor). preset = "legacy" is
87
+ # pyrefly's mypy-compatible behavior; "." on the search path is mypy's
88
+ # implicit current directory.
89
+ preset = "legacy"
90
+ python-version = "3.14"
91
+ search-path = [
92
+ "src",
93
+ "scripts",
94
+ ".",
95
+ ]
96
+ # Third-party libraries without type information.
97
+ ignore-missing-imports = [
97
98
  "github.*",
98
99
  "lupa.*",
99
100
  "pyapikey.*",
100
101
  "pylogconf.*",
101
102
  "pytconf.*",
102
103
  ]
103
- ignore_missing_imports = true
104
+
105
+ [tool.pyrefly.errors]
106
+ no-any-return = "error"
107
+
104
108
  [tool.hatch.envs.default]
105
109
  installer = "uv"
106
110
 
@@ -63,6 +63,26 @@ class ConfigAlgo(Config):
63
63
  )
64
64
 
65
65
 
66
+ # The ways the runs_show_failing and runs_show_not_success endpoints can
67
+ # query github. The first is the default.
68
+ RUNS_APIS = [
69
+ "graphql",
70
+ "rest",
71
+ ]
72
+
73
+
74
+ class ConfigRuns(Config):
75
+ """
76
+ Parameters to control how workflow runs are queried
77
+ """
78
+ api = ParamCreator.create_choice(
79
+ choice_list=RUNS_APIS,
80
+ help_string="graphql: check the default branch head commit of all repos in parallel batches (fast);"
81
+ " rest: walk every workflow's last run on any branch (slow, one call per workflow)",
82
+ default="graphql",
83
+ )
84
+
85
+
66
86
  class ConfigOutput(Config):
67
87
  """
68
88
  Parameters to control output
@@ -6,6 +6,9 @@ import json
6
6
  import os
7
7
  import subprocess
8
8
  import sys
9
+ from collections.abc import Callable
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from typing import Any
9
12
 
10
13
  import github
11
14
  import pyapikey
@@ -15,7 +18,7 @@ from pytconf.pydoc import get_first_line
15
18
  from pytconf.registry import the_registry
16
19
 
17
20
  import pygitpub.static
18
- from pygitpub.configs import AFFILIATIONS, ConfigAlgo, ConfigGithub, ConfigOutput
21
+ from pygitpub.configs import AFFILIATIONS, ConfigAlgo, ConfigGithub, ConfigOutput, ConfigRuns
19
22
  from pygitpub.utils.lua import config_path, load_lua_file
20
23
  from pygitpub.utils.misc import get_all_git_repos
21
24
 
@@ -31,7 +34,7 @@ def get_repo_folder(repo) -> str:
31
34
 
32
35
  def get_affiliation() -> str:
33
36
  """ validate the affiliation parameter and return it in the form github wants """
34
- values = [x.strip() for x in ConfigAlgo.affiliation.split(",")]
37
+ values: list[str] = [x.strip() for x in ConfigAlgo.affiliation.split(",")]
35
38
  values = [x for x in values if x != ""]
36
39
  if not values:
37
40
  raise ValueError("affiliation must not be empty")
@@ -202,7 +205,7 @@ def cleanup() -> None:
202
205
  existing = 0
203
206
  for run in workflow.get_runs():
204
207
  if ConfigOutput.verbose:
205
- print(f"inspecting {repo.name} {workflow.name} {run.conclusion}")
208
+ print(f"inspecting [workflow run] {repo.name} {workflow.name} {run.conclusion}")
206
209
  delete_it = False
207
210
  # if its a pages build delete it unless its in mid work (run.conclusion is None)
208
211
  if workflow.name == "pages-build-deployment" and run.conclusion is not None:
@@ -218,7 +221,7 @@ def cleanup() -> None:
218
221
  if existing >= 4:
219
222
  delete_it = True
220
223
  if delete_it:
221
- print(f"deleting {repo.name} {workflow.name} {run.head_branch} {run.conclusion} {run.url}")
224
+ print(f"deleting [workflow run] {repo.name} {workflow.name} {run.head_branch} {run.conclusion} {run.url}")
222
225
  try:
223
226
  run.delete()
224
227
  except github.GithubException as e:
@@ -241,7 +244,7 @@ def cleanup() -> None:
241
244
  existing_deployments += 1
242
245
 
243
246
  if delete_it:
244
- print(f"deleting deployment {repo.name} {deployment.id}")
247
+ print(f"deleting [deployment] {repo.name} {deployment.id}")
245
248
  try:
246
249
  deployment.create_status("inactive")
247
250
  except github.GithubException as e:
@@ -258,7 +261,7 @@ def cleanup() -> None:
258
261
  existing_releases = 0
259
262
  for release in repo.get_releases():
260
263
  if existing_releases >= 4:
261
- print(f"deleting release {repo.name} {release.title}")
264
+ print(f"deleting [release] {repo.name} {release.title}")
262
265
  try:
263
266
  release.delete_release()
264
267
  except github.GithubException as e:
@@ -296,14 +299,123 @@ def runs_show_running() -> None:
296
299
  print(f"{repo.name}: {workflow.name} {run.conclusion}")
297
300
 
298
301
 
302
+ # repos per GraphQL query; measured fastest at around 10
303
+ GRAPHQL_BATCH_SIZE = 10
304
+ # GraphQL queries in flight at once, well below github's concurrency limits
305
+ GRAPHQL_WORKERS = 26
306
+
307
+ GRAPHQL_ROLLUP_FIELD = """
308
+ r{index}: repository(owner: {owner}, name: {name}) {{
309
+ defaultBranchRef {{ target {{ ... on Commit {{ statusCheckRollup {{ state }} }} }} }}
310
+ }}
311
+ """
312
+
313
+ GRAPHQL_SUITES_QUERY = """
314
+ query($owner: String!, $name: String!) {
315
+ repository(owner: $owner, name: $name) {
316
+ defaultBranchRef {
317
+ target {
318
+ ... on Commit {
319
+ checkSuites(first: 50) {
320
+ nodes {
321
+ status
322
+ conclusion
323
+ workflowRun { workflow { name } }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
331
+ """
332
+
333
+
334
+ def graphql_show_runs(bad_state: Callable[[str], bool], bad_conclusion: Callable[[str], bool]) -> None:
335
+ """
336
+ Print the workflows that are not green on the default branch head commit
337
+ of every repo.
338
+
339
+ Resolving statusCheckRollup is slow on github's side (~4.5s per 50 repos)
340
+ and cursor pagination would run the pages one after another, so the repos
341
+ are split into small batches, each batch is one query with an aliased
342
+ `repository` field per repo, and the batches run in parallel. Repos whose
343
+ rollup state is bad then get one more query each to name the workflows.
344
+ Only the head commit of the default branch is looked at, so a failed run
345
+ on another branch, or a scheduled run on an older commit, does not show.
346
+ """
347
+ apikey = pyapikey.get_key(ConfigGithub.apikey)
348
+ # the default client sleeps a second between POSTs, and every GraphQL
349
+ # query is a POST, which would serialize the parallel queries
350
+ g = github.Github(
351
+ login_or_token=apikey,
352
+ pool_size=GRAPHQL_WORKERS,
353
+ seconds_between_requests=None,
354
+ seconds_between_writes=None,
355
+ )
356
+
357
+ def graphql(query: str, variables: dict[str, str]) -> dict[str, Any]:
358
+ _headers, data = g.requester.graphql_query(query, variables)
359
+ result: dict[str, Any] = data["data"]
360
+ return result
361
+
362
+ def get_states(batch: list[str]) -> list[str | None]:
363
+ fields = []
364
+ for index, full_name in enumerate(batch):
365
+ owner, name = full_name.split("/", 1)
366
+ # json.dumps gives a correctly quoted GraphQL string literal
367
+ fields.append(GRAPHQL_ROLLUP_FIELD.format(index=index, owner=json.dumps(owner), name=json.dumps(name)))
368
+ data = graphql("query {" + "".join(fields) + "}", {})
369
+ states: list[str | None] = []
370
+ for index in range(len(batch)):
371
+ ref = data[f"r{index}"]["defaultBranchRef"]
372
+ rollup = ref and ref["target"].get("statusCheckRollup")
373
+ states.append(rollup and rollup["state"])
374
+ return states
375
+
376
+ def get_bad_workflows(full_name: str) -> list[tuple[str, str]]:
377
+ owner, name = full_name.split("/", 1)
378
+ data = graphql(GRAPHQL_SUITES_QUERY, {"owner": owner, "name": name})
379
+ workflows = []
380
+ for suite in data["repository"]["defaultBranchRef"]["target"]["checkSuites"]["nodes"]:
381
+ # github creates an empty, never-run suite for every installed
382
+ # app; without a workflow run behind it, it tells us nothing
383
+ if suite["workflowRun"] is None:
384
+ continue
385
+ # an unfinished suite has no conclusion yet; report its status
386
+ conclusion = suite["conclusion"] or suite["status"]
387
+ if bad_conclusion(conclusion):
388
+ workflows.append((suite["workflowRun"]["workflow"]["name"], conclusion.lower()))
389
+ return workflows
390
+
391
+ full_names = [repo.full_name for repo in yield_repos()]
392
+ batches = [full_names[i:i + GRAPHQL_BATCH_SIZE] for i in range(0, len(full_names), GRAPHQL_BATCH_SIZE)]
393
+ with ThreadPoolExecutor(GRAPHQL_WORKERS) as executor:
394
+ states = [state for result in executor.map(get_states, batches) for state in result]
395
+ # repos without any checks have no state and are skipped, as the rest
396
+ # api path skips workflows that never ran
397
+ bad = [full_name for full_name, state in zip(full_names, states) if state is not None and bad_state(state)]
398
+ for full_name, workflows in zip(bad, executor.map(get_bad_workflows, bad)):
399
+ name = full_name.split("/", 1)[1]
400
+ for workflow, conclusion in workflows:
401
+ print(f"{name}: {workflow} {conclusion}")
402
+
403
+
299
404
  @register_endpoint(
300
405
  description="Show all runs which are last and failing in all workflows and all repositories",
301
406
  configs=[
302
407
  ConfigGithub,
303
408
  ConfigAlgo,
409
+ ConfigRuns,
304
410
  ],
305
411
  )
306
412
  def runs_show_failing() -> None:
413
+ if ConfigRuns.api == "graphql":
414
+ graphql_show_runs(
415
+ lambda state: state in ("FAILURE", "ERROR"),
416
+ lambda conclusion: conclusion == "FAILURE",
417
+ )
418
+ return
307
419
  for repo in yield_repos():
308
420
  for workflow in repo.get_workflows():
309
421
  last_run = next(iter(workflow.get_runs()), None)
@@ -316,9 +428,16 @@ def runs_show_failing() -> None:
316
428
  configs=[
317
429
  ConfigGithub,
318
430
  ConfigAlgo,
431
+ ConfigRuns,
319
432
  ],
320
433
  )
321
434
  def runs_show_not_success() -> None:
435
+ if ConfigRuns.api == "graphql":
436
+ graphql_show_runs(
437
+ lambda state: state != "SUCCESS",
438
+ lambda conclusion: conclusion != "SUCCESS",
439
+ )
440
+ return
322
441
  for repo in yield_repos():
323
442
  for workflow in repo.get_workflows():
324
443
  last_run = next(iter(workflow.get_runs()), None)
@@ -1,5 +1,5 @@
1
1
  """ version which can be consumed from within the module """
2
- VERSION_STR = "0.0.62"
2
+ VERSION_STR = "0.0.64"
3
3
  DESCRIPTION = "Pygitpub will help you work with github"
4
4
  APP_NAME = "pygitpub"
5
5
  LOGGER_NAME = "pygitpub"
File without changes