pygitpub 0.0.63__tar.gz → 0.0.65__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pygitpub
3
- Version: 0.0.63
3
+ Version: 0.0.65
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.63
45
-
46
- ![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)
44
+ version: 0.0.65
47
45
 
48
46
  ## github
49
47
 
@@ -9,9 +9,7 @@ project website: https://veltzer.github.io/pygitpub
9
9
 
10
10
  author: Mark Veltzer
11
11
 
12
- version: 0.0.63
13
-
14
- ![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)
12
+ version: 0.0.65
15
13
 
16
14
  ## github
17
15
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pygitpub"
7
- version = "0.0.63"
7
+ version = "0.0.65"
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,28 @@ 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
+ ".",
94
+ ]
95
+ # Third-party libraries without type information.
96
+ ignore-missing-imports = [
97
97
  "github.*",
98
98
  "lupa.*",
99
99
  "pyapikey.*",
100
100
  "pylogconf.*",
101
101
  "pytconf.*",
102
102
  ]
103
- ignore_missing_imports = true
103
+
104
+ [tool.pyrefly.errors]
105
+ no-any-return = "error"
106
+
104
107
  [tool.hatch.envs.default]
105
108
  installer = "uv"
106
109
 
@@ -14,11 +14,8 @@ AFFILIATIONS = [
14
14
 
15
15
  class ConfigGithub(Config):
16
16
  """
17
- Paramters for your github account
17
+ Parameters for your github account
18
18
  """
19
- username = ParamCreator.create_str(
20
- help_string="What is your github username?",
21
- )
22
19
  apikey = ParamCreator.create_str(
23
20
  help_string="What is the name of your apikey (see pyapikey)?",
24
21
  )
@@ -63,6 +60,26 @@ class ConfigAlgo(Config):
63
60
  )
64
61
 
65
62
 
63
+ # The ways the runs_show_failing and runs_show_not_success endpoints can
64
+ # query github. The first is the default.
65
+ RUNS_APIS = [
66
+ "graphql",
67
+ "rest",
68
+ ]
69
+
70
+
71
+ class ConfigRuns(Config):
72
+ """
73
+ Parameters to control how workflow runs are queried
74
+ """
75
+ api = ParamCreator.create_choice(
76
+ choice_list=RUNS_APIS,
77
+ help_string="graphql: check the default branch head commit of all repos in parallel batches (fast);"
78
+ " rest: walk every workflow's last run on any branch (slow, one call per workflow)",
79
+ default="graphql",
80
+ )
81
+
82
+
66
83
  class ConfigOutput(Config):
