code-node-graph 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.
Files changed (178) hide show
  1. code_node/__init__.py +40 -0
  2. code_node/__main__.py +7 -0
  3. code_node/analysis/__init__.py +3 -0
  4. code_node/analysis/aws_model.py +197 -0
  5. code_node/analysis/findings.py +462 -0
  6. code_node/analysis/graph.py +780 -0
  7. code_node/analysis/mapping.py +114 -0
  8. code_node/analysis/model.py +289 -0
  9. code_node/analysis/reader.py +1926 -0
  10. code_node/analysis/syntax.py +220 -0
  11. code_node/analysis/terms.py +505 -0
  12. code_node/cli.py +316 -0
  13. code_node/deploys/__init__.py +321 -0
  14. code_node/deploys/sam.py +371 -0
  15. code_node/deploys/serverless.py +117 -0
  16. code_node/kit/__init__.py +4 -0
  17. code_node/kit/blocks.py +184 -0
  18. code_node/kit/fmt.py +389 -0
  19. code_node/kit/highlight.py +131 -0
  20. code_node/kit/render.py +1066 -0
  21. code_node/kit/text.py +81 -0
  22. code_node/libraries/__init__.py +333 -0
  23. code_node/libraries/llm.py +124 -0
  24. code_node/libraries/mongo.py +169 -0
  25. code_node/libraries/redis.py +253 -0
  26. code_node/libraries/sql.py +232 -0
  27. code_node/pages.py +92 -0
  28. code_node/platforms/__init__.py +203 -0
  29. code_node/platforms/aws_lambda.py +290 -0
  30. code_node/platforms/web.py +301 -0
  31. code_node/project.py +402 -0
  32. code_node/report/__init__.py +3 -0
  33. code_node/report/brands.py +244 -0
  34. code_node/report/build.py +119 -0
  35. code_node/report/editor.py +40 -0
  36. code_node/report/graph.py +1411 -0
  37. code_node/report/logos/README.md +8 -0
  38. code_node/report/logos/anthropic-icon.svg +1 -0
  39. code_node/report/logos/aws-amplify.svg +1 -0
  40. code_node/report/logos/aws-api-gateway.svg +1 -0
  41. code_node/report/logos/aws-appflow.svg +1 -0
  42. code_node/report/logos/aws-appsync.svg +1 -0
  43. code_node/report/logos/aws-athena.svg +1 -0
  44. code_node/report/logos/aws-backup.svg +1 -0
  45. code_node/report/logos/aws-batch.svg +1 -0
  46. code_node/report/logos/aws-certificate-manager.svg +1 -0
  47. code_node/report/logos/aws-cloudformation.svg +1 -0
  48. code_node/report/logos/aws-cloudfront.svg +1 -0
  49. code_node/report/logos/aws-cloudtrail.svg +1 -0
  50. code_node/report/logos/aws-cloudwatch.svg +1 -0
  51. code_node/report/logos/aws-codebuild.svg +1 -0
  52. code_node/report/logos/aws-codecommit.svg +1 -0
  53. code_node/report/logos/aws-codedeploy.svg +1 -0
  54. code_node/report/logos/aws-codepipeline.svg +1 -0
  55. code_node/report/logos/aws-cognito.svg +1 -0
  56. code_node/report/logos/aws-config.svg +1 -0
  57. code_node/report/logos/aws-documentdb.svg +1 -0
  58. code_node/report/logos/aws-dynamodb.svg +1 -0
  59. code_node/report/logos/aws-ec2.svg +1 -0
  60. code_node/report/logos/aws-ecs.svg +1 -0
  61. code_node/report/logos/aws-eks.svg +1 -0
  62. code_node/report/logos/aws-elasticache.svg +1 -0
  63. code_node/report/logos/aws-elb.svg +1 -0
  64. code_node/report/logos/aws-eventbridge.svg +1 -0
  65. code_node/report/logos/aws-glacier.svg +1 -0
  66. code_node/report/logos/aws-glue.svg +1 -0
  67. code_node/report/logos/aws-iam.svg +1 -0
  68. code_node/report/logos/aws-keyspaces.svg +1 -0
  69. code_node/report/logos/aws-kinesis.svg +1 -0
  70. code_node/report/logos/aws-kms.svg +1 -0
  71. code_node/report/logos/aws-lake-formation.svg +1 -0
  72. code_node/report/logos/aws-lambda.svg +1 -0
  73. code_node/report/logos/aws-lightsail.svg +1 -0
  74. code_node/report/logos/aws-mq.svg +1 -0
  75. code_node/report/logos/aws-msk.svg +1 -0
  76. code_node/report/logos/aws-neptune.svg +1 -0
  77. code_node/report/logos/aws-open-search.svg +1 -0
  78. code_node/report/logos/aws-quicksight.svg +1 -0
  79. code_node/report/logos/aws-rds.svg +1 -0
  80. code_node/report/logos/aws-redshift.svg +1 -0
  81. code_node/report/logos/aws-route53.svg +1 -0
  82. code_node/report/logos/aws-s3.svg +1 -0
  83. code_node/report/logos/aws-secrets-manager.svg +1 -0
  84. code_node/report/logos/aws-ses.svg +1 -0
  85. code_node/report/logos/aws-shield.svg +1 -0
  86. code_node/report/logos/aws-sns.svg +1 -0
  87. code_node/report/logos/aws-sqs.svg +1 -0
  88. code_node/report/logos/aws-step-functions.svg +1 -0
  89. code_node/report/logos/aws-systems-manager.svg +1 -0
  90. code_node/report/logos/aws-timestream.svg +1 -0
  91. code_node/report/logos/aws-waf.svg +1 -0
  92. code_node/report/logos/aws-xray.svg +1 -0
  93. code_node/report/logos/aws.svg +1 -0
  94. code_node/report/logos/azure-icon.svg +1 -0
  95. code_node/report/logos/django-icon.svg +1 -0
  96. code_node/report/logos/elasticsearch.svg +1 -0
  97. code_node/report/logos/fastapi-icon.svg +1 -0
  98. code_node/report/logos/firebase-icon.svg +1 -0
  99. code_node/report/logos/flask.svg +1 -0
  100. code_node/report/logos/google-cloud.svg +1 -0
  101. code_node/report/logos/kafka-icon.svg +1 -0
  102. code_node/report/logos/mariadb-icon.svg +1 -0
  103. code_node/report/logos/mongodb-icon.svg +1 -0
  104. code_node/report/logos/mysql-icon.svg +1 -0
  105. code_node/report/logos/openai-icon.svg +1 -0
  106. code_node/report/logos/postgresql.svg +1 -0
  107. code_node/report/logos/python.svg +1 -0
  108. code_node/report/logos/rabbitmq-icon.svg +1 -0
  109. code_node/report/logos/redis.svg +1 -0
  110. code_node/report/logos/serverless.svg +1 -0
  111. code_node/report/logos/slack-icon.svg +1 -0
  112. code_node/report/logos/small/aws-api-gateway.svg +1 -0
  113. code_node/report/logos/small/aws-appflow.svg +1 -0
  114. code_node/report/logos/small/aws-appsync.svg +1 -0
  115. code_node/report/logos/small/aws-athena.svg +1 -0
  116. code_node/report/logos/small/aws-backup.svg +1 -0
  117. code_node/report/logos/small/aws-batch.svg +1 -0
  118. code_node/report/logos/small/aws-certificate-manager.svg +1 -0
  119. code_node/report/logos/small/aws-cloudformation.svg +1 -0
  120. code_node/report/logos/small/aws-cloudfront.svg +1 -0
  121. code_node/report/logos/small/aws-cloudtrail.svg +1 -0
  122. code_node/report/logos/small/aws-cloudwatch.svg +1 -0
  123. code_node/report/logos/small/aws-codebuild.svg +1 -0
  124. code_node/report/logos/small/aws-codecommit.svg +1 -0
  125. code_node/report/logos/small/aws-codedeploy.svg +1 -0
  126. code_node/report/logos/small/aws-codepipeline.svg +1 -0
  127. code_node/report/logos/small/aws-cognito.svg +1 -0
  128. code_node/report/logos/small/aws-config.svg +1 -0
  129. code_node/report/logos/small/aws-documentdb.svg +1 -0
  130. code_node/report/logos/small/aws-dynamodb.svg +1 -0
  131. code_node/report/logos/small/aws-ec2.svg +1 -0
  132. code_node/report/logos/small/aws-ecs.svg +1 -0
  133. code_node/report/logos/small/aws-eks.svg +1 -0
  134. code_node/report/logos/small/aws-elasticache.svg +1 -0
  135. code_node/report/logos/small/aws-elb.svg +1 -0
  136. code_node/report/logos/small/aws-eventbridge.svg +1 -0
  137. code_node/report/logos/small/aws-glacier.svg +1 -0
  138. code_node/report/logos/small/aws-glue.svg +1 -0
  139. code_node/report/logos/small/aws-iam.svg +1 -0
  140. code_node/report/logos/small/aws-keyspaces.svg +1 -0
  141. code_node/report/logos/small/aws-kinesis.svg +1 -0
  142. code_node/report/logos/small/aws-kms.svg +1 -0
  143. code_node/report/logos/small/aws-lake-formation.svg +1 -0
  144. code_node/report/logos/small/aws-lambda.svg +1 -0
  145. code_node/report/logos/small/aws-mq.svg +1 -0
  146. code_node/report/logos/small/aws-msk.svg +1 -0
  147. code_node/report/logos/small/aws-neptune.svg +1 -0
  148. code_node/report/logos/small/aws-open-search.svg +1 -0
  149. code_node/report/logos/small/aws-quicksight.svg +1 -0
  150. code_node/report/logos/small/aws-rds.svg +1 -0
  151. code_node/report/logos/small/aws-redshift.svg +1 -0
  152. code_node/report/logos/small/aws-route53.svg +1 -0
  153. code_node/report/logos/small/aws-s3.svg +1 -0
  154. code_node/report/logos/small/aws-secrets-manager.svg +1 -0
  155. code_node/report/logos/small/aws-ses.svg +1 -0
  156. code_node/report/logos/small/aws-shield.svg +1 -0
  157. code_node/report/logos/small/aws-sns.svg +1 -0
  158. code_node/report/logos/small/aws-sqs.svg +1 -0
  159. code_node/report/logos/small/aws-step-functions.svg +1 -0
  160. code_node/report/logos/small/aws-systems-manager.svg +1 -0
  161. code_node/report/logos/small/aws-timestream.svg +1 -0
  162. code_node/report/logos/small/aws-waf.svg +1 -0
  163. code_node/report/logos/small/aws-xray.svg +1 -0
  164. code_node/report/logos/snowflake-icon.svg +1 -0
  165. code_node/report/logos/sqlite.svg +1 -0
  166. code_node/report/logos/stripe.svg +1 -0
  167. code_node/report/logos/supabase-icon.svg +1 -0
  168. code_node/report/logos/twilio-icon.svg +1 -0
  169. code_node/report/outline.py +219 -0
  170. code_node/report/style.py +358 -0
  171. code_node/report/tabs.py +73 -0
  172. code_node/report/walk.py +624 -0
  173. code_node/serve.py +277 -0
  174. code_node_graph-0.1.0.dist-info/METADATA +165 -0
  175. code_node_graph-0.1.0.dist-info/RECORD +178 -0
  176. code_node_graph-0.1.0.dist-info/WHEEL +4 -0
  177. code_node_graph-0.1.0.dist-info/entry_points.txt +2 -0
  178. code_node_graph-0.1.0.dist-info/licenses/LICENSE +201 -0
