since-dev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
since_dev/init.py ADDED
@@ -0,0 +1,119 @@
1
+ """`since init`: the files that put Since in front of a coding agent, written into the repository so a clone carries them.
2
+
3
+ It adds its own entry and nothing else: other servers stay as they are, and a file it cannot read as JSON is left alone.
4
+ """
5
+
6
+ import json
7
+ import re
8
+
9
+ MCP_URL = "https://api.since.dev/mcp"
10
+ TARGETS = [
11
+ {"file": ".mcp.json", "key": "mcpServers", "entry": {"type": "http", "url": MCP_URL}, "what": "Claude Code"},
12
+ {"file": ".cursor/mcp.json", "key": "mcpServers", "entry": {"url": MCP_URL}, "what": "Cursor"},
13
+ {"file": ".vscode/mcp.json", "key": "servers", "entry": {"type": "http", "url": MCP_URL}, "what": "VS Code"},
14
+ ]
15
+ RULE_FILE = "AGENTS.md"
16
+ START = "<!-- since:start -->"
17
+ END = "<!-- since:end -->"
18
+ # The rule every listing ships; a test holds this and the JavaScript copy to the same words.
19
+ RULE = "\n".join([
20
+ "When you change a dependency version, a manifest or lockfile, an AI model ID, a runtime version, a Docker base image or a CI runner label, check it with the Since.dev MCP tools first:",
21
+ "",
22
+ "- `check_code` with the manifest, lockfile, model ID or `surface` (`runtime:<product>@<version>`) reports what is affected, on which line, the days left and the replacement.",
23
+ "- `whats_changed` with the package and `since` set to the current version lists every breaking change and deprecation between that version and today, each with its source.",
24
+ "",
25
+ "Treat `needs_review` and `unknown` as not settled, never as safe. Use the replacement the answer names. `fix_code` opens a fix pull request for a connected repository, and the editor asks before it runs.",
26
+ ])
27
+ BLOCK = "%s\n## Since.dev\n\n%s\n%s" % (START, RULE, END)
28
+
29
+
30
+ def _refuse_constant(name):
31
+ raise ValueError(name)
32
+
33
+
34
+ def loads(text):
35
+ """json.loads as JSON.parse reads it: NaN and Infinity are not JSON."""
36
+ return json.loads(text, parse_constant=_refuse_constant)
37
+
38
+
39
+ def dumps(value, indent):
40
+ return json.dumps(value, indent=indent, ensure_ascii=False)
41
+
42
+
43
+ def _indent_of(text):
44
+ """The indent a JSON file already uses: its first indented line decides, two spaces when there is none."""
45
+ match = re.search(r"^([ \t]+)\S", text, re.M)
46
+ if not match:
47
+ return 2
48
+ return "\t" if "\t" in match.group(1) else len(match.group(1))
49
+
50
+
51
+ def merge_config(text, key, entry):
52
+ """A config file with the Since entry in it. status: new, updated, same, or skipped when the file is not plain JSON."""
53
+ if text is None or text.strip() == "":
54
+ return {"status": "new", "text": dumps({key: {"since": entry}}, 2) + "\n"}
55
+ try:
56
+ data = loads(text)
57
+ except ValueError:
58
+ return {"status": "skipped", "text": text}
59
+ if not isinstance(data, dict) or (key in data and not isinstance(data[key], dict)):
60
+ return {"status": "skipped", "text": text}
61
+ if key in data and isinstance(data[key].get("since"), dict) and data[key]["since"] == entry:
62
+ return {"status": "same", "text": text}
63
+ if key not in data:
64
+ data[key] = {}
65
+ data[key]["since"] = entry
66
+ return {"status": "updated", "text": dumps(data, _indent_of(text)) + "\n"}
67
+
68
+
69
+ def merge_rule(text):
70
+ """AGENTS.md with the rule between its two markers: replaced when the markers are there, added at the end when they are not."""
71
+ if text is None or text.strip() == "":
72
+ return {"status": "new", "text": BLOCK + "\n"}
73
+ start = text.find(START)
74
+ end = -1 if start < 0 else text.find(END, start)
75
+ if start < 0 or end < 0:
76
+ return {"status": "updated", "text": "%s\n\n%s\n" % (text.rstrip("\n"), BLOCK)}
77
+ merged = text[:start] + BLOCK + text[end + len(END):]
78
+ return {"status": "same", "text": text} if merged == text else {"status": "updated", "text": merged}
79
+
80
+
81
+ WORDS = {
82
+ "new": ["Wrote", "Would write"],
83
+ "updated": ["Updated", "Would update"],
84
+ "same": ["Unchanged", "Unchanged"],
85
+ "skipped": ["Skipped", "Skipped"],
86
+ }
87
+
88
+
89
+ def init(io, program, dry_run):
90
+ root = io.root()
91
+ lines = [""]
92
+ skipped = []
93
+ changed = 0
94
+ rows = [dict(target, result=merge_config(io.read_file("%s/%s" % (root, target["file"])), target["key"], target["entry"])) for target in TARGETS]
95
+ rows.append({"file": RULE_FILE, "what": "the rule every coding agent reads", "result": merge_rule(io.read_file("%s/%s" % (root, RULE_FILE)))})
96
+ for row in rows:
97
+ status = row["result"]["status"]
98
+ if status in ("new", "updated"):
99
+ changed += 1
100
+ if not dry_run:
101
+ io.write_file("%s/%s" % (root, row["file"]), row["result"]["text"], None)
102
+ if status == "skipped":
103
+ skipped.append(row)
104
+ lines.append(" %s%s%s" % (WORDS[status][1 if dry_run else 0].ljust(14), row["file"].ljust(20), row["what"]))
105
+ lines.append("")
106
+ for row in skipped:
107
+ lines.append(' %s is not plain JSON, so it was left alone. Add this under "%s" by hand:' % (row["file"], row["key"]))
108
+ lines.append(' "since": %s' % json.dumps(row["entry"], separators=(",", ":"), ensure_ascii=False))
109
+ lines.append("")
110
+ if dry_run:
111
+ lines.append(" Nothing was written. Run %s init to write %s." % (program, "it" if changed == 1 else "them") if changed else " Everything is already set up.")
112
+ elif changed:
113
+ lines.append(" Commit these files: everyone who clones this repository gets the same setup.")
114
+ lines.append(" The first tool call opens a sign-in in the browser. No key goes in any file.")
115
+ else:
116
+ lines.append(" Everything is already set up.")
117
+ lines.append("")
118
+ io.out("\n".join(lines))
119
+ return 0
since_dev/local.py ADDED
@@ -0,0 +1,75 @@
1
+ """What the CLI reads from the folder it runs in: which repository this is, and which manifests to send.
2
+
3
+ The manifest rules are the GitHub Action's: the same names, the same folders skipped.
4
+ """
5
+
6
+ import re
7
+
8
+ PATTERNS = [
9
+ "**/package.json",
10
+ "**/requirements*.txt",
11
+ "**/pyproject.toml",
12
+ "**/go.mod",
13
+ "**/Cargo.toml",
14
+ "**/Gemfile",
15
+ "**/composer.json",
16
+ "**/pom.xml",
17
+ "**/build.gradle",
18
+ "**/build.gradle.kts",
19
+ "**/*.csproj",
20
+ ]
21
+ LOCKFILES = {
22
+ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock",
23
+ "poetry.lock", "Pipfile.lock", "uv.lock", "Cargo.lock", "Gemfile.lock", "composer.lock",
24
+ "packages.lock.json", "gradle.lockfile",
25
+ }
26
+ SKIP = {".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "target", ".next", "__pycache__"}
27
+ MAX_CHARACTERS = 500000
28
+
29
+ _SHORT = re.compile(r"([A-Za-z0-9_-][A-Za-z0-9_.-]*)/([A-Za-z0-9_.-]+)")
30
+ _REMOTE = re.compile(
31
+ r"(?:https?://|ssh://|git://)?(?:[^@/]+@)?([A-Za-z0-9.-]+\.[A-Za-z]+)(?::[0-9]+)?[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?(?:/.*)?"
32
+ )
33
+
34
+
35
+ def parse_repo(text):
36
+ """owner and name from "owner/repo", a GitHub URL or a git remote; None for anything else. A bare "owner/repo" means GitHub."""
37
+ value = ("" if text is None else str(text)).strip()
38
+ if not value:
39
+ return None
40
+ short = _SHORT.fullmatch(value)
41
+ if short:
42
+ name = short.group(2)
43
+ return {"host": "github.com", "owner": short.group(1), "name": name[:-4] if name.endswith(".git") else name}
44
+ remote = _REMOTE.fullmatch(value)
45
+ if not remote:
46
+ return None
47
+ return {"host": remote.group(1).lower(), "owner": remote.group(2), "name": remote.group(3)}
48
+
49
+
50
+ def pattern_regexp(pattern):
51
+ parts = re.split(r"(\*\*/|\*)", re.sub(r"^\./", "", pattern))
52
+ return re.compile("".join("(?:.*/)?" if part == "**/" else "[^/]*" if part == "*" else re.escape(part) for part in parts))
53
+
54
+
55
+ def find_manifests(io):
56
+ """The manifests under the folder, nearest the top first, then by name."""
57
+ patterns = [pattern_regexp(pattern) for pattern in PATTERNS]
58
+ found = []
59
+
60
+ def walk(folder):
61
+ for entry in io.list("%s/%s" % (io.cwd, folder) if folder else io.cwd):
62
+ relative = "%s/%s" % (folder, entry["name"]) if folder else entry["name"]
63
+ if entry["dir"]:
64
+ if entry["name"] not in SKIP:
65
+ walk(relative)
66
+ elif any(pattern.fullmatch(relative) for pattern in patterns):
67
+ found.append(relative)
68
+
69
+ walk("")
70
+ return sorted(found, key=lambda file: (file.count("/"), file))
71
+
72
+
73
+ def check_body(file, content):
74
+ name = file.split("/")[-1]
75
+ return {"lockfile" if name in LOCKFILES else "manifest": {"path": file, "content": content}, "detail": "summary"}
since_dev/realio.py ADDED
@@ -0,0 +1,134 @@
1
+ """The real world behind the CLI: the network, the folder, git, the terminal. The tests swap all of it for a fixture."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+
11
+ def _git(cwd, args):
12
+ try:
13
+ done = subprocess.run(["git"] + args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, timeout=10)
14
+ except (OSError, subprocess.SubprocessError):
15
+ return None
16
+ return done.stdout.decode("utf-8", "replace").strip() or None if done.returncode == 0 else None
17
+
18
+
19
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
20
+ """The API never redirects, so a redirect is never followed: a key goes to the host it was meant for and nowhere else."""
21
+
22
+ def redirect_request(self, *args, **kwargs):
23
+ return None
24
+
25
+
26
+ _OPENER = urllib.request.build_opener(_NoRedirect)
27
+
28
+
29
+ def _program():
30
+ """The command that runs this again: through uvx or pipx run there is no `since` on the PATH."""
31
+ script = (sys.argv[0] if sys.argv else "").replace("\\", "/")
32
+ if "/uv/" in script and "/archive-" in script:
33
+ return "uvx since-dev"
34
+ if "/pipx/" in script and "/.cache/" in script:
35
+ return "pipx run since-dev"
36
+ return "since"
37
+
38
+
39
+ class RealIo:
40
+ runtime = "python"
41
+
42
+ def __init__(self):
43
+ self.env = os.environ
44
+ self.cwd = os.getcwd()
45
+ self.home = os.path.expanduser("~")
46
+ self.platform = "win32" if sys.platform.startswith("win") else sys.platform
47
+ self.tty = sys.stdout.isatty()
48
+ self.program = _program()
49
+
50
+ def out(self, text):
51
+ sys.stdout.write(text)
52
+ sys.stdout.flush()
53
+
54
+ def err(self, text):
55
+ sys.stderr.write(text)
56
+ sys.stderr.flush()
57
+
58
+ def now(self):
59
+ return int(time.time() * 1000)
60
+
61
+ def sleep(self, ms):
62
+ time.sleep(ms / 1000)
63
+
64
+ def fetch(self, method, url, headers, body):
65
+ request = urllib.request.Request(url, data=None if body is None else body.encode("utf-8"), headers=headers, method=method)
66
+ try:
67
+ with _OPENER.open(request, timeout=120) as response:
68
+ return {"status": response.status, "retryAfter": response.headers.get("Retry-After"), "text": response.read().decode("utf-8", "replace")}
69
+ except urllib.error.HTTPError as error:
70
+ return {"status": error.code, "retryAfter": error.headers.get("Retry-After"), "text": error.read().decode("utf-8", "replace")}
71
+
72
+ def origin(self):
73
+ return _git(self.cwd, ["remote", "get-url", "origin"])
74
+
75
+ def root(self):
76
+ return _git(self.cwd, ["rev-parse", "--show-toplevel"]) or self.cwd
77
+
78
+ def open(self, url):
79
+ if self.env.get("CI"):
80
+ return
81
+ try:
82
+ if self.platform == "darwin":
83
+ command = ["open", url]
84
+ elif self.platform == "win32":
85
+ command = ["rundll32", "url.dll,FileProtocolHandler", url]
86
+ else:
87
+ command = ["xdg-open", url]
88
+ subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
89
+ except OSError:
90
+ pass # No browser to open: the link is printed, which is enough.
91
+
92
+ def prompt(self, question):
93
+ sys.stderr.write(question)
94
+ sys.stderr.flush()
95
+ return sys.stdin.readline()
96
+
97
+ def read_file(self, file):
98
+ try:
99
+ with open(file, encoding="utf-8") as handle:
100
+ return handle.read()
101
+ except (OSError, UnicodeDecodeError):
102
+ return None
103
+
104
+ def write_file(self, file, text, mode):
105
+ """mode is None for a file of the repository, or the permission bits for a private one (its folder is made private too)."""
106
+ folder = os.path.dirname(file)
107
+ if folder:
108
+ if mode is None:
109
+ os.makedirs(folder, exist_ok=True)
110
+ else:
111
+ os.makedirs(folder, mode=0o700, exist_ok=True)
112
+ if mode is None:
113
+ with open(file, "w", encoding="utf-8", newline="") as handle:
114
+ handle.write(text)
115
+ return
116
+ descriptor = os.open(file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
117
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
118
+ handle.write(text)
119
+ os.chmod(file, mode)
120
+
121
+ def remove(self, file):
122
+ try:
123
+ os.remove(file)
124
+ return True
125
+ except OSError:
126
+ return False
127
+
128
+ def list(self, folder):
129
+ """A folder's entries by name; a link is never followed into a folder."""
130
+ try:
131
+ with os.scandir(folder) as entries:
132
+ return sorted(({"name": entry.name, "dir": entry.is_dir(follow_symlinks=False)} for entry in entries), key=lambda entry: entry["name"])
133
+ except OSError:
134
+ return []
since_dev/render.py ADDED
@@ -0,0 +1,337 @@
1
+ """Text only: every function here turns an API answer into lines. No network, no files, no clock.
2
+
3
+ src/render.mjs is the same code in JavaScript, and the shared fixtures hold both to the same output.
4
+ """
5
+
6
+ SEVERITIES = ["low", "medium", "high", "critical"]
7
+ PAD = " " * 12
8
+ SHOWN = 10
9
+
10
+
11
+ class Palette:
12
+ def __init__(self, on):
13
+ self.on = on
14
+
15
+ def _paint(self, code, text):
16
+ return "\x1b[%dm%s\x1b[0m" % (code, text) if self.on else text
17
+
18
+ def bold(self, text):
19
+ return self._paint(1, text)
20
+
21
+ def dim(self, text):
22
+ return self._paint(2, text)
23
+
24
+ def red(self, text):
25
+ return self._paint(31, text)
26
+
27
+ def yellow(self, text):
28
+ return self._paint(33, text)
29
+
30
+ def plain(self, text):
31
+ return text
32
+
33
+
34
+ def is_number(value):
35
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
36
+
37
+
38
+ def num(value):
39
+ return "{:,}".format(value)
40
+
41
+
42
+ def plural(count, word):
43
+ return "%s %s%s" % (num(count), word, "" if count == 1 else "s")
44
+
45
+
46
+ def wait(seconds):
47
+ """How long to wait, in words: seconds under a minute and a half, then minutes, then hours, rounded up."""
48
+ if seconds < 90:
49
+ return plural(max(1, seconds), "second")
50
+ if seconds < 5400:
51
+ return plural(-(-seconds // 60), "minute")
52
+ return plural(-(-seconds // 3600), "hour")
53
+
54
+
55
+ def ago(ms):
56
+ """How long ago, from milliseconds; "" when the time is not known."""
57
+ if ms is None:
58
+ return ""
59
+ minutes = ms // 60000
60
+ if minutes < 1:
61
+ return "just now"
62
+ if minutes < 60:
63
+ return "%d min ago" % minutes
64
+ hours = minutes // 60
65
+ if hours < 48:
66
+ return "%s ago" % plural(hours, "hour")
67
+ return "%s ago" % plural(hours // 24, "day")
68
+
69
+
70
+ def _place_of(path, line):
71
+ return "%s:%d" % (path, line) if is_number(line) and line > 0 else str(path)
72
+
73
+
74
+ def _join(parts, separator):
75
+ return separator.join(str(part) for part in parts if part)
76
+
77
+
78
+ def scan_items(findings):
79
+ """A public scan's findings, as the rows the CLI prints."""
80
+ items = []
81
+ for finding in findings:
82
+ where = finding.get("where")
83
+ where = [place for place in where if isinstance(place, dict) and place.get("path")] if isinstance(where, list) else []
84
+ source = finding.get("source")
85
+ source = source.get("url") if isinstance(source, dict) else None
86
+ title = finding.get("title")
87
+ items.append({
88
+ "title": "" if title is None else str(title),
89
+ "review": finding.get("state") == "needs_review",
90
+ "severity": finding.get("severity"),
91
+ "daysLeft": finding.get("daysLeft") if is_number(finding.get("daysLeft")) else None,
92
+ "place": _place_of(where[0]["path"], where[0].get("line")) if where else None,
93
+ "extra": max(0, len(where) - 1) + (finding["more"] if is_number(finding.get("more")) else 0),
94
+ "replacement": finding.get("replacement") or None,
95
+ "source": source or None,
96
+ })
97
+ return items
98
+
99
+
100
+ def _merge_rows(group, program):
101
+ """Many undated findings about one dependency at one place, as one row: the count, the worst severity, and how to list them."""
102
+ members = group["members"]
103
+ proven = [row for row in members if not row["review"]]
104
+ pool = proven or members
105
+
106
+ def rank(level):
107
+ return SEVERITIES.index(level) if level in SEVERITIES else -1
108
+
109
+ severity = pool[0]["severity"]
110
+ for row in pool:
111
+ if rank(row["severity"]) > rank(severity):
112
+ severity = row["severity"]
113
+ tally = [[level, len([row for row in members if row["severity"] == level])] for level in reversed(SEVERITIES)]
114
+ tally = [entry for entry in tally if entry[1]]
115
+ unrated = len(members) - sum(entry[1] for entry in tally)
116
+ if unrated:
117
+ tally.append(["unrated", unrated])
118
+ breakdown = " (%s)" % ", ".join("%s %s" % (num(count), level) for level, count in tally) if len(tally) > 1 else ""
119
+ since = " --since %s" % group["version"] if group["version"] else ""
120
+ registry = " --ecosystem %s" % group["ecosystem"] if group["ecosystem"] and group["ecosystem"] != "npm" else ""
121
+ return {
122
+ "title": "%s: %s changes between this version and the latest%s" % (group["subject"], num(len(members)), breakdown),
123
+ "review": not proven,
124
+ "severity": severity,
125
+ "daysLeft": None,
126
+ "place": members[0]["place"],
127
+ "extra": 0,
128
+ "replacement": None,
129
+ "source": None,
130
+ "hint": "%s changed %s%s%s" % (program, group["name"], since, registry) if group["isPackage"] and group["name"] else None,
131
+ }
132
+
133
+
134
+ def check_items(findings, fallback_path, program):
135
+ """A keyed check's findings as the same rows. They carry no title, so one is built from the name, the version and the reason.
136
+
137
+ A manifest check answers with every recorded change between the pinned version and the latest, hundreds for an old pin:
138
+ the undated ones about one dependency at one place become one row, and `since changed` lists them.
139
+ """
140
+ rows = []
141
+ groups = {}
142
+ for finding in findings:
143
+ surface = finding["surface"] if isinstance(finding.get("surface"), dict) else {}
144
+ name = str(surface.get("name") or "")
145
+ version = finding.get("version") or None
146
+ subject = _join([name, version], " ")
147
+ reason = finding.get("reason")
148
+ reason = ("" if reason is None else str(reason)).strip()
149
+ where = finding.get("where")
150
+ where = [place for place in where if isinstance(place, dict) and place.get("file")] if isinstance(where, list) else []
151
+ first = _place_of(where[0]["file"], where[0].get("line")) if where else fallback_path
152
+ source = finding.get("source")
153
+ if isinstance(source, dict):
154
+ source = source.get("url")
155
+ places = finding["placeCount"] if is_number(finding.get("placeCount")) else len(where)
156
+ row = {
157
+ "title": reason if name and name.lower() in reason.lower() else _join([subject, reason], ": "),
158
+ "review": finding.get("state") == "needs_review",
159
+ "severity": finding.get("severity"),
160
+ "daysLeft": finding.get("daysLeft") if is_number(finding.get("daysLeft")) else None,
161
+ "place": first or None,
162
+ "extra": max(0, places - 1),
163
+ "replacement": finding.get("replacement") or None,
164
+ "source": source if isinstance(source, str) and source else None,
165
+ "hint": None,
166
+ }
167
+ if row["daysLeft"] is not None:
168
+ rows.append(row)
169
+ continue
170
+ key = "%s|%s|%s" % (surface.get("key") or name, version or "", row["place"] or "")
171
+ if key in groups:
172
+ groups[key]["members"].append(row)
173
+ else:
174
+ groups[key] = {"members": [row], "subject": subject, "name": name, "version": version, "isPackage": surface.get("kind") == "package", "ecosystem": surface.get("ecosystem") or None}
175
+ rows.append(groups[key])
176
+ return [(row["members"][0] if len(row["members"]) == 1 else _merge_rows(row, program)) if "members" in row else row for row in rows]
177
+
178
+
179
+ def order(items):
180
+ """Dated findings first (the next deadline, then the ones already passed, newest first), then undated, then the ones to review."""
181
+ def group(item):
182
+ return 2 if item["review"] else 1 if item["daysLeft"] is None else 0
183
+
184
+ def within(item):
185
+ if group(item) != 0:
186
+ return 0
187
+ return item["daysLeft"] if item["daysLeft"] >= 0 else 100000 - item["daysLeft"]
188
+
189
+ return sorted(items, key=lambda item: (group(item), within(item)))
190
+
191
+
192
+ def fails(item, fail_on):
193
+ """The CI rule, the same as the GitHub Action's: proven, and at or above the severity asked for."""
194
+ if fail_on not in SEVERITIES or item["review"] or item["severity"] not in SEVERITIES:
195
+ return False
196
+ return SEVERITIES.index(item["severity"]) >= SEVERITIES.index(fail_on)
197
+
198
+
199
+ def day_label(days_left):
200
+ if days_left < 0:
201
+ return "passed"
202
+ if days_left == 0:
203
+ return "today"
204
+ return "1 day" if days_left == 1 else "%s days" % num(days_left)
205
+
206
+
207
+ def _label(item):
208
+ if item["review"]:
209
+ return "review"
210
+ if item["daysLeft"] is None:
211
+ return item["severity"] if item["severity"] in SEVERITIES else "no date"
212
+ return day_label(item["daysLeft"])
213
+
214
+
215
+ def _day_tone(days_left, c):
216
+ return c.red if days_left <= 30 else c.yellow if days_left <= 90 else c.plain
217
+
218
+
219
+ def _tone(item, c):
220
+ if item["review"]:
221
+ return c.dim
222
+ if item["daysLeft"] is None:
223
+ return c.yellow if item["severity"] in ("critical", "high") else c.plain
224
+ return _day_tone(item["daysLeft"], c)
225
+
226
+
227
+ def counts_line(items, within):
228
+ """"3 with a date 2 undated 1 to review", without the counts that are zero."""
229
+ dated = len([item for item in items if not item["review"] and item["daysLeft"] is not None])
230
+ undated = len([item for item in items if not item["review"] and item["daysLeft"] is None])
231
+ review = len([item for item in items if item["review"]])
232
+ parts = []
233
+ if dated:
234
+ parts.append("%s with a date" % num(dated))
235
+ if undated:
236
+ parts.append("%s undated" % num(undated))
237
+ if review:
238
+ parts.append("%s to review" % num(review))
239
+ if parts:
240
+ return " ".join(parts)
241
+ return "No findings" if within is None else "Nothing dated within %s" % plural(within, "day")
242
+
243
+
244
+ def finding_lines(items, show_all, c, more_hint):
245
+ """The findings, three lines each: when and what, where and the replacement, the source."""
246
+ lines = []
247
+ shown = items if show_all else items[:SHOWN]
248
+ for item in shown:
249
+ lines.append(" %s%s" % (_tone(item, c)(_label(item).ljust(10)), item["title"]))
250
+ place = item["place"] + (" and %s more" % num(item["extra"]) if item["extra"] else "") if item["place"] else None
251
+ detail = _join([place, "use %s" % item["replacement"] if item["replacement"] else None, "list them: %s" % item["hint"] if item.get("hint") else None], " ")
252
+ if detail:
253
+ lines.append(c.dim(PAD + detail))
254
+ if item["source"]:
255
+ lines.append(c.dim(PAD + item["source"]))
256
+ lines.append("")
257
+ if len(items) > len(shown):
258
+ lines.extend([" and %s more %s" % (num(len(items) - len(shown)), more_hint), ""])
259
+ return lines
260
+
261
+
262
+ def _count(value, fallback):
263
+ return int(value) if is_number(value) and value else fallback
264
+
265
+
266
+ def answer_notes(answers):
267
+ """What keyed answers say about their own gaps: still being read, cut short, or names that did not resolve."""
268
+ notes = []
269
+ scanning = 0
270
+ reading = False
271
+ releases = 0
272
+ truncated = False
273
+ unresolved = []
274
+ for answer in answers:
275
+ if isinstance(answer.get("scanning"), dict):
276
+ scanning = max(scanning, _count(answer["scanning"].get("retryAfterSec"), 60))
277
+ if isinstance(answer.get("reading"), dict):
278
+ reading = True
279
+ releases += _count(answer["reading"].get("releases"), 0)
280
+ if answer.get("truncated"):
281
+ truncated = True
282
+ if isinstance(answer.get("unresolved"), list):
283
+ unresolved.extend(answer["unresolved"])
284
+ if scanning:
285
+ notes.append("Since is still reading this repository. Run this again in %s." % wait(scanning))
286
+ if reading:
287
+ what = plural(releases, "newer release") if releases else "newer releases"
288
+ notes.append("Since is reading %s now. Run this again in a few minutes to include them." % what)
289
+ if truncated:
290
+ notes.append("The answer was cut short: there are more findings than one answer holds.")
291
+ if unresolved:
292
+ names = ", ".join(str(entry.get("name")) for entry in unresolved[:3])
293
+ notes.append("Not fully checked yet: %s%s. --json says why." % (names, " and %s more" % num(len(unresolved) - 3) if len(unresolved) > 3 else ""))
294
+ return notes
295
+
296
+
297
+ def calendar_lines(entries, site, c):
298
+ """One line per calendar entry, with its replacement and its page under it."""
299
+ lines = []
300
+ for entry in entries:
301
+ days = entry.get("daysLeft") if is_number(entry.get("daysLeft")) else None
302
+ when = "no date" if days is None else day_label(days)
303
+ paint = c.plain if days is None else _day_tone(days, c)
304
+ lines.append(" %s%s" % (paint(when.ljust(10)), entry.get("title")))
305
+ path = entry.get("path")
306
+ link = "%s%s?utm_source=cli" % (site, path) if isinstance(path, str) and path.startswith("/") else None
307
+ detail = _join(["use %s" % entry["replacement"] if entry.get("replacement") else None, link], " ")
308
+ if detail:
309
+ lines.append(c.dim(PAD + detail))
310
+ return lines
311
+
312
+
313
+ def unique_changes(changes):
314
+ # One change can be recorded once per function it touches, with the same words: say it once.
315
+ seen = set()
316
+ unique = []
317
+ for change in changes:
318
+ key = (change.get("kind"), change.get("title"), change.get("introducedIn") or change.get("removedIn"))
319
+ if key not in seen:
320
+ seen.add(key)
321
+ unique.append(change)
322
+ return unique
323
+
324
+
325
+ def change_lines(changes, c):
326
+ """One line per change of a package, with the version, the date and the source under it."""
327
+ lines = []
328
+ for change in changes:
329
+ kind = change.get("kind")
330
+ version = change.get("introducedIn") or change.get("removedIn")
331
+ lines.append(" %s%s" % (("" if kind is None else str(kind)).replace("_", " ").ljust(19), change.get("title")))
332
+ evidence = change.get("evidence")
333
+ evidence = evidence[0].get("url") if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict) else None
334
+ detail = _join(["in %s" % version if version else None, change.get("effectiveDate") or None, evidence or None], " ")
335
+ if detail:
336
+ lines.append(c.dim(" " * 21 + detail))
337
+ return lines