github-dependents-info 3.2.1__tar.gz → 4.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-dependents-info
3
- Version: 3.2.1
3
+ Version: 4.0.0
4
4
  Summary: Collect information about dependencies between a github repo and other repositories. Results available in JSON, markdown and badges.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: github,dependent,used-by,repository,stats,dependencies,using
8
8
  Author: nvuillam
9
9
  Author-email: nicolas.vuillamy@gmail.com
10
- Requires-Python: >=3.10,<4.0
10
+ Requires-Python: >=3.11,<4.0
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Operating System :: OS Independent
@@ -15,12 +15,13 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.9
17
17
  Requires-Dist: beautifulsoup4 (==4.15.0)
18
- Requires-Dist: click (>=8.4.1,<8.5)
18
+ Requires-Dist: click (>=8.5,<8.6)
19
19
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
20
20
  Requires-Dist: idna (>=3.14)
21
- Requires-Dist: pandas (>=2.3.3,<3.0)
21
+ Requires-Dist: pandas (>=3.0.5,<3.1)
22
22
  Requires-Dist: rich (>=15,<15.1)
23
- Requires-Dist: typer (>=0.25.1,<0.26)
23
+ Requires-Dist: soupsieve (>=2.8.4)
24
+ Requires-Dist: typer (>=0.27.1,<0.28)
24
25
  Project-URL: Homepage, https://github.com/nvuillam/github-dependents-info
25
26
  Project-URL: Repository, https://github.com/nvuillam/github-dependents-info
26
27
  Description-Content-Type: text/markdown
@@ -268,25 +269,25 @@ _________________
268
269
  | Parameter | Type | Description |
269
270
  |-----------|--------|----------------------------------------------|
270
271
  | --repo | String | Repository. Example: `oxsecurity/megalinter` |
