@illuminis/comprism 0.1.0

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.
Files changed (80) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +281 -0
  3. package/out/agent/command.d.ts +86 -0
  4. package/out/agent/command.js +259 -0
  5. package/out/agent/render.d.ts +97 -0
  6. package/out/agent/render.js +255 -0
  7. package/out/agent/session.d.ts +175 -0
  8. package/out/agent/session.js +573 -0
  9. package/out/commands/ask.d.ts +1 -0
  10. package/out/commands/ask.js +146 -0
  11. package/out/commands/codemap.d.ts +2 -0
  12. package/out/commands/codemap.js +151 -0
  13. package/out/commands/commands-thin.d.ts +39 -0
  14. package/out/commands/commands-thin.js +182 -0
  15. package/out/commands/install.d.ts +163 -0
  16. package/out/commands/install.js +543 -0
  17. package/out/commands/keys.d.ts +55 -0
  18. package/out/commands/keys.js +344 -0
  19. package/out/commands/login.d.ts +9 -0
  20. package/out/commands/login.js +384 -0
  21. package/out/commands/repl.d.ts +1 -0
  22. package/out/commands/repl.js +752 -0
  23. package/out/commands/settings.d.ts +21 -0
  24. package/out/commands/settings.js +244 -0
  25. package/out/commands/welcome.d.ts +1 -0
  26. package/out/commands/welcome.js +196 -0
  27. package/out/executor/documents.d.ts +40 -0
  28. package/out/executor/documents.js +170 -0
  29. package/out/executor/files.d.ts +2 -0
  30. package/out/executor/files.js +360 -0
  31. package/out/executor/git.d.ts +48 -0
  32. package/out/executor/git.js +132 -0
  33. package/out/executor/hooks.d.ts +67 -0
  34. package/out/executor/hooks.js +247 -0
  35. package/out/executor/index.d.ts +29 -0
  36. package/out/executor/index.js +221 -0
  37. package/out/executor/notebook.d.ts +2 -0
  38. package/out/executor/notebook.js +147 -0
  39. package/out/executor/paths.d.ts +15 -0
  40. package/out/executor/paths.js +126 -0
  41. package/out/executor/shell.d.ts +41 -0
  42. package/out/executor/shell.js +336 -0
  43. package/out/graph/build.d.ts +45 -0
  44. package/out/graph/build.js +91 -0
  45. package/out/graph/facts.d.ts +47 -0
  46. package/out/graph/facts.js +12 -0
  47. package/out/graph/files.d.ts +45 -0
  48. package/out/graph/files.js +207 -0
  49. package/out/graph/read-locales.d.ts +29 -0
  50. package/out/graph/read-locales.js +246 -0
  51. package/out/graph/read-python.d.ts +11 -0
  52. package/out/graph/read-python.js +115 -0
  53. package/out/graph/read-typescript.d.ts +16 -0
  54. package/out/graph/read-typescript.js +292 -0
  55. package/out/graph/sync.d.ts +66 -0
  56. package/out/graph/sync.js +242 -0
  57. package/out/lib/attach.d.ts +62 -0
  58. package/out/lib/attach.js +228 -0
  59. package/out/lib/config.d.ts +93 -0
  60. package/out/lib/config.js +198 -0
  61. package/out/lib/connection.d.ts +73 -0
  62. package/out/lib/connection.js +188 -0
  63. package/out/lib/gateway.d.ts +239 -0
  64. package/out/lib/gateway.js +171 -0
  65. package/out/lib/prompt.d.ts +34 -0
  66. package/out/lib/prompt.js +108 -0
  67. package/out/lib/types.d.ts +417 -0
  68. package/out/lib/types.js +21 -0
  69. package/out/lib/ui.d.ts +114 -0
  70. package/out/lib/ui.js +265 -0
  71. package/out/lib/version.d.ts +24 -0
  72. package/out/lib/version.js +27 -0
  73. package/out/lib/voice.d.ts +50 -0
  74. package/out/lib/voice.js +218 -0
  75. package/out/postinstall.d.ts +2 -0
  76. package/out/postinstall.js +92 -0
  77. package/out/thin.d.ts +2 -0
  78. package/out/thin.js +259 -0
  79. package/package.json +101 -0
  80. package/scripts/read_python.py +270 -0
