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/fmt.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Numbers, sizes, money, times and counts: parsing what users type in, and formatting what reports show; the time
|
|
3
|
+
zone reports show times in (set_timezone); file names' extensions; and Stat, a running count and its total bytes.
|
|
4
|
+
|
|
5
|
+
HOURS_PER_MONTH is how AWS turns an hourly price into a monthly one.
|
|
6
|
+
|
|
7
|
+
The zone reports show times in is set_timezone()'s choice (_chosen, which is _DEFAULT until it's called). Until then
|
|
8
|
+
it's the zone the AWS_ANALYZER_TZ environment variable (TIMEZONE_ENV) names, for every notebook, worked out once per
|
|
9
|
+
value (_from_env), else UTC.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import warnings
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import date, datetime, timedelta, timezone, tzinfo
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
HOURS_PER_MONTH = 730
|
|
23
|
+
|
|
24
|
+
_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([kmgtp]?)i?b?\s*$", re.IGNORECASE)
|
|
25
|
+
_RELATIVE_TIME_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smhdw])\s*$", re.IGNORECASE)
|
|
26
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 7 * 86400}
|
|
27
|
+
_COUNT_RE = re.compile(r"^\s*(\d[\d,_]*(?:\.\d+)?)\s*([km]?)\s*$", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def human_size(num_bytes: float | None) -> str:
|
|
31
|
+
"""
|
|
32
|
+
1536 -> '1.5 KB' (binary units, like the AWS console).
|
|
33
|
+
"""
|
|
34
|
+
if num_bytes is None:
|
|
35
|
+
return "-"
|
|
36
|
+
value = float(num_bytes)
|
|
37
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
38
|
+
if abs(value) < 1024:
|
|
39
|
+
return f"{int(value)} B" if unit == "B" else f"{value:.1f} {unit}"
|
|
40
|
+
value /= 1024
|
|
41
|
+
return f"{value:.1f} PB"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_size(value: int | float | str | None) -> int | None:
|
|
45
|
+
"""
|
|
46
|
+
'10MB', '1.5 GiB', '512k', 1024 -> bytes. Units are binary (1 KB = 1024 B).
|
|
47
|
+
"""
|
|
48
|
+
if value is None or isinstance(value, (int, float)):
|
|
49
|
+
return None if value is None else int(value)
|
|
50
|
+
match = _SIZE_RE.match(value)
|
|
51
|
+
if not match:
|
|
52
|
+
raise ValueError(f"Can't parse size {value!r}; try 1024, '10MB' or '1.5GB'")
|
|
53
|
+
number, unit = match.groups()
|
|
54
|
+
return int(float(number) * 1024 ** " kmgtp".index(unit.lower() or " "))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _utcnow() -> datetime:
|
|
58
|
+
return datetime.now(timezone.utc)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
TIMEZONE_ENV = "AWS_ANALYZER_TZ"
|
|
62
|
+
_OFFSET_RE = re.compile(r"^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$", re.IGNORECASE)
|
|
63
|
+
_NUMERIC_ZONE_RE = re.compile(r"^(?:UTC|GMT)?[+-][\d:]+$")
|
|
64
|
+
_SHORT_ZONE_RE = re.compile(r"[A-Z]{2,5}")
|
|
65
|
+
_LONGEST_OFFSET = timedelta(hours=14)
|
|
66
|
+
_DEFAULT = object()
|
|
67
|
+
_chosen: Any = _DEFAULT
|
|
68
|
+
_from_env: dict[str, tzinfo | None] = {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def set_timezone(zone: str | tzinfo | None) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Show times in your own time zone: set_timezone('Asia/Kolkata'). Every report and window from then on shows
|
|
74
|
+
its times there and says which zone they're in ('Last modified (IST)', '... since 2024-05-01 14:02 IST'), and
|
|
75
|
+
dates you type in ('2024-05-01', '2024-05-01 10:00') are read there too. Returns the zone's short name, the way
|
|
76
|
+
reports show it ('IST', 'PST/PDT', 'UTC').
|
|
77
|
+
|
|
78
|
+
zone is a tz database name ('Europe/London', 'America/New_York'; its city alone works too: 'kolkata',
|
|
79
|
+
'new york'), 'local' for this machine's own zone, 'UTC', an offset ('+05:30', 'UTC-4') or a tzinfo. None goes
|
|
80
|
+
back to the default: the zone the AWS_ANALYZER_TZ environment variable names, else UTC. SageMaker's machines
|
|
81
|
+
keep their clocks in UTC, so 'local' is UTC there: name your zone instead.
|
|
82
|
+
|
|
83
|
+
A few things stay in UTC whatever the zone, and say so: CloudWatch's daily numbers (Lambda's "Day (UTC)"), which
|
|
84
|
+
AWS adds up by UTC day.
|
|
85
|
+
"""
|
|
86
|
+
global _chosen
|
|
87
|
+
_chosen = _DEFAULT if zone is None else _zone_from(zone)
|
|
88
|
+
return _tz_label()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _zone_from(value: str | tzinfo) -> tzinfo | None:
|
|
92
|
+
"""
|
|
93
|
+
What set_timezone() takes -> a tzinfo, or None for this machine's own zone. A name it can't find raises a
|
|
94
|
+
ValueError that says what it takes, with the closest names.
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(value, tzinfo):
|
|
97
|
+
return value
|
|
98
|
+
if not isinstance(value, str):
|
|
99
|
+
raise TypeError(f"set_timezone() takes a zone's name, like 'Asia/Kolkata', or a tzinfo; got {value!r}")
|
|
100
|
+
text = value.strip()
|
|
101
|
+
if text.lower() == "local":
|
|
102
|
+
return None
|
|
103
|
+
if text.upper() in ("UTC", "GMT", "Z"):
|
|
104
|
+
return timezone.utc
|
|
105
|
+
offset = _OFFSET_RE.match(text)
|
|
106
|
+
if offset:
|
|
107
|
+
return _offset_zone(offset, value)
|
|
108
|
+
return _named_zone(text, value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _offset_zone(offset: re.Match, value: str) -> tzinfo:
|
|
112
|
+
"""
|
|
113
|
+
The fixed zone an offset from UTC names ('+05:30', 'UTC-4'). One past +14:00, or with 60 minutes or more,
|
|
114
|
+
raises a ValueError.
|
|
115
|
+
"""
|
|
116
|
+
sign, hours, minutes = offset.groups()
|
|
117
|
+
delta = timedelta(hours=int(hours), minutes=int(minutes or 0))
|
|
118
|
+
if delta > _LONGEST_OFFSET or int(minutes or 0) >= 60:
|
|
119
|
+
raise ValueError(f"{value!r} isn't an offset from UTC: they run from -12:00 to +14:00")
|
|
120
|
+
return timezone(-delta if sign == "-" else delta)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _zone_places(names: set[str]) -> dict[str, str]:
|
|
124
|
+
"""
|
|
125
|
+
Each way to name a tz database zone, lowercase with spaces for underscores, -> its name: its full name
|
|
126
|
+
('asia/kolkata', 'america/new york') and its city alone ('kolkata', 'new york'). Where two zones share a city,
|
|
127
|
+
the one with fewer parts in its name wins: 'louisville' is America/Louisville, not America/Kentucky/Louisville.
|
|
128
|
+
"""
|
|
129
|
+
places: dict[str, str] = {}
|
|
130
|
+
for name in sorted(names, key=lambda n: (n.count("/"), n)):
|
|
131
|
+
full = name.lower().replace("_", " ")
|
|
132
|
+
places.setdefault(full, name)
|
|
133
|
+
places.setdefault(full.rsplit("/", 1)[-1], name)
|
|
134
|
+
return places
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _named_zone(text: str, value: str) -> tzinfo:
|
|
138
|
+
"""
|
|
139
|
+
The tz database zone named by its full name or its city, in any case. A Python without a tz database (Windows
|
|
140
|
+
has none of its own) or a name it can't find raises a ValueError that says what it takes, with the closest
|
|
141
|
+
names.
|
|
142
|
+
"""
|
|
143
|
+
from zoneinfo import ZoneInfo, available_timezones
|
|
144
|
+
|
|
145
|
+
names = available_timezones()
|
|
146
|
+
if not names:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Can't look up {value!r}: this Python has no time zone database. pip install tzdata, or "
|
|
149
|
+
"give an offset, like '+05:30'"
|
|
150
|
+
)
|
|
151
|
+
places = _zone_places(names)
|
|
152
|
+
asked = re.sub(r"[\s_]+", " ", text.lower())
|
|
153
|
+
if asked in places:
|
|
154
|
+
return ZoneInfo(places[asked])
|
|
155
|
+
close = dict.fromkeys(places[c] for c in difflib.get_close_matches(asked, list(places), n=3, cutoff=0.7))
|
|
156
|
+
hint = f" Did you mean {' or '.join(map(repr, close))}?" if close else ""
|
|
157
|
+
if _SHORT_ZONE_RE.fullmatch(text):
|
|
158
|
+
hint += f" A short name like {text} can mean more than one zone (IST is India's, Ireland's and Israel's)."
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"{value!r} isn't a time zone set_timezone() knows.{hint} Name it the way the tz database does "
|
|
161
|
+
"('Asia/Kolkata', 'America/New_York'), by its city ('kolkata'), or by its offset ('+05:30')"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _display_zone() -> tzinfo | None:
|
|
166
|
+
"""
|
|
167
|
+
The zone reports show times in: set_timezone()'s, else the one AWS_ANALYZER_TZ names, else UTC. None is this
|
|
168
|
+
machine's own zone.
|
|
169
|
+
"""
|
|
170
|
+
if _chosen is not _DEFAULT:
|
|
171
|
+
return _chosen
|
|
172
|
+
name = os.environ.get(TIMEZONE_ENV, "").strip()
|
|
173
|
+
if not name:
|
|
174
|
+
return timezone.utc
|
|
175
|
+
if name not in _from_env:
|
|
176
|
+
try:
|
|
177
|
+
_from_env[name] = _zone_from(name)
|
|
178
|
+
except ValueError as exc:
|
|
179
|
+
warnings.warn(f"{TIMEZONE_ENV}={name!r}: {exc}. Times show in UTC.", stacklevel=2)
|
|
180
|
+
_from_env[name] = timezone.utc
|
|
181
|
+
return _from_env[name]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _in_zone(moment: datetime) -> datetime:
|
|
185
|
+
"""
|
|
186
|
+
The moment in the zone reports show times in (UTC unless set_timezone() chose another).
|
|
187
|
+
"""
|
|
188
|
+
return moment.astimezone(_display_zone())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _zone_name(moment: datetime) -> str:
|
|
192
|
+
"""
|
|
193
|
+
The zone of a moment _in_zone() gave, as reports name it: 'UTC', 'IST', 'PDT'; 'UTC+04' or 'UTC+05:45' for a
|
|
194
|
+
zone that has no name in letters.
|
|
195
|
+
"""
|
|
196
|
+
name = moment.tzname() or ""
|
|
197
|
+
if name and not _NUMERIC_ZONE_RE.match(name):
|
|
198
|
+
return name
|
|
199
|
+
minutes = round((moment.utcoffset() or timedelta(0)).total_seconds() / 60)
|
|
200
|
+
hours, rest = divmod(abs(minutes), 60)
|
|
201
|
+
return "UTC" if not minutes else f"UTC{'-' if minutes < 0 else '+'}{hours:02d}" + (f":{rest:02d}" if rest else "")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _tz_label() -> str:
|
|
205
|
+
"""
|
|
206
|
+
The name of the zone reports show times in, for a column of times ('Last modified (IST)'): 'UTC', 'IST', or
|
|
207
|
+
both of a zone's names when it changes its clocks, standard time first ('PST/PDT', 'GMT/BST').
|
|
208
|
+
"""
|
|
209
|
+
zone, year = _display_zone(), _utcnow().year
|
|
210
|
+
moments = [datetime(year, month, 1, 12, tzinfo=timezone.utc).astimezone(zone) for month in (1, 7)]
|
|
211
|
+
named = sorted((m.utcoffset() or timedelta(0), _zone_name(m)) for m in moments)
|
|
212
|
+
return "/".join(dict.fromkeys(name for _, name in named))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_time(value: datetime | date | timedelta | str | None, now: datetime | None = None) -> datetime | None:
|
|
216
|
+
"""
|
|
217
|
+
datetime/date, ISO string ('2024-05-01', '2024-05-01T10:00Z'), or a relative
|
|
218
|
+
age like '7d', '12h', '30m', '2w' meaning "that long ago". Naive values are in the zone reports show times in:
|
|
219
|
+
UTC, unless set_timezone() chose another ('local' is this machine's zone, which astimezone() reads).
|
|
220
|
+
"""
|
|
221
|
+
if value is None:
|
|
222
|
+
return None
|
|
223
|
+
if isinstance(value, timedelta):
|
|
224
|
+
return (now or _utcnow()) - value
|
|
225
|
+
if isinstance(value, datetime):
|
|
226
|
+
moment = value
|
|
227
|
+
elif isinstance(value, date):
|
|
228
|
+
moment = datetime(value.year, value.month, value.day)
|
|
229
|
+
else:
|
|
230
|
+
relative = _RELATIVE_TIME_RE.match(str(value))
|
|
231
|
+
if relative:
|
|
232
|
+
seconds = float(relative.group(1)) * _UNIT_SECONDS[relative.group(2).lower()]
|
|
233
|
+
return (now or _utcnow()) - timedelta(seconds=seconds)
|
|
234
|
+
moment = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
|
235
|
+
if moment.tzinfo:
|
|
236
|
+
return moment
|
|
237
|
+
zone = _display_zone()
|
|
238
|
+
return moment.astimezone() if zone is None else moment.replace(tzinfo=zone)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def human_age(when: datetime | None, now: datetime | None = None) -> str:
|
|
242
|
+
"""
|
|
243
|
+
datetime -> '3d ago' / '5mo ago' / 'just now'.
|
|
244
|
+
"""
|
|
245
|
+
if when is None:
|
|
246
|
+
return "-"
|
|
247
|
+
seconds = ((now or _utcnow()) - when).total_seconds()
|
|
248
|
+
for unit, size in (
|
|
249
|
+
("y", 365 * 86400),
|
|
250
|
+
("mo", 30 * 86400),
|
|
251
|
+
("d", 86400),
|
|
252
|
+
("h", 3600),
|
|
253
|
+
("m", 60),
|
|
254
|
+
):
|
|
255
|
+
if seconds >= size:
|
|
256
|
+
return f"{int(seconds // size)}{unit} ago"
|
|
257
|
+
return "just now"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def human_money(usd: float | None) -> str:
|
|
261
|
+
"""
|
|
262
|
+
12.345 -> '$12.35', 0.004 -> '<$0.01', 12345.6 -> '$12,346', -3 -> '-$3.00'.
|
|
263
|
+
"""
|
|
264
|
+
if usd is None:
|
|
265
|
+
return "-"
|
|
266
|
+
sign, usd = ("-" if usd < 0 else ""), abs(usd)
|
|
267
|
+
if 0 < usd < 0.01:
|
|
268
|
+
return f"{sign}<$0.01"
|
|
269
|
+
return f"{sign}${usd:,.0f}" if usd >= 1000 else f"{sign}${usd:,.2f}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _fmt_dt(moment: datetime | None, *, seconds: bool = False, zone: bool = False) -> str:
|
|
273
|
+
"""
|
|
274
|
+
A moment as reports show it, in the zone set_timezone() chose (UTC unless): '2024-05-01 14:02', to the second
|
|
275
|
+
with seconds=True. zone=True adds the zone's name, for a time in a sentence: '2024-05-01 14:02 UTC'.
|
|
276
|
+
"""
|
|
277
|
+
if moment is None:
|
|
278
|
+
return "-"
|
|
279
|
+
moment = _in_zone(moment)
|
|
280
|
+
text = moment.strftime("%Y-%m-%d %H:%M:%S" if seconds else "%Y-%m-%d %H:%M")
|
|
281
|
+
return f"{text} {_zone_name(moment)}" if zone else text
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _duration(seconds: float) -> str:
|
|
285
|
+
"""
|
|
286
|
+
0.42 -> '0.4s', 42.4 -> '42s', 125 -> '2m 05s', 7500 -> '2h 05m'.
|
|
287
|
+
"""
|
|
288
|
+
if seconds < 10:
|
|
289
|
+
return f"{seconds:.1f}s"
|
|
290
|
+
if seconds < 60:
|
|
291
|
+
return f"{seconds:.0f}s"
|
|
292
|
+
if seconds < 3600:
|
|
293
|
+
return f"{int(seconds // 60)}m {int(seconds % 60):02d}s"
|
|
294
|
+
return f"{int(seconds // 3600)}h {int(seconds % 3600 // 60):02d}m"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _plural(count: int, word: str) -> str:
|
|
298
|
+
return f"{count:,} {word}{'' if count == 1 else 's'}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _count(value: int | None) -> str:
|
|
302
|
+
return "-" if value is None else f"{value:,}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _share(part: float, whole: float) -> float:
|
|
306
|
+
return part / whole if whole else 0.0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _band(value: float, bands: list[tuple[str, int | None]]) -> str:
|
|
310
|
+
for label, upper in bands:
|
|
311
|
+
if upper is None or value < upper:
|
|
312
|
+
return label
|
|
313
|
+
return bands[-1][0]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _total(cost: dict[str, float | None]) -> float | None:
|
|
317
|
+
"""
|
|
318
|
+
The sum of a cost breakdown, or None when no part of it is known.
|
|
319
|
+
"""
|
|
320
|
+
known = [v for v in cost.values() if v is not None]
|
|
321
|
+
return sum(known) if known else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _as_int(value: Any, name: str, *, hint: str = "") -> int:
|
|
325
|
+
"""
|
|
326
|
+
A number-of-items argument: 1000, '10,000', '10k' or '2m' -> int.
|
|
327
|
+
"""
|
|
328
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value).is_integer():
|
|
329
|
+
return int(value)
|
|
330
|
+
match = _COUNT_RE.match(value) if isinstance(value, str) else None
|
|
331
|
+
if match:
|
|
332
|
+
number = (
|
|
333
|
+
float(match.group(1).replace(",", "").replace("_", ""))
|
|
334
|
+
* {"": 1, "k": 1000, "m": 10**6}[match.group(2).lower()]
|
|
335
|
+
)
|
|
336
|
+
if number.is_integer():
|
|
337
|
+
return int(number)
|
|
338
|
+
raise ValueError(f"{name} takes a number of items, like 1000 or '10k'{hint}; got {value!r}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _as_count(value: Any, name: str) -> int | None:
|
|
342
|
+
"""
|
|
343
|
+
Like _as_int, for limits where None means no limit.
|
|
344
|
+
"""
|
|
345
|
+
return None if value is None else _as_int(value, name, hint=", or None for no limit")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_COMPRESSION_EXTS = {
|
|
349
|
+
"gz",
|
|
350
|
+
"gzip",
|
|
351
|
+
"bz2",
|
|
352
|
+
"xz",
|
|
353
|
+
"zst",
|
|
354
|
+
"zstd",
|
|
355
|
+
"snappy",
|
|
356
|
+
"lz4",
|
|
357
|
+
"zip",
|
|
358
|
+
"z",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def file_extension(key: str) -> str:
|
|
363
|
+
"""
|
|
364
|
+
'a/data.csv.gz' -> 'csv.gz', 'a/part-0.snappy.parquet' -> 'parquet'. A name without an extension ('README',
|
|
365
|
+
'name.', '.env') is '(none)'.
|
|
366
|
+
"""
|
|
367
|
+
if key.endswith("/"):
|
|
368
|
+
return "(folder marker)"
|
|
369
|
+
parts = key.rsplit("/", 1)[-1].lower().split(".")
|
|
370
|
+
if len(parts) < 2 or parts[-1] == "" or (len(parts) == 2 and parts[0] == ""):
|
|
371
|
+
return "(none)"
|
|
372
|
+
ext = parts[-1]
|
|
373
|
+
if ext in _COMPRESSION_EXTS and len(parts) > 2 and parts[-2].isalpha() and len(parts[-2]) <= 8:
|
|
374
|
+
return f"{parts[-2]}.{ext}"
|
|
375
|
+
return ext
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass
|
|
379
|
+
class Stat:
|
|
380
|
+
"""
|
|
381
|
+
Running count (objects, items) + total bytes.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
count: int = 0
|
|
385
|
+
size: int = 0
|
|
386
|
+
|
|
387
|
+
def add(self, size: int) -> None:
|
|
388
|
+
self.count += 1
|
|
389
|
+
self.size += size
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Source code as HTML, every piece escaped: JSON and Python with their tokens in colour (jk / js / jn / jl for JSON's
|
|
3
|
+
keys, text, numbers and literals; pk / pf / pa / pc for Python's keywords, calls, keyword arguments and comments).
|
|
4
|
+
The chat's code to copy and the Lambda explorer's Code tab draw with them; each service's stylesheet colours them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import keyword
|
|
11
|
+
import re
|
|
12
|
+
import tokenize
|
|
13
|
+
|
|
14
|
+
from .text import _esc
|
|
15
|
+
|
|
16
|
+
_JSON_TOKEN_RE = re.compile(r'("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _json_source_html(text: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
JSON text with its keys, text, numbers and true / false / null in their own colours (escaped).
|
|
22
|
+
"""
|
|
23
|
+
out, last = [], 0
|
|
24
|
+
for m in _JSON_TOKEN_RE.finditer(text):
|
|
25
|
+
out.append(_esc(text[last : m.start()]))
|
|
26
|
+
if m.group(1):
|
|
27
|
+
css = "jk" if m.group(2) else "js"
|
|
28
|
+
out.append(f'<span class="{css}">{_esc(m.group(1))}</span>{_esc(m.group(2) or "")}')
|
|
29
|
+
else:
|
|
30
|
+
out.append(f'<span class="{"jn" if m.group(3) else "jl"}">{_esc(m.group(0))}</span>')
|
|
31
|
+
last = m.end()
|
|
32
|
+
return "".join(out) + _esc(text[last:])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_PY_LITERALS = frozenset({"True", "False", "None"})
|
|
36
|
+
_LAYOUT_TOKENS = (tokenize.ENDMARKER, tokenize.INDENT, tokenize.DEDENT)
|
|
37
|
+
_NOT_CODE_TOKENS = (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _python_css(token: tokenize.TokenInfo, previous: str, following: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
The colour class of one Python token, given the code tokens just before and after it ('' when there's none):
|
|
43
|
+
a string followed by ':' is a dict key, a name followed by '(' a call, and a name followed by '=' inside a
|
|
44
|
+
call's brackets a keyword argument.
|
|
45
|
+
"""
|
|
46
|
+
if token.type == tokenize.COMMENT:
|
|
47
|
+
return "pc"
|
|
48
|
+
if token.type == tokenize.STRING:
|
|
49
|
+
return "jk" if following == ":" else "js"
|
|
50
|
+
if token.type == tokenize.NUMBER:
|
|
51
|
+
return "jn"
|
|
52
|
+
if token.type != tokenize.NAME:
|
|
53
|
+
return ""
|
|
54
|
+
if token.string in _PY_LITERALS:
|
|
55
|
+
return "jl"
|
|
56
|
+
if keyword.iskeyword(token.string):
|
|
57
|
+
return "pk"
|
|
58
|
+
if following == "(":
|
|
59
|
+
return "pf"
|
|
60
|
+
if following == "=" and previous in ("(", ","):
|
|
61
|
+
return "pa"
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _source_offset(line_starts: list[int], position: tuple[int, int], code: str) -> int:
|
|
66
|
+
"""
|
|
67
|
+
Where a token's (line, column) position is in the code, counted in characters.
|
|
68
|
+
"""
|
|
69
|
+
line, column = position
|
|
70
|
+
return line_starts[line - 1] + column if line - 1 < len(line_starts) else len(code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _python_html(code: str) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Python as HTML: keywords, text, numbers, True / False / None, calls, keyword arguments, dict keys and
|
|
76
|
+
comments in their own colours. Every piece is escaped; code that doesn't tokenize is shown plain.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
tokens = [
|
|
80
|
+
token for token in tokenize.generate_tokens(io.StringIO(code).readline) if token.type not in _LAYOUT_TOKENS
|
|
81
|
+
]
|
|
82
|
+
except (tokenize.TokenError, SyntaxError):
|
|
83
|
+
return _esc(code)
|
|
84
|
+
|
|
85
|
+
line_starts = [0]
|
|
86
|
+
for line in code.splitlines(keepends=True):
|
|
87
|
+
line_starts.append(line_starts[-1] + len(line))
|
|
88
|
+
code_tokens = [token for token in tokens if token.type not in _NOT_CODE_TOKENS]
|
|
89
|
+
following = {
|
|
90
|
+
id(token): code_tokens[k + 1].string if k + 1 < len(code_tokens) else "" for k, token in enumerate(code_tokens)
|
|
91
|
+
}
|
|
92
|
+
previous = {id(token): code_tokens[k - 1].string if k else "" for k, token in enumerate(code_tokens)}
|
|
93
|
+
|
|
94
|
+
out, last = [], 0
|
|
95
|
+
for token in tokens:
|
|
96
|
+
start = _source_offset(line_starts, token.start, code)
|
|
97
|
+
end = _source_offset(line_starts, token.end, code)
|
|
98
|
+
if start < last or end <= start:
|
|
99
|
+
continue
|
|
100
|
+
css = _python_css(token, previous.get(id(token), ""), following.get(id(token), ""))
|
|
101
|
+
piece = _esc(code[start:end])
|
|
102
|
+
out.append(_esc(code[last:start]))
|
|
103
|
+
out.append(f'<span class="{css}">{piece}</span>' if css else piece)
|
|
104
|
+
last = end
|
|
105
|
+
return "".join(out) + _esc(code[last:])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_HTML_PIECE_RE = re.compile(r'<span class="[^"]*">|</span>|\n|[^<\n]+')
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _python_lines(code: str) -> list[str]:
|
|
112
|
+
"""
|
|
113
|
+
Python as HTML, a line each: the whole source in colour (so a docstring or a call over several lines is coloured
|
|
114
|
+
as it should be), cut at each line end, with a colour that runs on past one opened again on the next.
|
|
115
|
+
"""
|
|
116
|
+
lines: list[str] = []
|
|
117
|
+
line: list[str] = []
|
|
118
|
+
opened = "" # the colour's <span> that's open, if one is
|
|
119
|
+
for match in _HTML_PIECE_RE.finditer(_python_html(code)):
|
|
120
|
+
piece = match.group(0)
|
|
121
|
+
if piece == "\n":
|
|
122
|
+
lines.append("".join(line) + ("</span>" if opened else ""))
|
|
123
|
+
line = [opened] if opened else []
|
|
124
|
+
continue
|
|
125
|
+
if piece.startswith("<span"):
|
|
126
|
+
opened = piece
|
|
127
|
+
elif piece == "</span>":
|
|
128
|
+
opened = ""
|
|
129
|
+
line.append(piece)
|
|
130
|
+
lines.append("".join(line))
|
|
131
|
+
return lines
|