271
- | -b<br/> --badgemarkdownfile | String | _(optional)_ Path to markdown file where to insert/update **Used by** badge <br/> (must contain tags `<!-- gh-dependents-info-used-by-start -->
272
+ | -b<br/> --badgemarkdownfile | String | _(optional)_ Path to markdown file where to insert/update **Used by** badge <br/> (must contain tags `<!-- gh-dependents-info-used-by-start -->
272
273
  [![Generated by github-dependents-info](https://img.shields.io/static/v1?label=Used%20by&message=51&color=informational&logo=slickpic)](https://github.com/nvuillam/github-dependents-info/blob/main/docs/github-dependents-info.md)<!-- gh-dependents-info-used-by-end -->`) |
273
- | -s<br/> --sort | String | _(optional)_ Sort order: name (default) or stars |
274
- | -x<br/> --minstars | String | _(optional)_ If set, filters repositories to keep only those with more than X stars |
275
- | -m<br/> --markdownfile | String | _(optional)_ Output markdown file file |
276
- | -d<br/> --docurl | String | _(optional)_ Hyperlink to use when clicking on badge markdown file badge. (Default: link to markdown file) |
277
- | -p<br/> --mergepackages | String | _(optional)_ In case of multiple packages, merge their stats in a single one in markdown and json output |
278
- | -j<br/> --json | String | _(optional)_ Output in json format |
279
- | -u<br/> --owner | String | _(optional)_ If set, filters repositories to keep only those owned by the specified user/organization |
280
- | -n<br/> --max-scraped-pages | Integer | _(optional)_ Maximum number of GitHub pages to scrape per package (0 uses all available pages) |
281
- | --pagination<br/> --no-pagination | Boolean | _(optional)_ Enable (default) or disable pagination when writing markdown output |
282
- | --page-size | Integer | _(optional)_ Number of repositories per markdown page when pagination is enabled (default: 500) |
283
- | --llm-summary<br/> --no-llm-summary | Boolean | _(optional)_ Generate an AI usage summary in markdown output when an LLM API key is present (default: enabled) |
284
- | --llm-model | String | _(optional)_ LiteLLM model to use for the summary. If not set, a lightweight model is selected based on the detected API key provider |
285
- | --llm-max-repos | Integer | _(optional)_ Max dependent repos included in the summary prompt payload (default: 500) |
286
- | --llm-max-words | Integer | _(optional)_ Max words for the generated summary (default: 300) |
287
- | --llm-timeout | Float | _(optional)_ Timeout (seconds) for the summary LLM call (default: 120) |
288
- | -v<br/> --version | Boolean | _(optional)_ Displays version of github-dependents-info |
289
- | --verbose | Boolean | _(optional)_ Verbose output |
274
+ | -s<br/> --sort | String | _(optional)_ Sort order: name (default) or stars |
275
+ | -x<br/> --minstars | String | _(optional)_ If set, filters repositories to keep only those with more than X stars |
276
+ | -m<br/> --markdownfile | String | _(optional)_ Output markdown file file |
277
+ | -d<br/> --docurl | String | _(optional)_ Hyperlink to use when clicking on badge markdown file badge. (Default: link to markdown file) |
278
+ | -p<br/> --mergepackages | String | _(optional)_ In case of multiple packages, merge their stats in a single one in markdown and json output |
279
+ | -j<br/> --json | String | _(optional)_ Output in json format |
280
+ | -u<br/> --owner | String | _(optional)_ If set, filters repositories to keep only those owned by the specified user/organization |
281
+ | -n<br/> --max-scraped-pages | Integer | _(optional)_ Maximum number of GitHub pages to scrape per package (0 uses all available pages) |
282
+ | --pagination<br/> --no-pagination | Boolean | _(optional)_ Enable (default) or disable pagination when writing markdown output |
283
+ | --page-size | Integer | _(optional)_ Number of repositories per markdown page when pagination is enabled (default: 500) |
284
+ | --llm-summary<br/> --no-llm-summary | Boolean | _(optional)_ Generate an AI usage summary in markdown output when an LLM API key is present (default: enabled) |
285
+ | --llm-model | String | _(optional)_ LiteLLM model to use for the summary. If not set, a lightweight model is selected based on the detected API key provider |
286
+ | --llm-max-repos | Integer | _(optional)_ Max dependent repos included in the summary prompt payload (default: 500) |
287
+ | --llm-max-words | Integer | _(optional)_ Max words for the generated summary (default: 300) |
288
+ | --llm-timeout | Float | _(optional)_ Timeout (seconds) for the summary LLM call (default: 120) |
289
+ | -v<br/> --version | Boolean | _(optional)_ Displays version of github-dependents-info |
290
+ | --verbose | Boolean | _(optional)_ Verbose output |
290
291
 
291
292
  Badge tags example (the tool replaces everything between the markers):
292
293
 
@@ -367,6 +368,7 @@ If an LLM API key is detected in the environment (for example `OPENAI_API_KEY`),
367
368
  - Override model with `--llm-model` (or env var `GITHUB_DEPENDENTS_INFO_LLM_MODEL` / `LITELLM_MODEL`)
368
369
  - Adjust max summary length with `--llm-max-words` (or env var `GITHUB_DEPENDENTS_INFO_LLM_MAX_WORDS`)
369
370
  - The summary is cached in `--csvdirectory` (file `llm_summary_<repo>.json`) and reused on subsequent runs
371
+ - A previously generated summary is never lost: if a new summary can not be generated (no API key anymore, no credit left, rate limit, timeout...), the summary found in the cache or in the existing `--markdownfile` is reused
370
372
 
371
373
 
372
374
  ## Use as GitHub Action
@@ -241,25 +241,25 @@ _________________
241
241
  | Parameter | Type | Description |
242
242
  |-----------|--------|----------------------------------------------|
243
243
  | --repo | String | Repository. Example: `oxsecurity/megalinter` |
244
- | -b<br/> --badgemarkdownfile | String | _(optional)_ Path to markdown file where to insert/update **Used by** badge <br/> (must contain tags `<!-- gh-dependents-info-used-by-start -->
244
+ | -b<br/> --badgemarkdownfile | String | _(optional)_ Path to markdown file where to insert/update **Used by** badge <br/> (must contain tags `<!-- gh-dependents-info-used-by-start -->
245
245
  [![Generated by github-dependents-info](https://img.shields.io/static/v1?label=Used%20by&message=51&color=informational&logo=slickpic)](https://github.com/nvuillam/github-dependents-info/blob/main/docs/github-dependents-info.md)<!-- gh-dependents-info-used-by-end -->`) |
246
- | -s<br/> --sort | String | _(optional)_ Sort order: name (default) or stars |
247
- | -x<br/> --minstars | String | _(optional)_ If set, filters repositories to keep only those with more than X stars |
248
- | -m<br/> --markdownfile | String | _(optional)_ Output markdown file file |
249
- | -d<br/> --docurl | String | _(optional)_ Hyperlink to use when clicking on badge markdown file badge. (Default: link to markdown file) |
250
- | -p<br/> --mergepackages | String | _(optional)_ In case of multiple packages, merge their stats in a single one in markdown and json output |
251
- | -j<br/> --json | String | _(optional)_ Output in json format |
252
- | -u<br/> --owner | String | _(optional)_ If set, filters repositories to keep only those owned by the specified user/organization |
253
- | -n<br/> --max-scraped-pages | Integer | _(optional)_ Maximum number of GitHub pages to scrape per package (0 uses all available pages) |
254
- | --pagination<br/> --no-pagination | Boolean | _(optional)_ Enable (default) or disable pagination when writing markdown output |
255
- | --page-size | Integer | _(optional)_ Number of repositories per markdown page when pagination is enabled (default: 500) |
256
- | --llm-summary<br/> --no-llm-summary | Boolean | _(optional)_ Generate an AI usage summary in markdown output when an LLM API key is present (default: enabled) |
257
- | --llm-model | String | _(optional)_ LiteLLM model to use for the summary. If not set, a lightweight model is selected based on the detected API key provider |
258
- | --llm-max-repos | Integer | _(optional)_ Max dependent repos included in the summary prompt payload (default: 500) |
259
- | --llm-max-words | Integer | _(optional)_ Max words for the generated summary (default: 300) |
260
- | --llm-timeout | Float | _(optional)_ Timeout (seconds) for the summary LLM call (default: 120) |
261
- | -v<br/> --version | Boolean | _(optional)_ Displays version of github-dependents-info |
262
- | --verbose | Boolean | _(optional)_ Verbose output |
246
+ | -s<br/> --sort | String | _(optional)_ Sort order: name (default) or stars |
247
+ | -x<br/> --minstars | String | _(optional)_ If set, filters repositories to keep only those with more than X stars |
248
+ | -m<br/> --markdownfile | String | _(optional)_ Output markdown file file |
249
+ | -d<br/> --docurl | String | _(optional)_ Hyperlink to use when clicking on badge markdown file badge. (Default: link to markdown file) |
250
+ | -p<br/> --mergepackages | String | _(optional)_ In case of multiple packages, merge their stats in a single one in markdown and json output |
251
+ | -j<br/> --json | String | _(optional)_ Output in json format |
252
+ | -u<br/> --owner | String | _(optional)_ If set, filters repositories to keep only those owned by the specified user/organization |
253
+ | -n<br/> --max-scraped-pages | Integer | _(optional)_ Maximum number of GitHub pages to scrape per package (0 uses all available pages) |
254
+ | --pagination<br/> --no-pagination | Boolean | _(optional)_ Enable (default) or disable pagination when writing markdown output |
255
+ | --page-size | Integer | _(optional)_ Number of repositories per markdown page when pagination is enabled (default: 500) |
256
+ | --llm-summary<br/> --no-llm-summary | Boolean | _(optional)_ Generate an AI usage summary in markdown output when an LLM API key is present (default: enabled) |
257
+ | --llm-model | String | _(optional)_ LiteLLM model to use for the summary. If not set, a lightweight model is selected based on the detected API key provider |
258
+ | --llm-max-repos | Integer | _(optional)_ Max dependent repos included in the summary prompt payload (default: 500) |
259
+ | --llm-max-words | Integer | _(optional)_ Max words for the generated summary (default: 300) |
260
+ | --llm-timeout | Float | _(optional)_ Timeout (seconds) for the summary LLM call (default: 120) |
261
+ | -v<br/> --version | Boolean | _(optional)_ Displays version of github-dependents-info |
262
+ | --verbose | Boolean | _(optional)_ Verbose output |
263
263
 
264
264
  Badge tags example (the tool replaces everything between the markers):
265
265
 
@@ -340,6 +340,7 @@ If an LLM API key is detected in the environment (for example `OPENAI_API_KEY`),
340
340
  - Override model with `--llm-model` (or env var `GITHUB_DEPENDENTS_INFO_LLM_MODEL` / `LITELLM_MODEL`)
341
341
  - Adjust max summary length with `--llm-max-words` (or env var `GITHUB_DEPENDENTS_INFO_LLM_MAX_WORDS`)
342
342
  - The summary is cached in `--csvdirectory` (file `llm_summary_<repo>.json`) and reused on subsequent runs
343
+ - A previously generated summary is never lost: if a new summary can not be generated (no API key anymore, no credit left, rate limit, timeout...), the summary found in the cache or in the existing `--markdownfile` is reused
343
344
 
344
345
 
345
346
  ## Use as GitHub Action
@@ -1,10 +1,12 @@
1
- import logging
2
1
  from typing import Annotated
3
2
 
3
+ import logging
4
+
4
5
  import typer
6
+ from rich.console import Console
7
+
5
8
  from github_dependents_info import version
6
9
  from github_dependents_info.gh_dependents_info import GithubDependentsInfo
7
- from rich.console import Console
8
10
 
9
11
  app = typer.Typer(
10
12
  name="github-dependents-info",
@@ -13,35 +13,40 @@ import numpy as np
13
13
  import pandas as pd
14
14
  from bs4 import BeautifulSoup
15
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Markers surrounding the AI summary in generated markdown, so a previous summary
19
+ # can be found again and reused when a new one can not be generated.
20
+ LLM_SUMMARY_START_MARKER = "<!-- gh-dependents-info-summary-start -->"
21
+ LLM_SUMMARY_END_MARKER = "<!-- gh-dependents-info-summary-end -->"
22
+
16
23
 
17
24
  class GithubDependentsInfo:
18
25
  def __init__(self, repo, **options) -> None:
19
26
  self.repo = repo
20
- self.outputrepo = self.repo if "outputrepo" not in options else options["outputrepo"]
27
+ self.outputrepo = options.get("outputrepo", self.repo)
21
28
  if self.outputrepo is None or self.outputrepo == "" or len(self.outputrepo) < 4:
22
29
  self.outputrepo = self.repo
23
30
  self.url_init = f"https://github.com/{self.repo}/network/dependents"
24
31
  self.url_starts_with = f"/{self.repo}/network/dependents" + "?package_id="
25
- self.sort_key = "name" if "sort_key" not in options else options["sort_key"]
26
- self.min_stars = None if "min_stars" not in options else options["min_stars"]
27
- self.json_output = True if "json_output" in options and options["json_output"] is True else False
28
- self.merge_packages = True if "merge_packages" in options and options["merge_packages"] is True else False
29
- self.doc_url = options["doc_url"] if "doc_url" in options else None
30
- self.markdown_file = options["markdown_file"] if "markdown_file" in options else None
31
- self.badge_color = options["badge_color"] if "badge_color" in options else "informational"
32
- self.debug = True if "debug" in options and options["debug"] is True else False
33
- self.overwrite_progress = (
34
- True if "overwrite_progress" in options and options["overwrite_progress"] is True else False
35
- )
32
+ self.sort_key = options.get("sort_key", "name")
33
+ self.min_stars = options.get("min_stars", None)
34
+ self.json_output = options.get("json_output") is True
35
+ self.merge_packages = options.get("merge_packages") is True
36
+ self.doc_url = options.get("doc_url", None)
37
+ self.markdown_file = options.get("markdown_file", None)
38
+ self.badge_color = options.get("badge_color", "informational")
39
+ self.debug = options.get("debug") is True
40
+ self.overwrite_progress = options.get("overwrite_progress") is True
36
41
  self.csv_directory = (
37
42
  Path(options["csv_directory"])
38
43
  if ("csv_directory" in options and options["csv_directory"] is not None)
39
44
  else None
40
45
  )
41
- self.owner = options["owner"] if "owner" in options else None
42
- self.max_scraped_pages = options["max_scraped_pages"] if "max_scraped_pages" in options else 0
46
+ self.owner = options.get("owner", None)
47
+ self.max_scraped_pages = options.get("max_scraped_pages", 0)
43
48
  self.max_concurrent_requests = options.get("max_concurrent_requests", 10)
44
- self.pagination = True if "pagination" not in options else options["pagination"]
49
+ self.pagination = options.get("pagination", True)
45
50
  self.page_size = options.get("page_size", 500)
46
51
  self.total_sum = 0
47
52
  self.total_public_sum = 0
@@ -51,7 +56,7 @@ class GithubDependentsInfo:
51
56
  self.all_public_dependent_repos = []
52
57
  self.badges = {}
53
58
  self.result = {}
54
- self.time_delay = options["time_delay"] if "time_delay" in options else 0.1
59
+ self.time_delay = options.get("time_delay", 0.1)
55
60
  self.http_retry_attempts = options.get("http_retry_attempts", 5)
56
61
  self.http_retry_initial_delay = options.get("http_retry_initial_delay", max(self.time_delay, 1.0))
57
62
  self.http_retry_backoff = options.get("http_retry_backoff", 2.0)
@@ -66,12 +71,13 @@ class GithubDependentsInfo:
66
71
  self.llm_model = (
67
72
  options.get("llm_model") or os.getenv("GITHUB_DEPENDENTS_INFO_LLM_MODEL") or os.getenv("LITELLM_MODEL")
68
73
  )
69
- self.llm_max_repos = int(options.get("llm_max_repos", os.getenv("GITHUB_DEPENDENTS_INFO_LLM_MAX_REPOS", 500)))
70
- self.llm_max_words = int(options.get("llm_max_words", os.getenv("GITHUB_DEPENDENTS_INFO_LLM_MAX_WORDS", 300)))
71
- self.llm_timeout = float(options.get("llm_timeout", os.getenv("GITHUB_DEPENDENTS_INFO_LLM_TIMEOUT", 120)))
74
+ self.llm_max_repos = int(options.get("llm_max_repos", os.getenv("GITHUB_DEPENDENTS_INFO_LLM_MAX_REPOS", "500")))
75
+ self.llm_max_words = int(options.get("llm_max_words", os.getenv("GITHUB_DEPENDENTS_INFO_LLM_MAX_WORDS", "300")))
76
+ self.llm_timeout = float(options.get("llm_timeout", os.getenv("GITHUB_DEPENDENTS_INFO_LLM_TIMEOUT", "120")))
72
77
  self.llm_model_used: str | None = None
73
78
  self.llm_summary: str | None = None
74
79
  self.llm_summary_error: str | None = None
80
+ self.llm_summary_reused: bool = False
75
81
 
76
82
  def collect(self):
77
83
  """Main entry point - synchronous wrapper for async collection."""
@@ -95,13 +101,13 @@ class GithubDependentsInfo:
95
101
  if self.owner:
96
102
  url += "&owner=" + self.owner
97
103
  if self.debug is True:
98
- logging.info("Package " + package["name"] + ": browsing " + url + " ...")
104
+ logger.info("Package " + package["name"] + ": browsing " + url + " ...")
99
105
  else:
100
106
  url = self.url_init
101
107
  if self.owner:
102
108
  url += "?owner=" + self.owner
103
109
  if self.debug is True:
104
- logging.info("Package " + self.repo + ": browsing " + url + " ...")
110
+ logger.info("Package " + self.repo + ": browsing " + url + " ...")
105
111
  package["url"] = url
106
112
  package["public_dependent_stars"] = 0
107
113
 
@@ -115,7 +121,7 @@ class GithubDependentsInfo:
115
121
  result = sorted(result, key=lambda d: d[self.sort_key])
116
122
  if self.debug is True:
117
123
  for r in result:
118
- logging.info(r)
124
+ logger.info(r)
119
125
 
120
126
  # Build package stats
121
127
  total_public_dependents = len(result)
@@ -151,8 +157,8 @@ class GithubDependentsInfo:
151
157
 
152
158
  # Output
153
159
  if self.debug is True:
154
- logging.info("Total for package: " + str(total_public_dependents))
155
- logging.info("")
160
+ logger.info("Total for package: " + str(total_public_dependents))
161
+ logger.info("")
156
162
  # Save crawl progress
157
163
  self.save_progress(package) # only saves if csv_directory is provided
158
164
 
@@ -225,10 +231,73 @@ class GithubDependentsInfo:
225
231
  if summary:
226
232
  self.llm_summary = summary
227
233
  return True
228
- except Exception as exc:
229
- logging.warning("Failed to load cached LLM summary: %s", exc)
234
+ except Exception as exc: # noqa: BLE001 - a corrupted cache must never break the run
235
+ logger.warning("Failed to load cached LLM summary: %s", exc)
230
236
  return False
231
237
 
238
+ def _extract_summary_from_markdown_content(self, content: str) -> str | None:
239
+ """Extract a summary previously written by github-dependents-info in a markdown content."""
240
+ marked = re.search(
241
+ re.escape(LLM_SUMMARY_START_MARKER) + "(.*?)" + re.escape(LLM_SUMMARY_END_MARKER),
242
+ content,
243
+ re.DOTALL,
244
+ )
245
+ if marked is not None:
246
+ block = marked.group(1)
247
+ else:
248
+ # Markdown generated before summary markers existed: fallback on the "## Summary" section
249
+ heading = re.search(r"^##\s+Summary\s*$", content, re.MULTILINE)
250
+ if heading is None:
251
+ return None
252
+ block = content[heading.end() :]
253
+ block = re.sub(r"^\s*##\s+Summary\s*$", "", block, count=1, flags=re.MULTILINE)
254
+ # Keep only the summary text: stop at the next heading, badge, table or footer
255
+ summary_lines: list[str] = []
256
+ for line in block.splitlines():
257
+ stripped = line.strip()
258
+ if stripped.startswith(("#", "[![", "|", "_Generated using [")):
259
+ break
260
+ summary_lines.append(line)
261
+ return "\n".join(summary_lines).strip() or None
262
+
263
+ def _load_previous_markdown_summary(self) -> str | None:
264
+ """Read the summary generated by a previous run in the output markdown file."""
265
+ if not self.markdown_file:
266
+ return None
267
+ try:
268
+ if not os.path.isfile(self.markdown_file):
269
+ return None
270
+ with open(self.markdown_file, encoding="utf-8") as f:
271
+ content = f.read()
272
+ except (OSError, UnicodeDecodeError) as exc:
273
+ logger.warning("Failed to read previous summary from %s: %s", self.markdown_file, exc)
274
+ return None
275
+ return self._extract_summary_from_markdown_content(content)
276
+
277
+ def reuse_previous_llm_summary(self, reason: str) -> bool:
278
+ """Reuse the summary of a previous run when a new one can not be generated.
279
+
280
+ This avoids losing an existing summary when the LLM call is impossible
281
+ (no API key anymore, no credit left, rate limit, timeout...).
282
+ """
283
+ if self.llm_summary:
284
+ return True
285
+ if self.load_llm_summary():
286
+ self.llm_summary_reused = True
287
+ else:
288
+ previous_summary = self._load_previous_markdown_summary()
289
+ if not previous_summary:
290
+ return False
291
+ self.llm_summary = previous_summary
292
+ self.llm_summary_reused = True
293
+ # Keep it in the progress directory so next runs can find it faster
294
+ self.save_llm_summary()
295
+ logger.warning(
296
+ "Unable to generate a new AI summary (%s): reusing the summary generated by a previous run",
297
+ reason,
298
+ )
299
+ return True
300
+
232
301
  def save_llm_summary(self) -> None:
233
302
  """Persist LLM summary into progress directory if enabled."""
234
303
  cache_path = self._llm_summary_cache_path()
@@ -245,9 +314,9 @@ class GithubDependentsInfo:
245
314
  }
246
315
  with open(cache_path, "w", encoding="utf-8") as f:
247
316
  json.dump(payload, f, indent=2)
248
- except Exception as exc:
317
+ except Exception as exc: # noqa: BLE001 - caching is best effort
249
318
  if self.debug:
250
- logging.warning("Failed to save cached LLM summary: %s", exc)
319
+ logger.warning("Failed to save cached LLM summary: %s", exc)
251
320
 
252
321
  def _prepare_llm_summary_payload(self) -> dict:
253
322
  """Prepare a compact data payload for the LLM prompt."""
@@ -295,6 +364,7 @@ class GithubDependentsInfo:
295
364
 
296
365
  provider_info = self._detect_llm_provider()
297
366
  if provider_info is None:
367
+ self.reuse_previous_llm_summary("no LLM API key found in environment variables")
298
368
  return
299
369
 
300
370
  # Default model if none was provided
@@ -346,9 +416,13 @@ class GithubDependentsInfo:
346
416
  if content:
347
417
  self.llm_summary = str(content).strip()
348
418
  self.save_llm_summary()
349
- except Exception as exc:
419
+ else:
420
+ self.llm_summary_error = "LLM returned an empty summary"
421
+ self.reuse_previous_llm_summary(self.llm_summary_error)
422
+ except Exception as exc: # noqa: BLE001 - any provider error must not break the run
350
423
  self.llm_summary_error = str(exc)
351
- logging.warning("Failed to generate LLM summary: %s", exc)
424
+ logger.warning("Failed to generate LLM summary: %s", exc)
425
+ self.reuse_previous_llm_summary(str(exc))
352
426
  finally:
353
427
  # LiteLLM can keep async clients around. Ensure they're closed before
354
428
  # asyncio.run() tears down the event loop to avoid:
@@ -375,9 +449,9 @@ class GithubDependentsInfo:
375
449
  result = close_fn()
376
450
  if inspect.isawaitable(result):
377
451
  await result
378
- except Exception as exc:
452
+ except Exception as exc: # noqa: BLE001 - cleanup is best effort
379
453
  if self.debug:
380
- logging.debug("LiteLLM async client cleanup skipped: %s", exc)
454
+ logger.debug("LiteLLM async client cleanup skipped: %s", exc)
381
455
 
382
456
  def _extract_owner_repo(self, dependent_row):
383
457
  repo_anchor = dependent_row.find("a", {"data-hovercard-type": "repository"})
@@ -426,7 +500,7 @@ class GithubDependentsInfo:
426
500
  if "{{" in package_name:
427
501
  continue
428
502
  if self.debug is True:
429
- logging.info(package_name)
503
+ logger.info(package_name)
430
504
  self.packages += [{"id": package_id, "name": package_name}]
431
505
  if len(self.packages) == 0:
432
506
  self.packages = [{"id": None, "name": self.repo}]
@@ -452,7 +526,10 @@ class GithubDependentsInfo:
452
526
  sources_all_df.set_index("name", inplace=True)
453
527
  source_df = pd.json_normalize(source_info).set_index("name", drop=True)
454
528
  for column in source_df.columns:
455
- if source_df[column].dtype == object and column in sources_all_df.columns:
529
+ # pandas >= 3 infers text columns as StringDtype rather than object, so a
530
+ # `dtype == object` test misses them and writing text into a numeric column
531
+ # raises instead of upcasting. Widen the target column for any non-numeric value.
532
+ if column in sources_all_df.columns and not pd.api.types.is_numeric_dtype(source_df[column]):
456
533
  sources_all_df[column] = sources_all_df[column].astype("object")
457
534
  sources_all_df.loc[source_df.index, column] = source_df[column]
458
535
  sources_all_df.reset_index(inplace=True, drop=False)
@@ -567,9 +644,26 @@ class GithubDependentsInfo:
567
644
  """Build the standard footer for markdown files."""
568
645
  return [
569
646
  "",
570
- "_Generated using [github-dependents-info]"
571
- "(https://github.com/nvuillam/github-dependents-info), "
572
- "by [Nicolas Vuillamy](https://github.com/nvuillam)_",
647
+ (
648
+ "_Generated using [github-dependents-info]"
649
+ "(https://github.com/nvuillam/github-dependents-info), "
650
+ "by [Nicolas Vuillamy](https://github.com/nvuillam)_"
651
+ ),
652
+ ]
653
+
654
+ def _append_llm_summary(self, md_lines: list) -> None:
655
+ """Append the AI summary section, surrounded by markers so it can be reused by later runs."""
656
+ if not self.llm_summary:
657
+ return
658
+ md_lines += [
659
+ LLM_SUMMARY_START_MARKER,
660
+ "",
661
+ "## Summary",
662
+ "",
663
+ self.llm_summary.strip(),
664
+ "",
665
+ LLM_SUMMARY_END_MARKER,
666
+ "",
573
667
  ]
574
668
 
575
669
  def _append_summary_table(self, md_lines: list) -> None:
@@ -606,8 +700,7 @@ class GithubDependentsInfo:
606
700
  self._append_summary_table(md_lines)
607
701
 
608
702
  # Optional LLM summary
609
- if self.llm_summary:
610
- md_lines += ["## Summary", "", self.llm_summary.strip(), ""]
703
+ self._append_llm_summary(md_lines)
611
704
 
612
705
  # Single dependents list
613
706
  if self.merge_packages is True:
@@ -702,8 +795,7 @@ class GithubDependentsInfo:
702
795
  self._append_summary_table(md_lines)
703
796
 
704
797
  # Optional LLM summary (only on first page)
705
- if self.llm_summary:
706
- md_lines += ["## Summary", "", self.llm_summary.strip(), ""]
798
+ self._append_llm_summary(md_lines)
707
799
 
708
800
  # Calculate start and end indices for this page
709
801
  start_idx = (page_num - 1) * self.page_size
@@ -840,12 +932,9 @@ class GithubDependentsInfo:
840
932
  md_lines += [f"|{image_md} &nbsp; [{repo_label}](https://github.com/{repo_label}) | {repo_stars} |"]
841
933
 
842
934
  def build_badge(self, label, nb, **options):
843
- if "url" in options:
844
- url = options["url"]
845
- else:
846
- url = f"https://github.com/{self.repo}/network/dependents"
935
+ url = options.get("url", f"https://github.com/{self.repo}/network/dependents")
847
936
  return (
848
- f"[![Generated by github-dependents-info](https://img.shields.io/static/v1?label={label}&message={str(nb)}"
937
+ f"[![Generated by github-dependents-info](https://img.shields.io/static/v1?label={label}&message={nb!s}"
849
938
  + f"&color={self.badge_color}&logo=slickpic)]({url})"
850
939
  )
851
940
 
@@ -890,7 +979,7 @@ class GithubDependentsInfo:
890
979
  raise
891
980
  delay = self._compute_retry_delay(attempt, response=exc.response)
892
981
  if self.debug:
893
- logging.warning(
982
+ logger.warning(
894
983
  "HTTP %s while fetching %s (attempt %s/%s). Retrying in %.1fs",
895
984
  status_code,
896
985
  url,
@@ -905,7 +994,7 @@ class GithubDependentsInfo:
905
994
  raise
906
995
  delay = self._compute_retry_delay(attempt)
907
996
  if self.debug:
908
- logging.warning(
997
+ logger.warning(
909
998
  "Request error while fetching %s (attempt %s/%s): %s. Retrying in %.1fs",
910
999
  url,
911
1000
  attempt,
@@ -952,12 +1041,12 @@ class GithubDependentsInfo:
952
1041
  # Check if we've reached the max pages limit
953
1042
  if self.max_scraped_pages > 0 and page_number >= self.max_scraped_pages:
954
1043
  if self.debug is True:
955
- logging.info(f" - reached max scraped pages limit ({self.max_scraped_pages})")
1044
+ logger.info(f" - reached max scraped pages limit ({self.max_scraped_pages})")
956
1045
  break
957
1046
  next_link = u["href"]
958
1047
  page_number += 1
959
1048
  if self.debug is True:
960
- logging.info(f" - discovered page {page_number}")
1049
+ logger.info(f" - discovered page {page_number}")
961
1050
  break
962
1051
 
963
1052
  if next_link is None:
@@ -970,13 +1059,13 @@ class GithubDependentsInfo:
970
1059
  current_soup = BeautifulSoup(current_content, "html.parser")
971
1060
  except (httpx.RequestError, httpx.TimeoutException) as e:
972
1061
  if self.debug:
973
- logging.warning(f"Failed to fetch page during discovery: {e}")
1062
+ logger.warning(f"Failed to fetch page during discovery: {e}")
974
1063
  break
975
1064
 
976
1065
  # Fetch additional pages in parallel if any
977
1066
  if urls_to_fetch:
978
1067
  if self.debug:
979
- logging.info(f" - fetching {len(urls_to_fetch)} additional pages in parallel...")
1068
+ logger.info(f" - fetching {len(urls_to_fetch)} additional pages in parallel...")
980
1069
  tasks = [self.fetch_page(client, page_url, semaphore) for page_url in urls_to_fetch]
981
1070
  additional_pages = await asyncio.gather(*tasks, return_exceptions=True)
982
1071
  pages_content += additional_pages
@@ -987,7 +1076,7 @@ class GithubDependentsInfo:
987
1076
  for page_content in pages_content:
988
1077
  if isinstance(page_content, Exception):
989
1078
  if self.debug:
990
- logging.warning(f"Failed to fetch page: {page_content}")
1079
+ logger.warning(f"Failed to fetch page: {page_content}")
991
1080
  continue
992
1081
 
993
1082
  soup = BeautifulSoup(page_content, "html.parser")
@@ -997,7 +1086,7 @@ class GithubDependentsInfo:
997
1086
  owner_repo = self._extract_owner_repo(t)
998
1087
  if owner_repo is None:
999
1088
  if self.debug:
1000
- logging.warning("Skipping dependent row without repository link")
1089
+ logger.warning("Skipping dependent row without repository link")
1001
1090
  continue
1002
1091
  owner_name, repo_name = owner_repo
1003
1092
  star_svg = t.find("svg", {"class": "octicon-star"})
@@ -1068,6 +1157,6 @@ class GithubDependentsInfo:
1068
1157
  number_as_string = number_as_string.replace(",", "").replace(" ", "")
1069
1158
  try:
1070
1159
  return int(number_as_string)
1071
- except Exception:
1072
- logging.warning(f'WARNING: Unable to get integer from "{number_as_string}"')
1160
+ except (ValueError, TypeError):
1161
+ logger.warning(f'WARNING: Unable to get integer from "{number_as_string}"')
1073
1162
  return 0
@@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
5
5
 
6
6
  [project]
7
7
  name = "github-dependents-info"
8
- version = "3.2.1"
8
+ version = "4.0.0"
9
9
  description = "Collect information about dependencies between a github repo and other repositories. Results available in JSON, markdown and badges."
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
13
13
  authors = [
14
14
  {name = "nvuillam", email = "nicolas.vuillamy@gmail.com"},
15
15
  ]
16
- requires-python = ">=3.10,<4.0"
16
+ requires-python = ">=3.11,<4.0"
17
17
 
18
18
  # Keywords description https://python-poetry.org/docs/pyproject/#keywords
19
19
  keywords = ["github", "dependent", "used-by", "repository", "stats", "dependencies", "using"]
@@ -37,41 +37,42 @@ Repository = "https://github.com/nvuillam/github-dependents-info"
37
37
  "github-dependents-info" = "github_dependents_info.__main__:app"
38
38
 
39
39
  [tool.poetry.dependencies]
40
- python = ">=3.10,<4.0"
40
+ python = ">=3.11,<4.0"
41
41
 
42
- click = ">=8.4.1,<8.5"
43
- typer = ">=0.25.1,<0.26"
42
+ click = ">=8.5,<8.6"
43
+ typer = ">=0.27.1,<0.28"
44
44
  rich = ">=15,<15.1"
45
45
  beautifulsoup4 = "4.15.0"
46
- pandas = ">=2.3.3,<3.0"
46
+ pandas = ">=3.0.5,<3.1"
47
47
  httpx = "^0.28.1"
48
48
  idna = ">=3.14"
49
+ soupsieve = ">=2.8.4" # security floor (CVE-2026-49476, CVE-2026-49477), pulled by beautifulsoup4
49
50
 
50
51
  [tool.poetry.group.dev.dependencies]
51
52
  bandit = "^1.7.5"
52
53
  black = ">=26.3.1"
53
54
  darglint = "^1.8.1"
54
- isort = {extras = ["colors"], version = "^8.0.0"}
55
+ isort = {extras = ["colors"], version = "^9.0.0"}
55
56
  mypy = "^2.0"
56
57
  mypy-extensions = "^1.0.0"
57
58
  pre-commit = "^4.0.0"
58
59
  pydocstyle = "^6.3.0"
59
60
  pylint = "^4.0.0"
60
- pytest = "^9.0.0"
61
+ pytest = "^9.1.1"
61
62
  pyupgrade = "^3.4.0"
62
- safety = "^3.8.1"
63
63
  coverage = "^7.3.4"
64
- coverage-badge = "^1.1.0"
65
- cryptography = ">=48.0.0"
64
+ anybadge = "^1.16.0" # replaces coverage-badge, which imports pkg_resources (removed in setuptools 82)
65
+ cryptography = ">=50.0.0"
66
66
  pytest-html = "^4.1.1"
67
67
  pytest-cov = "^7.0.0"
68
- marshmallow = ">=4.3,<4.4"
68
+ marshmallow = ">=4.3.1,<4.4"
69
69
  authlib = ">=1.7.0"
70
70
  requests = ">=2.34.2"
71
71
  urllib3 = ">=2.6.3"
72
72
  filelock = ">=3.20.4"
73
73
  virtualenv = ">=21.3.0"
74
- nltk = ">=3.9.4"
74
+ nltk = ">=3.10.0"
75
+ setuptools = ">=83.0.0"
75
76
 
76
77
 
77
78
  [[tool.poetry.source]]