package/out/thin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/out/thin.js ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ /**
38
+ * The published command line tool.
39
+ *
40
+ * ── Why there are two entry points ────────────────────────────────────────
41
+ *
42
+ * `cli.ts` is the full internal tool. It reaches into the decision, the price
43
+ * list and the study, because our own work needs to. It is never published.
44
+ *
45
+ * THIS is what a customer installs. It carries no decision, no price list, no
46
+ * task representation and no study. Every question that needs any of those is
47
+ * asked of our server and only the answer comes back.
48
+ *
49
+ * The split is deliberate rather than tidy. A single entry point with the
50
+ * dangerous parts behind lazy imports still SHIPS them, and a package on a
51
+ * public registry is readable by anyone who installs it, forever. The only
52
+ * reliable way to not ship something is to not put it in the file that ships.
53
+ *
54
+ * The rule, from the owner: anything a client installs is thin; anything
55
+ * model-related runs on our server.
56
+ */
57
+ const ui = __importStar(require("./lib/ui"));
58
+ const version_1 = require("./lib/version");
59
+ const out = (s = '') => process.stdout.write(s + '\n');
60
+ /**
61
+ * The value after a flag, or undefined when the flag was not given.
62
+ *
63
+ * `args[args.indexOf('--mode') + 1]` reads the FIRST argument when the flag is
64
+ * absent, because `indexOf` answers -1 and -1 + 1 is 0. So
65
+ * `comprism work "create a.txt"` took the request itself as the permission mode
66
+ * and refused the whole command before it started: the plainest way anybody
67
+ * runs this tool was the one way it could not be run.
68
+ */
69
+ function flag(args, name) {
70
+ const at = args.indexOf(name);
71
+ return at === -1 ? undefined : args[at + 1];
72
+ }
73
+ function usage() {
74
+ out(ui.banner('CompletionPrism'));
75
+ out(ui.c.dim(' Every answer on the model that finishes it for least, with a receipt.'));
76
+ out();
77
+ out(ui.table(['COMMAND', 'WHAT IT DOES'], [
78
+ ['comprism ask "<your question>"', 'one prompt, one answer, and what it cost'],
79
+ ['comprism work', 'have it do the work: reads your project, edits, runs your tests'],
80
+ ['comprism login', 'sign in with your email and password'],
81
+ ['comprism', 'start a session and just type your questions'],
82
+ ['comprism keys', 'add a provider key, or point at a file of them'],
83
+ ['comprism model', 'the model your savings are measured against'],
84
+ ['comprism providers', 'every AI vendor and model, and what you can reach'],
85
+ ['comprism workspace', 'the folder to work in, default is where you are'],
86
+ ['comprism status', 'is this machine signed in, and to what'],
87
+ ['comprism check', 'what this machine still needs, and how to add it'],
88
+ ['comprism logout', 'sign out, and revoke this machine\'s key'],
89
+ ]));
90
+ out();
91
+ out(ui.c.dim(` v${version_1.COMPRISM_VERSION} · every decision is made on our servers`));
92
+ out();
93
+ }
94
+ async function main() {
95
+ const [, , command, ...args] = process.argv;
96
+ switch (command) {
97
+ case 'ask': {
98
+ const { cmdAsk } = await Promise.resolve().then(() => __importStar(require('./commands/ask')));
99
+ return cmdAsk(args);
100
+ }
101
+ // `work` is the word. `agent` still answers to it, because anybody who
102
+ // learned the old name should not meet "unknown command".
103
+ case 'work':
104
+ case 'agent': {
105
+ const { runAgent, AGENT_HELP } = await Promise.resolve().then(() => __importStar(require('./agent/command')));
106
+ if (args.includes('--help') || args.includes('-h')) {
107
+ out(AGENT_HELP);
108
+ return;
109
+ }
110
+ const { subject, closePrompt } = await Promise.resolve().then(() => __importStar(require('./lib/prompt')));
111
+ // Asked for when it was not given, same as every other command.
112
+ const request = await subject(args, ' what would you like done? ', ['--mode', '--model', '--max-spend', '--resume', '--cwd', '--allow']);
113
+ closePrompt();
114
+ if (!request) {
115
+ out(ui.fail('Nothing was asked for.'));
116
+ out();
117
+ return;
118
+ }
119
+ const spend = flag(args, '--max-spend');
120
+ process.exitCode = await runAgent({
121
+ request: [request],
122
+ mode: flag(args, '--mode'),
123
+ plan: args.includes('--plan'),
124
+ approvePlan: args.includes('--approve-plan'),
125
+ model: flag(args, '--model'),
126
+ pin: args.includes('--pin'),
127
+ ...(args.includes('--max-spend') ? { maxSpend: Number(spend) } : {}),
128
+ allow: args.reduce((acc, a, i) => (args[i - 1] === '--allow' ? [...acc, a] : acc), []),
129
+ yes: args.includes('--yes'),
130
+ background: args.includes('--background'),
131
+ resume: flag(args, '--resume'),
132
+ // Go without the code map for this one job. Two reasons somebody uses
133
+ // it: they are measuring, and a run with a map and a run without one
134
+ // are two different quantities; or they suspect the map and want the
135
+ // files read directly. Both are legitimate and neither should require
136
+ // an administrator.
137
+ noMap: args.includes('--no-map') || args.includes('--no-graph'),
138
+ // The saved working folder when there is one, so `comprism workspace`
139
+ // is a setting the agent honors rather than a stored string nothing reads.
140
+ cwd: flag(args, '--cwd')
141
+ ?? (await Promise.resolve().then(() => __importStar(require('./commands/settings')))).currentWorkspace().path,
142
+ });
143
+ return;
144
+ }
145
+ // The map of the project you are standing in: what it holds, and a way to
146
+ // have it read again. `graph` answers to it as well, because that is what
147
+ // the thing is called in the documentation and in the patent.
148
+ case 'map':
149
+ case 'graph': {
150
+ const { cmdMap } = await Promise.resolve().then(() => __importStar(require('./commands/codemap')));
151
+ process.exitCode = await cmdMap(args);
152
+ return;
153
+ }
154
+ case 'login': {
155
+ const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./commands/login')));
156
+ await cmdLogin(args);
157
+ // Signing in is not the thing somebody came to do. It used to print the
158
+ // header and exit, so a person who had just signed in had to type the
159
+ // program's name a second time to get to a prompt. If the sign in
160
+ // worked and there is somebody at the keyboard, carry straight on into
161
+ // the session.
162
+ //
163
+ // Only with a terminal attached: a script that signs in must not be
164
+ // handed an interactive prompt and left waiting for a line that never
165
+ // comes. And only when it actually worked, which the exit code says.
166
+ if (!process.exitCode && process.stdin.isTTY) {
167
+ const { runRepl } = await Promise.resolve().then(() => __importStar(require('./commands/repl')));
168
+ return runRepl();
169
+ }
170
+ return;
171
+ }
172
+ // Kept as a second name for the same job. Anybody who learned the old word
173
+ // should not hit "unknown command" for it.
174
+ case 'connect': {
175
+ const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./commands/login')));
176
+ return cmdLogin(args);
177
+ }
178
+ // The three words people type for the same act. `logout` used to answer
179
+ // "there is no command called logout", which is the product telling
180
+ // somebody their own vocabulary is wrong.
181
+ case 'logout':
182
+ case 'signout':
183
+ case 'disconnect': {
184
+ const { cmdDisconnect } = await Promise.resolve().then(() => __importStar(require('./commands/commands-thin')));
185
+ return cmdDisconnect();
186
+ }
187
+ case 'model': {
188
+ const { cmdModel } = await Promise.resolve().then(() => __importStar(require('./commands/settings')));
189
+ return cmdModel(args);
190
+ }
191
+ case 'providers': {
192
+ const { cmdProviders } = await Promise.resolve().then(() => __importStar(require('./commands/settings')));
193
+ return cmdProviders();
194
+ }
195
+ case 'workspace': {
196
+ const { cmdWorkspace } = await Promise.resolve().then(() => __importStar(require('./commands/settings')));
197
+ return cmdWorkspace(args);
198
+ }
199
+ case 'keys': {
200
+ const { cmdKeys } = await Promise.resolve().then(() => __importStar(require('./commands/keys')));
201
+ return cmdKeys(args);
202
+ }
203
+ case 'status': {
204
+ const { cmdStatus } = await Promise.resolve().then(() => __importStar(require('./commands/commands-thin')));
205
+ return cmdStatus();
206
+ }
207
+ // What this machine still needs. Its own word because somebody whose PDF
208
+ // would not read has a question about THIS machine, and the answer should
209
+ // not be buried inside a setup command they have already run.
210
+ case 'check':
211
+ case 'doctor': {
212
+ const { check } = await Promise.resolve().then(() => __importStar(require('./lib/readiness')));
213
+ const machine = check();
214
+ out();
215
+ out(ui.banner('This machine'));
216
+ out();
217
+ for (const line of machine.lines)
218
+ out(line);
219
+ out();
220
+ if (machine.ready) {
221
+ out(ui.ok('Everything the work needs is here.'));
222
+ }
223
+ else {
224
+ out(` ${ui.c.dim('to add what is missing:')} ${ui.c.bold(machine.fix)}`);
225
+ }
226
+ out();
227
+ out(ui.c.dim(' Word, PowerPoint and PDF documents are built on our servers,'));
228
+ out(ui.c.dim(' so nothing has to be installed here for those.'));
229
+ out();
230
+ return;
231
+ }
232
+ case '--version':
233
+ case '-v':
234
+ out(version_1.COMPRISM_VERSION);
235
+ return;
236
+ case 'help':
237
+ case '--help':
238
+ case '-h':
239
+ return usage();
240
+ default: {
241
+ // The bare command shows WHERE YOU ARE, not a list of other commands. A
242
+ // person who does not know which model their savings are measured
243
+ // against cannot read a saving, and they will not run three commands to
244
+ // find out. The command list is one word away, under `help`.
245
+ if (command) {
246
+ out(ui.fail(`There is no command called "${command}".`));
247
+ out();
248
+ return usage();
249
+ }
250
+ // The bare command STARTS the product rather than describing it.
251
+ const { runRepl } = await Promise.resolve().then(() => __importStar(require('./commands/repl')));
252
+ return runRepl();
253
+ }
254
+ }
255
+ }
256
+ main().catch((err) => {
257
+ process.stderr.write(ui.fail(err instanceof Error ? err.message : 'something went wrong') + '\n');
258
+ process.exitCode = 1;
259
+ });
package/package.json ADDED
@@ -0,0 +1,101 @@
1
+ {
2
+ "name": "@illuminis/comprism",
3
+ "version": "0.1.0",
4
+ "description": "CompletionPrism Companion. Records what finishing a piece of work actually costs - every attempt, and the human time spent noticing and repairing the ones that failed.",
5
+ "license": "UNLICENSED",
6
+ "private": false,
7
+ "main": "out/thin.js",
8
+ "types": "out/thin.d.ts",
9
+ "bin": {
10
+ "comprism": "out/thin.js"
11
+ },
12
+ "files": [
13
+ "out/thin.js",
14
+ "out/thin.d.ts",
15
+ "out/postinstall.js",
16
+ "out/postinstall.d.ts",
17
+ "out/commands/install.js",
18
+ "out/commands/install.d.ts",
19
+ "out/lib/config.js",
20
+ "out/lib/config.d.ts",
21
+ "out/lib/connection.js",
22
+ "out/lib/connection.d.ts",
23
+ "out/lib/gateway.js",
24
+ "out/lib/gateway.d.ts",
25
+ "out/lib/prompt.js",
26
+ "out/lib/prompt.d.ts",
27
+ "out/lib/types.js",
28
+ "out/lib/types.d.ts",
29
+ "out/lib/ui.js",
30
+ "out/lib/ui.d.ts",
31
+ "out/lib/version.js",
32
+ "out/lib/version.d.ts",
33
+ "out/lib/attach.js",
34
+ "out/lib/attach.d.ts",
35
+ "out/lib/voice.js",
36
+ "out/lib/voice.d.ts",
37
+ "out/commands/ask.js",
38
+ "out/commands/ask.d.ts",
39
+ "out/commands/commands-thin.js",
40
+ "out/commands/commands-thin.d.ts",
41
+ "out/commands/keys.js",
42
+ "out/commands/keys.d.ts",
43
+ "out/commands/login.js",
44
+ "out/commands/login.d.ts",
45
+ "out/commands/repl.js",
46
+ "out/commands/repl.d.ts",
47
+ "out/commands/settings.js",
48
+ "out/commands/settings.d.ts",
49
+ "out/commands/welcome.js",
50
+ "out/commands/welcome.d.ts",
51
+ "out/graph/**",
52
+ "out/commands/codemap.js",
53
+ "out/commands/codemap.d.ts",
54
+ "scripts/read_python.py",
55
+ "out/agent/**",
56
+ "out/executor/**",
57
+ "!out/internal/**",
58
+ "README.md",
59
+ "LICENSE"
60
+ ],
61
+ "engines": {
62
+ "node": ">=18"
63
+ },
64
+ "scripts": {
65
+ "build": "tsc -p ./",
66
+ "dev": "tsc -w -p ./",
67
+ "test": "npm run build && node --test out/**/*.test.js",
68
+ "gate": "npm run build && npm test && npm run shipcheck",
69
+ "prepublishOnly": "npm run build",
70
+ "prepack": "npm run build",
71
+ "postinstall": "node out/postinstall.js || true",
72
+ "shipcheck": "node scripts/check-no-model-logic.mjs"
73
+ },
74
+ "devDependencies": {
75
+ "@types/node": "^20.11.0"
76
+ },
77
+ "publishConfig": {
78
+ "access": "public",
79
+ "registry": "https://registry.npmjs.org/"
80
+ },
81
+ "repository": {
82
+ "type": "git",
83
+ "url": "git+https://github.com/Illuminis-ai/completionprism.git",
84
+ "directory": "app/cli"
85
+ },
86
+ "keywords": [
87
+ "ai",
88
+ "cost",
89
+ "observability",
90
+ "llm",
91
+ "completion"
92
+ ],
93
+ "author": "illuminis.ai LLC",
94
+ "homepage": "https://completionprism.illuminis.ai",
95
+ "bugs": {
96
+ "url": "https://github.com/Illuminis-ai/completionprism/issues"
97
+ },
98
+ "dependencies": {
99
+ "typescript": "^5.4.0"
100
+ }
101
+ }
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """Read Python files into code map facts, using Python's own parser.
3
+
4
+ This is a parser, not a model. Nothing here guesses: every object and every
5
+ link comes from the syntax tree, so the same file always produces the same
6
+ facts and two machines reading one project agree.
7
+
8
+ It runs on the person's own machine and prints facts, never source. What
9
+ leaves is "a function called price_of exists at line 40 of this file, and
10
+ these things call it". What it says is never read by anything but the parser.
11
+
12
+ Usage: read_python.py <project-root> <relative-path-list-file>
13
+ Writes one JSON object to stdout.
14
+ """
15
+ import ast
16
+ import json
17
+ import os
18
+ import sys
19
+
20
+ ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
21
+ MODEL_BASE_HINTS = {"Base", "BaseDataMixin", "DeclarativeBase", "Model",
22
+ "SQLModel", "models.Model"}
23
+ COLUMN_FACTORIES = {"Column", "mapped_column", "relationship"}
24
+
25
+
26
+ def dotted(node):
27
+ """Render a Name / Attribute chain back to its source text."""
28
+ if isinstance(node, ast.Name):
29
+ return node.id
30
+ if isinstance(node, ast.Attribute):
31
+ base = dotted(node.value)
32
+ return "%s.%s" % (base, node.attr) if base else node.attr
33
+ if isinstance(node, ast.Call):
34
+ return dotted(node.func)
35
+ if isinstance(node, ast.Subscript):
36
+ return dotted(node.value)
37
+ return ""
38
+
39
+
40
+ def literal_str(node):
41
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
42
+ return node.value
43
+ return None
44
+
45
+
46
+ class ModuleReader(ast.NodeVisitor):
47
+ def __init__(self, file_id, module_path):
48
+ self.file_id = file_id
49
+ self.module_path = module_path
50
+ self.objects = []
51
+ self.links = []
52
+ self.scope = []
53
+ self.owner = [file_id]
54
+ self.router_prefixes = {}
55
+ self.mounts = []
56
+
57
+ def qual(self, name):
58
+ return ".".join(self.scope + [name])
59
+
60
+ def add_object(self, object_id, kind, name, line, **extra):
61
+ entry = {"id": object_id, "kind": kind, "name": name,
62
+ "file": self.file_id, "line": line}
63
+ entry.update({k: v for k, v in extra.items() if v not in (None, [], "")})
64
+ self.objects.append(entry)
65
+
66
+ def add_link(self, src, kind, dst=None, to_name=None, **extra):
67
+ link = {"from": src, "kind": kind}
68
+ if dst:
69
+ link["to"] = dst
70
+ if to_name:
71
+ link["to_name"] = to_name
72
+ link.update(extra)
73
+ self.links.append(link)
74
+
75
+ # ---- imports ---------------------------------------------------------
76
+ def visit_Import(self, node):
77
+ for alias in node.names:
78
+ self.add_link(self.file_id, "imports", to_name=alias.name, line=node.lineno)
79
+ self.generic_visit(node)
80
+
81
+ def visit_ImportFrom(self, node):
82
+ module = node.module or ""
83
+ if node.level:
84
+ base = self.module_path.rsplit(".", node.level)[0]
85
+ module = "%s.%s" % (base, module) if module else base
86
+ for alias in node.names:
87
+ self.add_link(
88
+ self.file_id, "imports",
89
+ to_name=("%s.%s" % (module, alias.name)) if module else alias.name,
90
+ line=node.lineno)
91
+ self.generic_visit(node)
92
+
93
+ # ---- routers ---------------------------------------------------------
94
+ def visit_Assign(self, node):
95
+ """`router = APIRouter(prefix="/billing")` fixes every path below it."""
96
+ if not self.scope and isinstance(node.value, ast.Call):
97
+ leaf = dotted(node.value.func).split(".")[-1]
98
+ if leaf in ("APIRouter", "Blueprint", "Router"):
99
+ prefix = ""
100
+ for kw in node.value.keywords:
101
+ if kw.arg in ("prefix", "url_prefix"):
102
+ prefix = literal_str(kw.value) or ""
103
+ for target in node.targets:
104
+ if isinstance(target, ast.Name):
105
+ self.router_prefixes[target.id] = prefix
106
+ self.generic_visit(node)
107
+
108
+ def _include_router(self, node):
109
+ """`include_router(billing.router, prefix="/api")` mounts a whole file."""
110
+ prefix = ""
111
+ for kw in node.keywords:
112
+ if kw.arg in ("prefix", "url_prefix"):
113
+ prefix = literal_str(kw.value) or ""
114
+ if not node.args:
115
+ return
116
+ target = dotted(node.args[0])
117
+ if target:
118
+ self.mounts.append({"target": target, "prefix": prefix, "line": node.lineno})
119
+
120
+ # ---- classes ---------------------------------------------------------
121
+ def visit_ClassDef(self, node):
122
+ qual = self.qual(node.name)
123
+ object_id = "%s::%s" % (self.file_id, qual)
124
+ bases = [dotted(b) for b in node.bases]
125
+ tablename = None
126
+ columns = []
127
+ for stmt in node.body:
128
+ if isinstance(stmt, ast.Assign):
129
+ for target in stmt.targets:
130
+ if isinstance(target, ast.Name):
131
+ if target.id == "__tablename__":
132
+ tablename = literal_str(stmt.value)
133
+ elif isinstance(stmt.value, ast.Call) and \
134
+ dotted(stmt.value.func).split(".")[-1] in COLUMN_FACTORIES:
135
+ columns.append(target.id)
136
+ elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
137
+ if isinstance(stmt.value, ast.Call) and \
138
+ dotted(stmt.value.func).split(".")[-1] in COLUMN_FACTORIES:
139
+ columns.append(stmt.target.id)
140
+
141
+ is_model = bool(tablename) or any(
142
+ b.split(".")[-1] in MODEL_BASE_HINTS for b in bases)
143
+ self.add_object(object_id, "model" if is_model else "class", qual,
144
+ node.lineno, bases=bases, table=tablename, columns=columns)
145
+ self.add_link(self.file_id, "defines", object_id)
146
+ for base in bases:
147
+ self.add_link(object_id, "inherits", to_name=base)
148
+ if tablename:
149
+ table_id = "table:%s" % tablename
150
+ self.add_object(table_id, "table", tablename, node.lineno)
151
+ self.add_link(object_id, "maps_to", table_id)
152
+
153
+ self.scope.append(node.name)
154
+ self.owner.append(object_id)
155
+ self.generic_visit(node)
156
+ self.owner.pop()
157
+ self.scope.pop()
158
+
159
+ # ---- functions -------------------------------------------------------
160
+ def _function(self, node, is_async):
161
+ qual = self.qual(node.name)
162
+ object_id = "%s::%s" % (self.file_id, qual)
163
+ decorators = [dotted(d) for d in node.decorator_list]
164
+ self.add_object(object_id, "method" if self.scope else "function", qual,
165
+ node.lineno, decorators=decorators,
166
+ is_async=is_async or None,
167
+ args=[a.arg for a in node.args.args])
168
+ self.add_link(self.file_id, "defines", object_id)
169
+
170
+ # An endpoint: @router.get("/things"). The path here is RELATIVE to its
171
+ # own router; the service puts the two halves back together.
172
+ for dec in node.decorator_list:
173
+ if not isinstance(dec, ast.Call):
174
+ continue
175
+ target = dotted(dec.func)
176
+ verb = target.split(".")[-1].lower()
177
+ if verb not in ROUTE_METHODS:
178
+ continue
179
+ path = literal_str(dec.args[0]) if dec.args else None
180
+ if path is None:
181
+ continue
182
+ router_var = target.rsplit(".", 1)[0] if "." in target else ""
183
+ route_id = "route:%s %s@%s" % (verb.upper(), path, self.file_id)
184
+ self.add_object(route_id, "route", "%s %s" % (verb.upper(), path),
185
+ node.lineno, method=verb.upper(), path=path,
186
+ router_var=router_var)
187
+ self.add_link(route_id, "handled_by", object_id)
188
+ self.add_link(self.file_id, "declares", route_id)
189
+
190
+ self.scope.append(node.name)
191
+ self.owner.append(object_id)
192
+ self.generic_visit(node)
193
+ self.owner.pop()
194
+ self.scope.pop()
195
+
196
+ def visit_FunctionDef(self, node):
197
+ self._function(node, False)
198
+
199
+ def visit_AsyncFunctionDef(self, node):
200
+ self._function(node, True)
201
+
202
+ def visit_Name(self, node):
203
+ """A capitalized name used in an expression is nearly always a class or
204
+ a model being referenced, which is how database usage shows up."""
205
+ if isinstance(node.ctx, ast.Load) and node.id[:1].isupper():
206
+ self.add_link(self.owner[-1], "references", to_name=node.id, line=node.lineno)
207
+ self.generic_visit(node)
208
+
209
+ def visit_Call(self, node):
210
+ target = dotted(node.func)
211
+ if target.split(".")[-1] in ("include_router", "register_blueprint"):
212
+ self._include_router(node)
213
+ if target:
214
+ leaf = target.split(".")[-1]
215
+ if leaf and not leaf.startswith("_"):
216
+ self.add_link(self.owner[-1], "calls", to_name=leaf,
217
+ full_name=target, line=node.lineno)
218
+ self.generic_visit(node)
219
+
220
+
221
+ def module_path_for(rel_path):
222
+ trimmed = rel_path[:-3]
223
+ if trimmed.endswith("/__init__"):
224
+ trimmed = trimmed[: -len("/__init__")]
225
+ return trimmed.replace("/", ".")
226
+
227
+
228
+ def main():
229
+ if len(sys.argv) < 3:
230
+ sys.stderr.write("usage: read_python.py <project-root> <path-list-file>\n")
231
+ return 2
232
+ root = os.path.abspath(sys.argv[1])
233
+ with open(sys.argv[2], "r", encoding="utf8") as handle:
234
+ rels = [line.strip() for line in handle if line.strip()]
235
+
236
+ facts = {}
237
+ failures = []
238
+ for rel_path in rels:
239
+ full = os.path.join(root, rel_path)
240
+ try:
241
+ with open(full, "r", encoding="utf-8") as handle:
242
+ source = handle.read()
243
+ tree = ast.parse(source, filename=rel_path)
244
+ except (SyntaxError, UnicodeDecodeError, OSError, ValueError) as exc:
245
+ failures.append({"file": rel_path, "reason": type(exc).__name__})
246
+ continue
247
+
248
+ reader = ModuleReader(rel_path, module_path_for(rel_path))
249
+ reader.visit(tree)
250
+ entry = {
251
+ "objects": [{
252
+ "id": rel_path, "kind": "file",
253
+ "name": os.path.basename(rel_path), "file": rel_path, "line": 1,
254
+ "lang": "python", "lines": source.count("\n") + 1,
255
+ "module": module_path_for(rel_path),
256
+ }] + reader.objects,
257
+ "links": reader.links,
258
+ }
259
+ if reader.router_prefixes or reader.mounts:
260
+ entry["routers"] = {"prefixes": reader.router_prefixes,
261
+ "mounts": reader.mounts}
262
+ facts[rel_path] = entry
263
+
264
+ json.dump({"facts": facts, "failures": failures}, sys.stdout,
265
+ separators=(",", ":"))
266
+ return 0
267
+
268
+
269
+ if __name__ == "__main__":
270
+ sys.exit(main())