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.
- code_node/__init__.py +40 -0
- code_node/__main__.py +7 -0
- code_node/analysis/__init__.py +3 -0
- code_node/analysis/aws_model.py +197 -0
- code_node/analysis/findings.py +462 -0
- code_node/analysis/graph.py +780 -0
- code_node/analysis/mapping.py +114 -0
- code_node/analysis/model.py +289 -0
- code_node/analysis/reader.py +1926 -0
- code_node/analysis/syntax.py +220 -0
- code_node/analysis/terms.py +505 -0
- code_node/cli.py +316 -0
- code_node/deploys/__init__.py +321 -0
- code_node/deploys/sam.py +371 -0
- code_node/deploys/serverless.py +117 -0
- code_node/kit/__init__.py +4 -0
- code_node/kit/blocks.py +184 -0
- code_node/kit/fmt.py +389 -0
- code_node/kit/highlight.py +131 -0
- code_node/kit/render.py +1066 -0
- code_node/kit/text.py +81 -0
- code_node/libraries/__init__.py +333 -0
- code_node/libraries/llm.py +124 -0
- code_node/libraries/mongo.py +169 -0
- code_node/libraries/redis.py +253 -0
- code_node/libraries/sql.py +232 -0
- code_node/pages.py +92 -0
- code_node/platforms/__init__.py +203 -0
- code_node/platforms/aws_lambda.py +290 -0
- code_node/platforms/web.py +301 -0
- code_node/project.py +402 -0
- code_node/report/__init__.py +3 -0
- code_node/report/brands.py +244 -0
- code_node/report/build.py +119 -0
- code_node/report/editor.py +40 -0
- code_node/report/graph.py +1411 -0
- code_node/report/logos/README.md +8 -0
- code_node/report/logos/anthropic-icon.svg +1 -0
- code_node/report/logos/aws-amplify.svg +1 -0
- code_node/report/logos/aws-api-gateway.svg +1 -0
- code_node/report/logos/aws-appflow.svg +1 -0
- code_node/report/logos/aws-appsync.svg +1 -0
- code_node/report/logos/aws-athena.svg +1 -0
- code_node/report/logos/aws-backup.svg +1 -0
- code_node/report/logos/aws-batch.svg +1 -0
- code_node/report/logos/aws-certificate-manager.svg +1 -0
- code_node/report/logos/aws-cloudformation.svg +1 -0
- code_node/report/logos/aws-cloudfront.svg +1 -0
- code_node/report/logos/aws-cloudtrail.svg +1 -0
- code_node/report/logos/aws-cloudwatch.svg +1 -0
- code_node/report/logos/aws-codebuild.svg +1 -0
- code_node/report/logos/aws-codecommit.svg +1 -0
- code_node/report/logos/aws-codedeploy.svg +1 -0
- code_node/report/logos/aws-codepipeline.svg +1 -0
- code_node/report/logos/aws-cognito.svg +1 -0
- code_node/report/logos/aws-config.svg +1 -0
- code_node/report/logos/aws-documentdb.svg +1 -0
- code_node/report/logos/aws-dynamodb.svg +1 -0
- code_node/report/logos/aws-ec2.svg +1 -0
- code_node/report/logos/aws-ecs.svg +1 -0
- code_node/report/logos/aws-eks.svg +1 -0
- code_node/report/logos/aws-elasticache.svg +1 -0
- code_node/report/logos/aws-elb.svg +1 -0
- code_node/report/logos/aws-eventbridge.svg +1 -0
- code_node/report/logos/aws-glacier.svg +1 -0
- code_node/report/logos/aws-glue.svg +1 -0
- code_node/report/logos/aws-iam.svg +1 -0
- code_node/report/logos/aws-keyspaces.svg +1 -0
- code_node/report/logos/aws-kinesis.svg +1 -0
- code_node/report/logos/aws-kms.svg +1 -0
- code_node/report/logos/aws-lake-formation.svg +1 -0
- code_node/report/logos/aws-lambda.svg +1 -0
- code_node/report/logos/aws-lightsail.svg +1 -0
- code_node/report/logos/aws-mq.svg +1 -0
- code_node/report/logos/aws-msk.svg +1 -0
- code_node/report/logos/aws-neptune.svg +1 -0
- code_node/report/logos/aws-open-search.svg +1 -0
- code_node/report/logos/aws-quicksight.svg +1 -0
- code_node/report/logos/aws-rds.svg +1 -0
- code_node/report/logos/aws-redshift.svg +1 -0
- code_node/report/logos/aws-route53.svg +1 -0
- code_node/report/logos/aws-s3.svg +1 -0
- code_node/report/logos/aws-secrets-manager.svg +1 -0
- code_node/report/logos/aws-ses.svg +1 -0
- code_node/report/logos/aws-shield.svg +1 -0
- code_node/report/logos/aws-sns.svg +1 -0
- code_node/report/logos/aws-sqs.svg +1 -0
- code_node/report/logos/aws-step-functions.svg +1 -0
- code_node/report/logos/aws-systems-manager.svg +1 -0
- code_node/report/logos/aws-timestream.svg +1 -0
- code_node/report/logos/aws-waf.svg +1 -0
- code_node/report/logos/aws-xray.svg +1 -0
- code_node/report/logos/aws.svg +1 -0
- code_node/report/logos/azure-icon.svg +1 -0
- code_node/report/logos/django-icon.svg +1 -0
- code_node/report/logos/elasticsearch.svg +1 -0
- code_node/report/logos/fastapi-icon.svg +1 -0
- code_node/report/logos/firebase-icon.svg +1 -0
- code_node/report/logos/flask.svg +1 -0
- code_node/report/logos/google-cloud.svg +1 -0
- code_node/report/logos/kafka-icon.svg +1 -0
- code_node/report/logos/mariadb-icon.svg +1 -0
- code_node/report/logos/mongodb-icon.svg +1 -0
- code_node/report/logos/mysql-icon.svg +1 -0
- code_node/report/logos/openai-icon.svg +1 -0
- code_node/report/logos/postgresql.svg +1 -0
- code_node/report/logos/python.svg +1 -0
- code_node/report/logos/rabbitmq-icon.svg +1 -0
- code_node/report/logos/redis.svg +1 -0
- code_node/report/logos/serverless.svg +1 -0
- code_node/report/logos/slack-icon.svg +1 -0
- code_node/report/logos/small/aws-api-gateway.svg +1 -0
- code_node/report/logos/small/aws-appflow.svg +1 -0
- code_node/report/logos/small/aws-appsync.svg +1 -0
- code_node/report/logos/small/aws-athena.svg +1 -0
- code_node/report/logos/small/aws-backup.svg +1 -0
- code_node/report/logos/small/aws-batch.svg +1 -0
- code_node/report/logos/small/aws-certificate-manager.svg +1 -0
- code_node/report/logos/small/aws-cloudformation.svg +1 -0
- code_node/report/logos/small/aws-cloudfront.svg +1 -0
- code_node/report/logos/small/aws-cloudtrail.svg +1 -0
- code_node/report/logos/small/aws-cloudwatch.svg +1 -0
- code_node/report/logos/small/aws-codebuild.svg +1 -0
- code_node/report/logos/small/aws-codecommit.svg +1 -0
- code_node/report/logos/small/aws-codedeploy.svg +1 -0
- code_node/report/logos/small/aws-codepipeline.svg +1 -0
- code_node/report/logos/small/aws-cognito.svg +1 -0
- code_node/report/logos/small/aws-config.svg +1 -0
- code_node/report/logos/small/aws-documentdb.svg +1 -0
- code_node/report/logos/small/aws-dynamodb.svg +1 -0
- code_node/report/logos/small/aws-ec2.svg +1 -0
- code_node/report/logos/small/aws-ecs.svg +1 -0
- code_node/report/logos/small/aws-eks.svg +1 -0
- code_node/report/logos/small/aws-elasticache.svg +1 -0
- code_node/report/logos/small/aws-elb.svg +1 -0
- code_node/report/logos/small/aws-eventbridge.svg +1 -0
- code_node/report/logos/small/aws-glacier.svg +1 -0
- code_node/report/logos/small/aws-glue.svg +1 -0
- code_node/report/logos/small/aws-iam.svg +1 -0
- code_node/report/logos/small/aws-keyspaces.svg +1 -0
- code_node/report/logos/small/aws-kinesis.svg +1 -0
- code_node/report/logos/small/aws-kms.svg +1 -0
- code_node/report/logos/small/aws-lake-formation.svg +1 -0
- code_node/report/logos/small/aws-lambda.svg +1 -0
- code_node/report/logos/small/aws-mq.svg +1 -0
- code_node/report/logos/small/aws-msk.svg +1 -0
- code_node/report/logos/small/aws-neptune.svg +1 -0
- code_node/report/logos/small/aws-open-search.svg +1 -0
- code_node/report/logos/small/aws-quicksight.svg +1 -0
- code_node/report/logos/small/aws-rds.svg +1 -0
- code_node/report/logos/small/aws-redshift.svg +1 -0
- code_node/report/logos/small/aws-route53.svg +1 -0
- code_node/report/logos/small/aws-s3.svg +1 -0
- code_node/report/logos/small/aws-secrets-manager.svg +1 -0
- code_node/report/logos/small/aws-ses.svg +1 -0
- code_node/report/logos/small/aws-shield.svg +1 -0
- code_node/report/logos/small/aws-sns.svg +1 -0
- code_node/report/logos/small/aws-sqs.svg +1 -0
- code_node/report/logos/small/aws-step-functions.svg +1 -0
- code_node/report/logos/small/aws-systems-manager.svg +1 -0
- code_node/report/logos/small/aws-timestream.svg +1 -0
- code_node/report/logos/small/aws-waf.svg +1 -0
- code_node/report/logos/small/aws-xray.svg +1 -0
- code_node/report/logos/snowflake-icon.svg +1 -0
- code_node/report/logos/sqlite.svg +1 -0
- code_node/report/logos/stripe.svg +1 -0
- code_node/report/logos/supabase-icon.svg +1 -0
- code_node/report/logos/twilio-icon.svg +1 -0
- code_node/report/outline.py +219 -0
- code_node/report/style.py +358 -0
- code_node/report/tabs.py +73 -0
- code_node/report/walk.py +624 -0
- code_node/serve.py +277 -0
- code_node_graph-0.1.0.dist-info/METADATA +165 -0
- code_node_graph-0.1.0.dist-info/RECORD +178 -0
- code_node_graph-0.1.0.dist-info/WHEEL +4 -0
- code_node_graph-0.1.0.dist-info/entry_points.txt +2 -0
- 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"
|