code_node/kit/text.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Escaping text for HTML, clipping and padding it for the text tables, and finding search words in it.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import html
8
+ import re
9
+ import unicodedata
10
+ from typing import Any, Iterable
11
+
12
+
13
+ def _esc(value: Any) -> str:
14
+ return html.escape("" if value is None else str(value))
15
+
16
+
17
+ def _clip(text: str, width: int = 90) -> str:
18
+ return text if len(text) <= width else text[: width - 1] + "…"
19
+
20
+
21
+ def _width(text: str) -> int:
22
+ """
23
+ How many columns a terminal gives `text`, so text tables line up when a cell holds an emoji (📁) or CJK:
24
+ 2 for a wide character, 1 more for a symbol U+FE0F turns into an emoji (⚙️), 0 for combining marks and joiners.
25
+ """
26
+ width, wide = 0, False
27
+ for ch in text:
28
+ if ch == "️":
29
+ width, wide = width + (not wide), True
30
+ elif not unicodedata.combining(ch) and unicodedata.category(ch) not in ("Mn", "Me", "Cf"):
31
+ wide = unicodedata.east_asian_width(ch) in ("W", "F")
32
+ width += 2 if wide else 1
33
+ return width
34
+
35
+
36
+ def _pad(text: str, width: int, right: bool = False) -> str:
37
+ """
38
+ ljust / rjust by the columns the text takes on screen (_width), not its length.
39
+ """
40
+ fill = " " * max(0, width - _width(text))
41
+ return fill + text if right else text + fill
42
+
43
+
44
+ def _text_bar(fraction: float, width: int = 20) -> str:
45
+ fraction = max(0.0, min(1.0, fraction))
46
+ filled = round(fraction * width)
47
+ return "█" * filled + "░" * (width - filled) + f" {fraction * 100:5.1f}%"
48
+
49
+
50
+ def search_rank(text: str, fields: Iterable[Any]) -> int | None:
51
+ """
52
+ Where something goes in a search for `text` among its fields (a name, an ID, a description...), best first:
53
+ 0 when a field is the text (an ID pasted whole), 1 when one starts with it, 2 when one holds it, 3 when every
54
+ word of it is in some field; None when a word is in none. Case is ignored, so 'k7qj' finds 'K7QJ2M4XNA'; an
55
+ empty search finds everything, at 3.
56
+ """
57
+ wanted = " ".join(str(text or "").lower().split())
58
+ if not wanted:
59
+ return 3
60
+ values = [" ".join(str(f).lower().split()) for f in fields if f]
61
+ if wanted in values:
62
+ return 0
63
+ if any(v.startswith(wanted) for v in values):
64
+ return 1
65
+ if any(wanted in v for v in values):
66
+ return 2
67
+ if all(any(word in v for v in values) for word in wanted.split()):
68
+ return 3
69
+ return None
70
+
71
+
72
+ def _marked(text: str, words: Iterable[str]) -> str:
73
+ """
74
+ HTML for `text` with every place a search word is found, in any case and inside longer words ('k7qj' in
75
+ 'K7QJ2M4XNA'), in <mark>. Each piece is escaped before it's wrapped.
76
+ """
77
+ unique = sorted({w for w in words if w}, key=len, reverse=True)
78
+ if not unique:
79
+ return _esc(text)
80
+ regex = re.compile("(" + "|".join(re.escape(w) for w in unique) + ")", re.IGNORECASE)
81
+ return "".join(f"<mark>{_esc(piece)}</mark>" if i % 2 else _esc(piece) for i, piece in enumerate(regex.split(text)))
@@ -0,0 +1,333 @@
1
+ """
2
+ Libraries: what the code reaches through a library that isn't boto3. A library plugin knows a library's clients
3
+ (a Redis client, a database connection and its cursors, an OpenAI client) and what each of their calls does (reads
4
+ a key, writes a table, runs a model). The reader keeps track of the clients the code makes as it does boto3's, asks
5
+ the plugin about each call made through one, and the graph draws a box for each service they reach. Pure, like
6
+ analysis/: no I/O, no rendering.
7
+
8
+ Unlike a platform (one per map), libraries stack: each is asked about the modules it names, so code that imports
9
+ redis, psycopg2 and openai is read with all three, and the platform's checks apply to what each finds (on Lambda,
10
+ a connection made on every call; a client that waits longer than the function's timeout).
11
+
12
+ Another package adds one by subclassing Library and naming it under the `code_node.libraries` entry point group:
13
+
14
+ [project.entry-points."code_node.libraries"]
15
+ kafka = "my_package.kafka:Kafka"
16
+
17
+ or, from a script, with register_library(Kafka()).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ from dataclasses import dataclass, field, replace
24
+ from typing import TYPE_CHECKING, Any, Callable
25
+
26
+ from ..analysis.syntax import _a, _const, _unparse
27
+
28
+ if TYPE_CHECKING:
29
+ from ..analysis.model import CodeMap, LibraryCall
30
+
31
+ ENTRY_POINT_GROUP = "code_node.libraries"
32
+
33
+ _BUILT_IN = (
34
+ "code_node.libraries.redis:RedisLibrary",
35
+ "code_node.libraries.sql:SqlLibrary",
36
+ "code_node.libraries.mongo:MongoLibrary",
37
+ "code_node.libraries.llm:LlmLibrary",
38
+ )
39
+
40
+ ACCESS = ("reads", "writes", "sends", "runs") # what a call does to what it reaches, as for AWS calls
41
+
42
+ ICONS = ("database", "queue", "key", "send", "spark", "bucket", "flow", "cloud") # what a service's box can show
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Handle:
47
+ """
48
+ Something a library gives the code that the reader keeps track of, as a name holds it: a Redis client, a
49
+ database cursor, a MongoDB collection. kind is the library's own word for it; facts are what the plugin wants
50
+ to remember about it, as (name, value) pairs: (('database', 'shop'), ('collection', 'orders')).
51
+ """
52
+
53
+ kind: str # 'client', 'connection', 'cursor', 'collection'
54
+ service: str = "" # what it reaches, as people name it: 'Redis', 'PostgreSQL'
55
+ facts: tuple[tuple[str, str], ...] = ()
56
+ opens: bool = False # making it opens a client or a connection: better made once, at module level
57
+ waits: str = "" # how long a call through it waits when the service doesn't answer, when that's too long
58
+ # ('as long as Redis takes'); '' when it's given a timeout
59
+ fix: str = "" # ...and how to give it one: 'redis.Redis(..., socket_timeout=5)'
60
+
61
+ def fact(self, name: str, default: str = "") -> str:
62
+ return next((value for key, value in self.facts if key == name), default)
63
+
64
+ def but(self, kind: str | None = None, **facts: str) -> Handle:
65
+ """
66
+ A handle made from this one: another kind, with more facts (or new values for some). It doesn't open
67
+ anything itself.
68
+ """
69
+ kept = tuple((key, value) for key, value in self.facts if key not in facts)
70
+ return replace(self, kind=kind or self.kind, facts=kept + tuple(facts.items()), opens=False)
71
+
72
+
73
+ @dataclass
74
+ class Use:
75
+ """
76
+ What a call through a library does, for the map: its operation, how it touches the service, and what on.
77
+ """
78
+
79
+ operation: str # 'GET', 'INSERT', 'chat.completions.create'
80
+ access: str = "reads" # one of ACCESS
81
+ target: str = "" # a key, a table, a collection, a model: 'cart:{user}', 'orders'
82
+ service: str = "" # the box it's drawn in ('' for the handle's service, else the library's title)
83
+ icon: str = "" # '' for the library's
84
+ flag: str = "" # a warning about this call, in a few words, said on its box and its step: 'built from the event'
85
+ why: str = "" # ...and for the findings, the rest of a sentence that starts with the call and where it is ('Redis
86
+ # KEYS at app.py line 9'): 'walks every key in the database, ... Use scan_iter(match=...) instead.'
87
+ batch: str = "" # when it's made in a loop, the call that does many at once: 'executemany()'
88
+ gives: Handle | None = None # what the call returns, when that's worth keeping track of (a cursor)
89
+
90
+
91
+ @dataclass
92
+ class _Argument:
93
+ """
94
+ What the reader knows about one argument of a call.
95
+ """
96
+
97
+ handle: Handle | None = None # a handle it holds
98
+ text: str = "" # the text it's built from, as code: the f-string a name holds, not the name
99
+ inputs: tuple[str, ...] = () # what goes into it from the entry point's input: ('event body.id',)
100
+ built: bool = False # text put together while the code runs (an f-string, +, % or .format()), not a literal
101
+
102
+
103
+ @dataclass
104
+ class Call:
105
+ """
106
+ A call a library plugin is asked about, as the reader sees it: its arguments as written, the literals among
107
+ them, what each one holds, and where the call is.
108
+ """
109
+
110
+ name: str # the function's full name ('redis.Redis', 'sqlite3.connect'), or the method's ('get')
111
+ node: ast.Call
112
+ loop: str = "" # the loop it's in: 'for item in items:'
113
+ startup: bool = False # made when the code is imported
114
+ once: bool = False # made once and kept: at import, in a cached function, or kept in a global
115
+ look: Callable[[ast.expr], _Argument] = field(default=lambda node: _Argument(), repr=False)
116
+
117
+ def argument(self, index: int | None = None, keyword: str = "") -> ast.expr | None:
118
+ """
119
+ An argument as written: the index-th positional one, else the keyword one.
120
+ """
121
+ if index is not None and index < len(self.node.args) and not isinstance(self.node.args[index], ast.Starred):
122
+ return self.node.args[index]
123
+ return next((kw.value for kw in self.node.keywords if keyword and kw.arg == keyword), None)
124
+
125
+ def given(self, *keywords: str) -> bool:
126
+ """
127
+ Whether any of these keyword arguments is passed (or **kwargs, which could hold it).
128
+ """
129
+ return any(kw.arg in keywords or kw.arg is None for kw in self.node.keywords)
130
+
131
+ def value(self, index: int | None = None, keyword: str = "") -> Any:
132
+ """
133
+ A literal argument's value: 'orders', 5. None when it isn't a literal.
134
+ """
135
+ node = self.argument(index, keyword)
136
+ return _const(node) if node is not None else None
137
+
138
+ def code(self, index: int | None = None, keyword: str = "", most: int = 60) -> str:
139
+ """
140
+ An argument as written: "f'cart:{user}'". '' when it isn't passed.
141
+ """
142
+ node = self.argument(index, keyword)
143
+ return _unparse(node, most) if node is not None else ""
144
+
145
+ def shown(self, index: int | None = None, keyword: str = "", most: int = 40) -> str:
146
+ """
147
+ An argument as a box lists it: a literal string as it is ('orders'), an f-string with what goes in it
148
+ ('cart:{user}'), else the code ('key').
149
+ """
150
+ node = self.argument(index, keyword)
151
+ if node is None:
152
+ return ""
153
+ if isinstance(_const(node), str):
154
+ return _short(str(_const(node)), most)
155
+ if isinstance(node, ast.JoinedStr):
156
+ parts = [
157
+ str(_const(v)) if isinstance(_const(v), str) else "{" + _unparse(v.value, 20) + "}"
158
+ for v in node.values
159
+ if isinstance(v, (ast.Constant, ast.FormattedValue))
160
+ ]
161
+ return _short("".join(parts), most)
162
+ return _short(_unparse(node, most), most)
163
+
164
+ def held(self, index: int | None = None, keyword: str = "") -> Handle | None:
165
+ """
166
+ The handle an argument holds: the connection passed to a function, the text() SQLAlchemy made.
167
+ """
168
+ node = self.argument(index, keyword)
169
+ return self.look(node).handle if node is not None else None
170
+
171
+ def text(self, index: int | None = None, keyword: str = "") -> str:
172
+ """
173
+ The text an argument is, as code: the literal or f-string passed, or the one a name passed holds
174
+ ('sql' -> "f'SELECT * FROM orders WHERE id = {order_id}'"). '' when it can't tell.
175
+ """
176
+ node = self.argument(index, keyword)
177
+ if node is None:
178
+ return ""
179
+ found = self.look(node)
180
+ if found.text:
181
+ return found.text
182
+ if isinstance(_const(node), str) or isinstance(node, (ast.JoinedStr, ast.BinOp)):
183
+ return _unparse(node, 400)
184
+ return ""
185
+
186
+ def built(self, index: int | None = None, keyword: str = "") -> bool:
187
+ """
188
+ Whether an argument is text put together as the code runs (an f-string, +, % or .format(), where it's
189
+ passed or in the name passed), rather than a literal.
190
+ """
191
+ node = self.argument(index, keyword)
192
+ return node is not None and self.look(node).built
193
+
194
+ def inputs(self, index: int | None = None, keyword: str = "") -> list[str]:
195
+ """
196
+ What from the entry point's input goes into an argument, however many names it went through on the way:
197
+ ['event body.id']. That's what whoever calls the code controls.
198
+ """
199
+ node = self.argument(index, keyword)
200
+ return list(self.look(node).inputs) if node is not None else []
201
+
202
+
203
+ def _short(text: str, most: int) -> str:
204
+ return text if len(text) <= most else text[: most - 1] + "…"
205
+
206
+
207
+ class Library:
208
+ """
209
+ A library the reader can follow calls through. A plugin sets `name`, `title` and `modules` (the top-level
210
+ modules it's for), and overrides the hooks below: make() for what the module's functions and classes give the
211
+ code, attribute(), item() and call() for what's done with those, describe() for how a name holding one is said,
212
+ and findings() for what's worth fixing in its calls. Each hook returns None for what it doesn't know, which the
213
+ reader then leaves alone.
214
+ """
215
+
216
+ name = "" # 'redis'
217
+ title = "" # 'Redis': how people call it, said where the page lists the plugins it used
218
+ modules: tuple[str, ...] = () # the top-level modules it's for: ('redis',). Code that imports none of them never
219
+ # reaches it
220
+ icon = "cloud" # its boxes' icon, one of ICONS
221
+
222
+ def make(self, name: str, call: Call) -> Handle | Use | None:
223
+ """
224
+ A call to a function or class of its modules, by its full name ('redis.Redis', 'redis.from_url',
225
+ 'sqlite3.connect'): the Handle it gives the code, or what it does (a Use).
226
+ """
227
+ return None
228
+
229
+ def attribute(self, handle: Handle, name: str) -> Handle | None:
230
+ """
231
+ An attribute read from a handle that is itself something to keep track of: mongo_client.shop (a database),
232
+ openai_client.chat (part of the client). None for a method: calling it asks call().
233
+ """
234
+ return None
235
+
236
+ def item(self, handle: Handle, key: Any) -> Handle | None:
237
+ """
238
+ handle[key]: mongo_client['shop']. key is the literal, or None when it isn't one.
239
+ """
240
+ return None
241
+
242
+ def call(self, handle: Handle, method: str, call: Call) -> Handle | Use | None:
243
+ """
244
+ A method called on a handle: what it does (a Use, which can give another handle), or the handle it gives
245
+ (a cursor from a connection).
246
+ """
247
+ return None
248
+
249
+ def describe(self, handle: Handle) -> str:
250
+ """
251
+ What a name holding the handle holds, in a few words: 'a Redis client', 'a PostgreSQL cursor'.
252
+ """
253
+ service = handle.service or self.title
254
+ return f"{_a(service)} {service} {handle.kind}"
255
+
256
+ def findings(self, cmap: CodeMap, calls: list[LibraryCall]) -> list[tuple[str, str]]:
257
+ """
258
+ Its own findings, as (level, message), from its calls in the map. The general ones (flagged calls, calls in
259
+ a loop that a batch call would do, clients that wait too long or connect on every call) are made for every
260
+ library.
261
+ """
262
+ return []
263
+
264
+
265
+ _libraries: dict[str, Library] = {}
266
+
267
+ _loaded = False
268
+
269
+
270
+ def register_library(library: Library) -> None:
271
+ """
272
+ Adds a library plugin (or replaces the one with its name): from then on, calls through its modules are followed.
273
+ """
274
+ if not library.name or not library.modules:
275
+ raise ValueError("a library plugin needs a name and the modules it's for")
276
+ libraries()
277
+ _libraries[library.name] = library
278
+
279
+
280
+ def _add(found: Library | type[Library]) -> None:
281
+ library = found() if isinstance(found, type) else found
282
+ if library.name and library.modules:
283
+ _libraries.setdefault(library.name, library) # one registered by hand first wins
284
+
285
+
286
+ def libraries() -> list[Library]:
287
+ """
288
+ Every library plugin: the built-in ones, then those installed packages add, loaded the first time it's asked.
289
+ """
290
+ global _loaded
291
+ if not _loaded:
292
+ _loaded = True
293
+ from importlib import import_module
294
+ from importlib.metadata import entry_points
295
+
296
+ for target in _BUILT_IN:
297
+ module, _, attr = target.partition(":")
298
+ _add(getattr(import_module(module), attr))
299
+ for point in entry_points(group=ENTRY_POINT_GROUP):
300
+ try:
301
+ _add(point.load())
302
+ except Exception: # noqa: BLE001 a broken plugin leaves the others working
303
+ continue
304
+ return list(_libraries.values())
305
+
306
+
307
+ def library_for(module: str) -> Library | None:
308
+ """
309
+ The library plugin for a top-level module ('redis'), or None.
310
+ """
311
+ return next((library for library in reversed(libraries()) if module in library.modules), None)
312
+
313
+
314
+ def get_library(name: str) -> Library | None:
315
+ """
316
+ The library plugin with that name, or None.
317
+ """
318
+ return next((library for library in libraries() if library.name == name), None)
319
+
320
+
321
+ __all__ = [
322
+ "ACCESS",
323
+ "ENTRY_POINT_GROUP",
324
+ "ICONS",
325
+ "Call",
326
+ "Handle",
327
+ "Library",
328
+ "Use",
329
+ "get_library",
330
+ "libraries",
331
+ "library_for",
332
+ "register_library",
333
+ ]
@@ -0,0 +1,124 @@
1
+ """
2
+ Language models through their SDKs: OpenAI (and Azure OpenAI) and Anthropic. A call names its model; a client made
3
+ without a timeout waits the SDK's own (ten minutes, then it tries again twice), which a short-lived function never
4
+ lives to see.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..analysis.syntax import _a
10
+ from . import Call, Handle, Library, Use
11
+
12
+ _CLIENTS = {
13
+ "OpenAI": "OpenAI",
14
+ "AsyncOpenAI": "OpenAI",
15
+ "AzureOpenAI": "Azure OpenAI",
16
+ "AsyncAzureOpenAI": "Azure OpenAI",
17
+ "Anthropic": "Anthropic",
18
+ "AsyncAnthropic": "Anthropic",
19
+ "AnthropicBedrock": "Anthropic on Bedrock",
20
+ "AsyncAnthropicBedrock": "Anthropic on Bedrock",
21
+ "AnthropicVertex": "Anthropic on Vertex AI",
22
+ "AsyncAnthropicVertex": "Anthropic on Vertex AI",
23
+ }
24
+
25
+ _PARTS = frozenset(
26
+ { # a client's attributes that are parts of it, not methods: client.chat.completions.create
27
+ "chat",
28
+ "completions",
29
+ "embeddings",
30
+ "images",
31
+ "audio",
32
+ "speech",
33
+ "transcriptions",
34
+ "translations",
35
+ "moderations",
36
+ "responses",
37
+ "files",
38
+ "batches",
39
+ "models",
40
+ "fine_tuning",
41
+ "jobs",
42
+ "vector_stores",
43
+ "uploads",
44
+ "beta",
45
+ "threads",
46
+ "runs",
47
+ "messages",
48
+ "assistants",
49
+ "realtime",
50
+ "containers",
51
+ "conversations",
52
+ "evals",
53
+ "webhooks",
54
+ "skills",
55
+ }
56
+ )
57
+
58
+ _READS = frozenset({"list", "retrieve", "count_tokens", "content", "results", "get"})
59
+
60
+ _WRITES = frozenset({"delete", "cancel", "upload"})
61
+
62
+ _LEGACY = { # openai < 1.0: module functions -> the call they made
63
+ "openai.ChatCompletion.create": "chat.completions.create",
64
+ "openai.Completion.create": "completions.create",
65
+ "openai.Embedding.create": "embeddings.create",
66
+ "openai.Image.create": "images.generate",
67
+ }
68
+
69
+ _WAITS = "up to 10 minutes a try (the SDK's default timeout), and it tries 3 times"
70
+
71
+
72
+ class LlmLibrary(Library):
73
+ """
74
+ The OpenAI and Anthropic Python SDKs: a client, then client.chat.completions.create(model=...),
75
+ client.responses.create(...), client.messages.create(...), client.embeddings.create(...).
76
+ """
77
+
78
+ name = "llm"
79
+ title = "OpenAI and Anthropic"
80
+ modules = ("openai", "anthropic")
81
+ icon = "spark"
82
+
83
+ def make(self, name: str, call: Call) -> Handle | Use | None:
84
+ last = name.rsplit(".", 1)[-1]
85
+ if last in _CLIENTS:
86
+ return Handle(
87
+ "client",
88
+ _CLIENTS[last],
89
+ opens=True,
90
+ waits="" if call.given("timeout") else _WAITS,
91
+ fix=f"{last}(timeout=20, max_retries=1)",
92
+ )
93
+ if name in _LEGACY:
94
+ return self._use("OpenAI", _LEGACY[name], call)
95
+ return None
96
+
97
+ def attribute(self, handle: Handle, name: str) -> Handle | None:
98
+ if name in _PARTS:
99
+ path = f"{handle.fact('path')}.{name}".lstrip(".")
100
+ return handle.but("client", path=path)
101
+ return None
102
+
103
+ def call(self, handle: Handle, method: str, call: Call) -> Handle | Use | None:
104
+ if method in ("with_options", "copy"):
105
+ timed = call.given("timeout")
106
+ return Handle(handle.kind, handle.service, handle.facts, waits="" if timed else handle.waits)
107
+ path = handle.fact("path")
108
+ if not path:
109
+ return None
110
+ return self._use(handle.service, f"{path}.{method}", call)
111
+
112
+ def _use(self, service: str, operation: str, call: Call) -> Use:
113
+ method = operation.rsplit(".", 1)[-1]
114
+ model = call.shown(None, "model") or call.shown(None, "engine")
115
+ if method in _READS or operation.startswith("models."):
116
+ return Use(operation, "reads", model, service)
117
+ if method in _WRITES or operation.startswith(("files.", "vector_stores.", "uploads.")):
118
+ return Use(operation, "writes", model, service)
119
+ batch = "" if operation.startswith("batches.") else "the Batch API (half the price, when the answers can wait)"
120
+ return Use(operation, "runs", model, service, batch=batch)
121
+
122
+ def describe(self, handle: Handle) -> str:
123
+ path = handle.fact("path")
124
+ return f"{handle.service}'s {path}" if path else f"{_a(handle.service)} {handle.service} client"