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/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