67
84
  """
68
85
  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")
@@ -41,9 +44,13 @@ def get_affiliation() -> str:
41
44
  return ",".join(values)
42
45
 
43
46
 
44
- def yield_repos():
47
+ def get_github(**kwargs: Any) -> github.Github:
45
48
  apikey = pyapikey.get_key(ConfigGithub.apikey)
46
- g = github.Github(login_or_token=apikey)
49
+ return github.Github(auth=github.Auth.Token(apikey), **kwargs)
50
+
51
+
52
+ def yield_repos():
53
+ g = get_github()
47
54
  # github filters by affiliation server side, so repos we are not interested
48
55
  # in (org repos, by default) are never fetched in the first place
49
56
  affiliation = get_affiliation()
@@ -92,7 +99,8 @@ def fix_metadata() -> None:
92
99
  print(f"in {folder}...")
93
100
  print(f"description_short is [{description_short}]")
94
101
  print(f"repo.description is [{repo.description}]")
95
- repo.edit(description=description_short)
102
+ if not ConfigAlgo.dryrun:
103
+ repo.edit(description=description_short)
96
104
  if "KEYWORDS" not in project:
97
105
  print(f"{folder} no keywords")
98
106
  continue
@@ -101,32 +109,36 @@ def fix_metadata() -> None:
101
109
  print(f"in {folder}...")
102
110
  print(f"keywords is [{keywords}]")
103
111
  print(f"repo.get_topics() is [{repo.get_topics()}]")
104
- repo.replace_topics(keywords)
112
+ if not ConfigAlgo.dryrun:
113
+ repo.replace_topics(keywords)
105
114
 
106
115
 
107
116
  @register_endpoint(
108
117
  description="Set the correct website for the project on all repos where it is not correct",
109
118
  configs=[
110
119
  ConfigGithub,
120
+ ConfigOutput,
111
121
  ConfigAlgo,
112
122
  ],
113
123
  )
114
124
  def homepage_fix() -> None:
115
125
  for repo in yield_repos():
126
+ owner = repo.owner.login
127
+ # the user site repo is served at the root of the site, usually under
128
+ # a custom domain, so the pattern below does not apply to it
129
+ if repo.name.lower() == f"{owner.lower()}.github.io":
130
+ if ConfigOutput.verbose:
131
+ print(f"skipping [{repo.name}] because it is the user site")
132
+ continue
116
133
  if repo.has_pages:
117
- right_homepage = f"https://{ConfigGithub.username}.github.io/{repo.name}/"
118
- if repo.homepage != right_homepage:
119
- print(f"{repo.name} is wrong!")
120
- print(f"homepage: {repo.homepage}")
121
- print(f"right_homepage: {right_homepage}")
122
- print(f"patching [{repo.name}]...")
123
- repo.edit(repo.name, homepage=right_homepage)
134
+ right_homepage = f"https://{owner}.github.io/{repo.name}/"
124
135
  else:
125
- right_homepage = f"https://github.com/{ConfigGithub.username}/{repo.name}/"
126
- if repo.homepage != right_homepage:
127
- print(f"{repo.name} is wrong!")
128
- print(f"homepage: {repo.homepage}")
129
- print(f"right_homepage: {right_homepage}")
136
+ right_homepage = f"https://github.com/{owner}/{repo.name}/"
137
+ if repo.homepage != right_homepage:
138
+ print(f"{repo.name} is wrong!")
139
+ print(f"homepage: {repo.homepage}")
140
+ print(f"right_homepage: {right_homepage}")
141
+ if not ConfigAlgo.dryrun:
130
142
  print(f"patching [{repo.name}]...")
131
143
  repo.edit(repo.name, homepage=right_homepage)
132
144
 
@@ -195,20 +207,23 @@ def repos_list() -> None:
195
207
  ],
196
208
  )
197
209
  def cleanup() -> None:
198
- # pylint: disable=too-many-branches,too-many-statements
199
210
  for repo in yield_repos():
200
211
  # 1. Cleanup workflow runs
201
212
  for workflow in repo.get_workflows():
202
213
  existing = 0
203
214
  for run in workflow.get_runs():
204
215
  if ConfigOutput.verbose:
205
- print(f"inspecting [workflow run] {repo.name} {workflow.name} {run.conclusion}")
216
+ print(f"inspecting [workflow run] {repo.name} {workflow.name} {run.status} {run.conclusion}")
217
+ # github refuses to delete a run that is queued or still
218
+ # running, so leave those alone and don't count them
219
+ if run.status != "completed":
220
+ continue
206
221
  delete_it = False
207
- # if its a pages build delete it unless its in mid work (run.conclusion is None)
208
- if workflow.name == "pages-build-deployment" and run.conclusion is not None:
222
+ # if its a pages build delete it
223
+ if workflow.name == "pages-build-deployment":
209
224
  delete_it = True
210
- # if its not on the master branch, delete it
211
- if run.head_branch != "master":
225
+ # if its not on the default branch, delete it
226
+ if run.head_branch != repo.default_branch:
212
227
  delete_it = True
213
228
  # if its not a paged build and it failed then delete it
214
229
  if workflow.name != "pages-build-deployment" and run.conclusion in (
@@ -219,6 +234,8 @@ def cleanup() -> None:
219
234
  delete_it = True
220
235
  if delete_it:
221
236
  print(f"deleting [workflow run] {repo.name} {workflow.name} {run.head_branch} {run.conclusion} {run.url}")
237
+ if ConfigAlgo.dryrun:
238
+ continue
222
239
  try:
223
240
  run.delete()
224
241
  except github.GithubException as e:
@@ -242,12 +259,13 @@ def cleanup() -> None:
242
259
 
243
260
  if delete_it:
244
261
  print(f"deleting [deployment] {repo.name} {deployment.id}")
262
+ if ConfigAlgo.dryrun:
263
+ continue
245
264
  try:
246
265
  deployment.create_status("inactive")
247
266
  except github.GithubException as e:
248
267
  print(f"Failed to deactivate deployment: {e}")
249
268
  try:
250
- # pylint: disable=protected-access
251
269
  status, _, _ = deployment._requester.requestJson("DELETE", deployment.url)
252
270
  if status != 204:
253
271
  print(f"Failed to delete deployment: HTTP {status}")
@@ -259,6 +277,8 @@ def cleanup() -> None:
259
277
  for release in repo.get_releases():
260
278
  if existing_releases >= 4:
261
279
  print(f"deleting [release] {repo.name} {release.title}")
280
+ if ConfigAlgo.dryrun:
281
+ continue
262
282
  try:
263
283
  release.delete_release()
264
284
  except github.GithubException as e:
@@ -292,8 +312,108 @@ def runs_show_running() -> None:
292
312
  for repo in yield_repos():
293
313
  for workflow in repo.get_workflows():
294
314
  for run in workflow.get_runs():
295
- if run.conclusion is None:
296
- print(f"{repo.name}: {workflow.name} {run.conclusion}")
315
+ if run.status in ("in_progress", "queued"):
316
+ print(f"{repo.name}: {workflow.name} {run.status}")
317
+
318
+
319
+ # repos per GraphQL query; measured fastest at around 10
320
+ GRAPHQL_BATCH_SIZE = 10
321
+ # GraphQL queries in flight at once, well below github's concurrency limits
322
+ GRAPHQL_WORKERS = 26
323
+
324
+ GRAPHQL_ROLLUP_FIELD = """
325
+ r{index}: repository(owner: {owner}, name: {name}) {{
326
+ defaultBranchRef {{ target {{ ... on Commit {{ statusCheckRollup {{ state }} }} }} }}
327
+ }}
328
+ """
329
+
330
+ GRAPHQL_SUITES_QUERY = """
331
+ query($owner: String!, $name: String!) {
332
+ repository(owner: $owner, name: $name) {
333
+ defaultBranchRef {
334
+ target {
335
+ ... on Commit {
336
+ checkSuites(first: 50) {
337
+ nodes {
338
+ status
339
+ conclusion
340
+ workflowRun { workflow { name } }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ """
349
+
350
+
351
+ def graphql_show_runs(bad_state: Callable[[str], bool], bad_conclusion: Callable[[str], bool]) -> None:
352
+ """
353
+ Print the workflows that are not green on the default branch head commit
354
+ of every repo.
355
+
356
+ Resolving statusCheckRollup is slow on github's side (~4.5s per 50 repos)
357
+ and cursor pagination would run the pages one after another, so the repos
358
+ are split into small batches, each batch is one query with an aliased
359
+ `repository` field per repo, and the batches run in parallel. Repos whose
360
+ rollup state is bad then get one more query each to name the workflows.
361
+ Only the head commit of the default branch is looked at, so a failed run
362
+ on another branch, or a scheduled run on an older commit, does not show.
363
+ """
364
+ # the default client sleeps a second between POSTs, and every GraphQL
365
+ # query is a POST, which would serialize the parallel queries
366
+ g = get_github(
367
+ pool_size=GRAPHQL_WORKERS,
368
+ seconds_between_requests=None,
369
+ seconds_between_writes=None,
370
+ )
371
+
372
+ def graphql(query: str, variables: dict[str, str]) -> dict[str, Any]:
373
+ _headers, data = g.requester.graphql_query(query, variables)
374
+ result: dict[str, Any] = data["data"]
375
+ return result
376
+
377
+ def get_states(batch: list[str]) -> list[str | None]:
378
+ fields = []
379
+ for index, full_name in enumerate(batch):
380
+ owner, name = full_name.split("/", 1)
381
+ # json.dumps gives a correctly quoted GraphQL string literal
382
+ fields.append(GRAPHQL_ROLLUP_FIELD.format(index=index, owner=json.dumps(owner), name=json.dumps(name)))
383
+ data = graphql("query {" + "".join(fields) + "}", {})
384
+ states: list[str | None] = []
385
+ for index in range(len(batch)):
386
+ ref = data[f"r{index}"]["defaultBranchRef"]
387
+ rollup = ref and ref["target"].get("statusCheckRollup")
388
+ states.append(rollup and rollup["state"])
389
+ return states
390
+
391
+ def get_bad_workflows(full_name: str) -> list[tuple[str, str]]:
392
+ owner, name = full_name.split("/", 1)
393
+ data = graphql(GRAPHQL_SUITES_QUERY, {"owner": owner, "name": name})
394
+ workflows = []
395
+ for suite in data["repository"]["defaultBranchRef"]["target"]["checkSuites"]["nodes"]:
396
+ # github creates an empty, never-run suite for every installed
397
+ # app; without a workflow run behind it, it tells us nothing
398
+ if suite["workflowRun"] is None:
399
+ continue
400
+ # an unfinished suite has no conclusion yet; report its status
401
+ conclusion = suite["conclusion"] or suite["status"]
402
+ if bad_conclusion(conclusion):
403
+ workflows.append((suite["workflowRun"]["workflow"]["name"], conclusion.lower()))
404
+ return workflows
405
+
406
+ full_names = [repo.full_name for repo in yield_repos()]
407
+ batches = [full_names[i:i + GRAPHQL_BATCH_SIZE] for i in range(0, len(full_names), GRAPHQL_BATCH_SIZE)]
408
+ with ThreadPoolExecutor(GRAPHQL_WORKERS) as executor:
409
+ states = [state for result in executor.map(get_states, batches) for state in result]
410
+ # repos without any checks have no state and are skipped, as the rest
411
+ # api path skips workflows that never ran
412
+ bad = [full_name for full_name, state in zip(full_names, states) if state is not None and bad_state(state)]
413
+ for full_name, workflows in zip(bad, executor.map(get_bad_workflows, bad)):
414
+ name = full_name.split("/", 1)[1]
415
+ for workflow, conclusion in workflows:
416
+ print(f"{name}: {workflow} {conclusion}")
297
417
 
298
418
 
299
419
  @register_endpoint(
@@ -301,9 +421,16 @@ def runs_show_running() -> None:
301
421
  configs=[
302
422
  ConfigGithub,
303
423
  ConfigAlgo,
424
+ ConfigRuns,
304
425
  ],
305
426
  )
306
427
  def runs_show_failing() -> None:
428
+ if ConfigRuns.api == "graphql":
429
+ graphql_show_runs(
430
+ lambda state: state in ("FAILURE", "ERROR"),
431
+ lambda conclusion: conclusion == "FAILURE",
432
+ )
433
+ return
307
434
  for repo in yield_repos():
308
435
  for workflow in repo.get_workflows():
309
436
  last_run = next(iter(workflow.get_runs()), None)
@@ -316,9 +443,16 @@ def runs_show_failing() -> None:
316
443
  configs=[
317
444
  ConfigGithub,
318
445
  ConfigAlgo,
446
+ ConfigRuns,
319
447
  ],
320
448
  )
321
449
  def runs_show_not_success() -> None:
450
+ if ConfigRuns.api == "graphql":
451
+ graphql_show_runs(
452
+ lambda state: state != "SUCCESS",
453
+ lambda conclusion: conclusion != "SUCCESS",
454
+ )
455
+ return
322
456
  for repo in yield_repos():
323
457
  for workflow in repo.get_workflows():
324
458
  last_run = next(iter(workflow.get_runs()), None)
@@ -345,7 +479,6 @@ def pull_all() -> None:
345
479
  [
346
480
  "git",
347
481
  "pull",
348
- # "--tags",
349
482
  ],
350
483
  cwd=folder,
351
484
  )
@@ -365,7 +498,7 @@ def pull_all() -> None:
365
498
 
366
499
 
367
500
  @register_endpoint(
368
- description="Pull all projects from github",
501
+ description="Clone all projects from github",
369
502
  configs=[
370
503
  ConfigGithub,
371
504
  ConfigAlgo,
@@ -378,12 +511,9 @@ def clone_all() -> None:
378
511
  project = repo.name
379
512
  folder = get_repo_folder(repo)
380
513
  all_repo_folders.add(folder)
381
- # print(f"considering [{project}] from [{repo.ssh_url}]...")
382
514
  if os.path.isfile(folder):
383
- # print(f"skipping [{folder}] as it is not to be cloned...")
384
515
  continue
385
516
  if os.path.isdir(folder):
386
- # print(f"skipping [{folder}] as it is already cloned...")
387
517
  continue
388
518
  if ConfigAlgo.dryrun:
389
519
  continue
@@ -397,7 +527,6 @@ def clone_all() -> None:
397
527
  ],
398
528
  cwd=base_dir,
399
529
  stdout=subprocess.DEVNULL,
400
- stderr=subprocess.DEVNULL,
401
530
  )
402
531
  if ConfigAlgo.show_extra:
403
532
  disk_repos = get_all_git_repos(base_dir)
@@ -421,7 +550,7 @@ def workflows_run() -> None:
421
550
  for workflow in repo.get_workflows():
422
551
  print(f"{repo.name}: {workflow.name}...", end="")
423
552
  sys.stdout.flush()
424
- ret = workflow.create_dispatch(ref="master")
553
+ ret = workflow.create_dispatch(ref=repo.default_branch)
425
554
  print(f"{ret}")
426
555
 
427
556
 
@@ -1,5 +1,5 @@
1
1
  """ version which can be consumed from within the module """
2
- VERSION_STR = "0.0.63"
2
+ VERSION_STR = "0.0.65"
3
3
  DESCRIPTION = "Pygitpub will help you work with github"
4
4
  APP_NAME = "pygitpub"
5
5
  LOGGER_NAME = "pygitpub"
@@ -1,25 +1,8 @@
1
1
  """ misc.py """
2
2
 
3
3
  import glob
4
- import logging
5
- import os
6
4
  import os.path
7
5
 
8
- from pygitpub import LOGGER_NAME
9
-
10
-
11
- def get_logger():
12
- return logging.getLogger(LOGGER_NAME)
13
-
14
-
15
- def get_number_of_files(folder: str) -> int:
16
- count = 0
17
- for _root, _directories, files in os.walk(folder):
18
- count += len(files)
19
- return count
20
-
21
-
22
-
23
6
 
24
7
  def get_all_git_repos(base_dir: str) -> set[str]:
25
8
  """ we use */.git under base_dir with recursive=False (which is the default)
File without changes
File without changes