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
@@ -0,0 +1,1066 @@
1
+ """
2
+ How reports are drawn, for every service: the stylesheet each report starts with, and the HTML and text of the
3
+ blocks every service uses (blocks.py); a service's own blocks register how they're drawn when it's imported.
4
+
5
+ Each service's reports sit in an HTML box of its own root class (.s3a, .ddb, .kba, ...): `_base_css(root, extra)`
6
+ writes the shared rules for it and adds the rules for the service's own blocks, and the service's
7
+ `_render_html(blocks, max_rows)` is `_report_html` with its stylesheet, root class and badge. `_render_text` is the
8
+ same for every service.
9
+
10
+ Tables sort by a click on a header, filter a column of a few repeated values and choose their columns, and a finding
11
+ reads as a headline over its detail. All of it is plain HTML and CSS (radio buttons, selects and checkboxes in a form,
12
+ read by :has() rules), never JavaScript, so it still works in a reopened notebook, where scripts don't run.
13
+
14
+ _BASE_RULES are the rules every service's reports share. "{root}" in them stands for the service's root class,
15
+ filled in by str.replace, not str.format: the rules are full of braces. A header click can sort by the first
16
+ _SORT_COLUMNS columns, since each needs two CSS rules.
17
+
18
+ A table's controls carry _HIDE, which hides them until the stylesheet shows them, so a notebook opened without its
19
+ styles (JupyterLab strips <style> from an untrusted notebook) shows a plain table. JupyterLab's sanitizer drops a
20
+ bare `hidden` attribute, but keeps hidden="hidden".
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import inspect
26
+ import re
27
+ from collections import Counter
28
+ from typing import Any, Callable
29
+
30
+ from .blocks import (
31
+ _CELLS,
32
+ _HTML,
33
+ _TEXT,
34
+ _Cards,
35
+ _Findings,
36
+ _Next,
37
+ _Note,
38
+ _Report,
39
+ _Table,
40
+ _Text,
41
+ _Title,
42
+ _Tone,
43
+ _drawer,
44
+ _draws,
45
+ )
46
+ from .fmt import _plural, file_extension
47
+ from .text import _clip, _esc, _pad, _text_bar, _width
48
+
49
+ _SORT_COLUMNS = 12
50
+ _BASE_RULES = """\
51
+ .{root}{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;font-size:13px;line-height:1.45}
52
+ .{root} h3{margin:10px 0 2px;font-size:16px}
53
+ .{root} h3 .badge{display:inline-block;vertical-align:2px;margin-right:8px;padding:1px 7px;border-radius:9px;font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;background:rgba(59,130,246,.14);color:#3b82f6}
54
+ .{root} h4{margin:14px 0 4px;font-size:13px;font-weight:600}
55
+ .{root} .sub{opacity:.65;font-size:12px;margin-bottom:6px}
56
+ .{root} .cards{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
57
+ .{root} .card{border:1px solid rgba(127,127,127,.3);border-radius:6px;padding:6px 12px;min-width:96px}
58
+ .{root} .card.warn{border-color:rgba(245,158,11,.8);background:rgba(245,158,11,.08)}
59
+ .{root} .card.bad{border-color:rgba(239,68,68,.8);background:rgba(239,68,68,.08)}
60
+ .{root} .card.ok{border-color:rgba(16,185,129,.7)}
61
+ .{root} .card .l{font-size:11px;opacity:.65}
62
+ .{root} .card .v{font-size:15px;font-weight:600;overflow-wrap:anywhere}
63
+ .{root} .tw{max-width:100%;overflow-x:auto;margin:2px 0 8px}
64
+ .{root} .tw.scroll{max-height:640px;overflow:auto}
65
+ .{root} table.t{border-collapse:collapse;width:auto;font-size:inherit;margin:0;table-layout:auto}
66
+ .{root} table.t tr{padding:0;border:0}
67
+ .{root} table.t tbody tr,.{root} table.t tbody tr:hover{background:transparent}
68
+ .{root} table.t th{text-align:left;font-weight:600;padding:5px 10px;border-bottom:1px solid rgba(127,127,127,.5)}
69
+ .{root} table.t th{background-image:linear-gradient(rgba(127,127,127,.08),rgba(127,127,127,.08))}
70
+ .{root} table.t th.n{text-align:right}
71
+ .{root} .tw.scroll table.t th{position:sticky;top:0;z-index:1;box-shadow:inset 0 -1px rgba(127,127,127,.5);backdrop-filter:blur(8px)}
72
+ .{root} .tw.scroll table.t th{background-color:var(--jp-layout-color0,var(--vscode-editor-background,transparent))}
73
+ .{root} table.t td{text-align:left;padding:3px 10px;border-bottom:1px solid rgba(127,127,127,.15);vertical-align:top}
74
+ .{root} table.t td{white-space:pre-line;overflow-wrap:break-word;max-width:640px}
75
+ .{root} table.t tbody tr:hover td{background:rgba(127,127,127,.07)}
76
+ .{root} table.t td.s{white-space:nowrap}
77
+ .{root} table.t td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
78
+ .{root} table.t td.p{white-space:normal}
79
+ .{root} td.p .pl{display:block;white-space:nowrap}
80
+ .{root} td.p .pd{display:inline-block;max-width:30em;overflow:hidden;text-overflow:ellipsis;direction:rtl;vertical-align:top;opacity:.6}
81
+ .{root} td.p .pd::before,.{root} td.p .pd::after{content:"\\200E"}
82
+ .{root} td.p .pn{white-space:normal;overflow-wrap:anywhere}
83
+ .{root} td.p .pi{margin-right:5px;user-select:none;-webkit-user-select:none}
84
+ .{root} form.tbl{margin:0;counter-reset:rows}
85
+ .{root} form.tbl tbody tr{counter-increment:rows}
86
+ .{root} .tt{display:flex;flex-wrap:wrap;align-items:baseline;column-gap:14px}
87
+ .{root} form.tbl h4,.{root} .tt h4{margin:14px 0 4px}
88
+ .{root} .tb{display:none;flex-wrap:wrap;align-items:center;gap:4px 10px;font-size:12px;margin:2px 0 4px}
89
+ .{root} .tb .tl{opacity:.6}
90
+ .{root} .flt{display:inline-flex;align-items:center;gap:5px;opacity:.9}
91
+ .{root} .flt select{font:inherit;font-size:12px;color:inherit;background:transparent;cursor:pointer}
92
+ .{root} .flt select{border:1px solid rgba(127,127,127,.45);border-radius:12px;padding:1px 6px}
93
+ .{root} .flt option{background:var(--jp-layout-color1,var(--vscode-editor-background,Canvas))}
94
+ .{root} .flt option{color:var(--jp-ui-font-color1,var(--vscode-editor-foreground,CanvasText))}
95
+ .{root} .cols{position:relative}
96
+ .{root} .cols>summary{list-style:none;cursor:pointer;border:1px solid rgba(127,127,127,.45);border-radius:12px;padding:0 8px}
97
+ .{root} .cols>summary{opacity:.55;transition:opacity .15s}
98
+ .{root} .tbl:hover .cols>summary,.{root} .cols[open]>summary,.{root} .cols:has(input:not(:checked))>summary{opacity:1}
99
+ .{root} .cols>summary::-webkit-details-marker{display:none}
100
+ .{root} .cols>summary::after{content:" \\25BE";opacity:.7}
101
+ .{root} .cols>.menu{position:absolute;z-index:10;top:calc(100% + 4px);left:0;min-width:170px;max-height:320px;overflow:auto}
102
+ .{root} .cols>.menu{display:flex;flex-direction:column;gap:2px;padding:6px 10px 8px;border:1px solid rgba(127,127,127,.35)}
103
+ .{root} .cols>.menu{border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.2)}
104
+ .{root} .cols>.menu{background:var(--jp-layout-color1,var(--vscode-editor-background,Canvas))}
105
+ .{root} .cols label{display:flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer;padding:1px 0}
106
+ .{root} .cols input{margin:0}
107
+ .{root} .cols button{align-self:flex-start;margin-top:4px}
108
+ .{root} .tbl button[type=reset]{font:inherit;font-size:12px;color:#3b82f6;background:none;border:0;padding:0;cursor:pointer}
109
+ .{root} .tbl button[type=reset]:hover{text-decoration:underline}
110
+ .{root} .tf{display:none;align-items:center;gap:8px;font-size:12px;margin:-4px 0 8px}
111
+ .{root} .tf .cnt{font-weight:600}
112
+ .{root} .tf .cnt::before{content:counter(rows)}
113
+ @supports selector(:has(*)){
114
+ .{root} .tb{display:flex}
115
+ .{root} .flt select:has(option:checked:not(.all)),.{root} .cols:has(input:not(:checked))>summary{border-color:#3b82f6}
116
+ .{root} .flt select:has(option:checked:not(.all)),.{root} .cols:has(input:not(:checked))>summary{background:rgba(59,130,246,.12)}
117
+ .{root} form.tbl:has(select option:checked:not(.all)) .tf{display:flex}
118
+ }
119
+ @supports (grid-template-columns:subgrid) and selector(:has(*)){
120
+ .{root} form.tbl table.t{display:grid;grid-template-columns:var(--gc);width:fit-content;max-width:100%}
121
+ .{root} form.tbl table.t>thead,.{root} form.tbl table.t>tbody{display:contents}
122
+ .{root} form.tbl table.t tr{display:grid;grid-column:1/-1;grid-template-columns:subgrid}
123
+ .{root} form.tbl table.t thead tr{order:-1}
124
+ .{root} form.tbl .tw.scroll table.t thead tr{position:sticky;top:0;z-index:2}
125
+ .{root} th.srt label{display:none;position:relative;cursor:pointer;user-select:none;-webkit-user-select:none}
126
+ .{root} th.srt label.o1{display:inline-flex}
127
+ .{root} th.srt:has(.o1 input:checked) label.o1,.{root} th.srt:has(.o2 input:checked) label.o1{display:none}
128
+ .{root} th.srt:has(.o1 input:checked) label.o2,.{root} th.srt:has(.o2 input:checked) label.o3{display:inline-flex}
129
+ .{root} th.srt label::after{content:"\\2195";position:absolute;left:100%;margin-left:2px;font-size:11px;font-weight:400;opacity:.28}
130
+ .{root} th.srt.n label::after{left:auto;right:100%;margin:0 2px 0 0}
131
+ .{root} th.srt:hover label::after{opacity:.6}
132
+ .{root} th.srt:hover{color:#3b82f6}
133
+ .{root} th.srt:has(.ra:checked),.{root} th.srt:has(.rd:checked){color:#3b82f6}
134
+ .{root} th.srt:has(.ra:checked) label::after{content:"\\2191";opacity:1}
135
+ .{root} th.srt:has(.rd:checked) label::after{content:"\\2193";opacity:1}
136
+ {sort_rules}}
137
+ .{root} table.t td.tree{white-space:pre;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
138
+ .{root} table.t td.bar{white-space:nowrap;font-variant-numeric:tabular-nums}
139
+ .{root} .track{display:inline-block;width:110px;height:8px;border-radius:2px;background:rgba(127,127,127,.18)}
140
+ .{root} .track{vertical-align:middle;margin-right:6px}
141
+ .{root} .fill{display:block;height:100%;border-radius:2px;background:#3b82f6}
142
+ .{root} .pill{display:inline-block;padding:0 7px;border-radius:9px;font-weight:600;font-size:12px}
143
+ .{root} .pill.warn{background:rgba(245,158,11,.18);box-shadow:inset 0 0 0 1px rgba(245,158,11,.6)}
144
+ .{root} .pill.bad{background:rgba(239,68,68,.16);box-shadow:inset 0 0 0 1px rgba(239,68,68,.6)}
145
+ .{root} .pill.ok{background:rgba(16,185,129,.14);box-shadow:inset 0 0 0 1px rgba(16,185,129,.55)}
146
+ .{root} .note{padding:5px 10px;margin:4px 0;border-left:3px solid #3b82f6;background:rgba(59,130,246,.08)}
147
+ .{root} .note::before{content:"\\2139\\FE0E";margin-right:7px;opacity:.7}
148
+ .{root} .note.warn{border-left-color:#f59e0b;background:rgba(245,158,11,.10)}
149
+ .{root} .note.warn::before{content:"\\26A0\\FE0E"}
150
+ .{root} .note.ok{border-left-color:#10b981;background:rgba(16,185,129,.10)}
151
+ .{root} .note.ok::before{content:"\\2713"}
152
+ .{root} .ld{font-weight:600}
153
+ .{root} .fd{margin:6px 0 10px}
154
+ .{root} .fh{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;margin:10px 0 4px}
155
+ .{root} .fh .fl{font-weight:600;opacity:.75;margin-right:2px}
156
+ .{root} .chip{display:inline-block;padding:0 8px;border-radius:9px;font-size:11px;font-weight:600;line-height:18px}
157
+ .{root} .chip.warn{background:rgba(245,158,11,.18);box-shadow:inset 0 0 0 1px rgba(245,158,11,.5)}
158
+ .{root} .chip.info{background:rgba(59,130,246,.12);box-shadow:inset 0 0 0 1px rgba(59,130,246,.4)}
159
+ .{root} .fi{display:grid;grid-template-columns:16px minmax(0,1fr);column-gap:7px;padding:6px 12px 6px 9px;margin:4px 0}
160
+ .{root} .fi{border-left:3px solid #3b82f6;background:rgba(59,130,246,.06);border-radius:0 5px 5px 0}
161
+ .{root} .fi.warn{border-left-color:#f59e0b;background:rgba(245,158,11,.10)}
162
+ .{root} .fi::before{content:"\\2139\\FE0E";grid-row:span 2;opacity:.7}
163
+ .{root} .fi.warn::before{content:"\\26A0\\FE0E"}
164
+ .{root} .fi .hd{font-weight:600}
165
+ .{root} .fi .dt{grid-column:2;margin:2px 0 0;opacity:.9}
166
+ .{root} .fi ul.dt{padding-left:16px}
167
+ .{root} .fi ul.dt li{margin:1px 0}
168
+ .{root} .m{font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
169
+ .{root} code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;padding:0 4px;border-radius:4px}
170
+ .{root} code{background:rgba(127,127,127,.15);user-select:all;-webkit-user-select:all;cursor:text}
171
+ .{root} .more{opacity:.6;font-size:12px;margin:-4px 0 8px}
172
+ .{root} pre{max-height:420px;overflow:auto;padding:8px 10px;border:1px solid rgba(127,127,127,.3);border-radius:6px;font-size:12px}
173
+ .{root} pre.wrap{white-space:pre-wrap;overflow-wrap:anywhere;font-family:inherit;font-size:13px;line-height:1.5;max-height:560px}
174
+ .{root} pre.code{user-select:all;-webkit-user-select:all;cursor:text}
175
+ .{root} .hint{font-weight:400;font-size:11px;opacity:.55;margin-left:8px}
176
+ .{root} details.sec{margin:14px 0 4px}
177
+ .{root} details.sec>summary{cursor:pointer;font-weight:600;margin-bottom:4px}
178
+ .{root} .next{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;margin:12px 0 4px;padding-top:8px}
179
+ .{root} .next{border-top:1px dashed rgba(127,127,127,.35)}
180
+ .{root} .next .nl{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;opacity:.6}
181
+ .{root} .next .nw{font-size:12px;opacity:.65;margin-left:6px}""".replace(
182
+ "{sort_rules}",
183
+ "".join(
184
+ f".{{root}} form.tbl:has(.k{j} .ra:checked) tbody tr{{order:var(--a{j})}}\n"
185
+ f".{{root}} form.tbl:has(.k{j} .rd:checked) tbody tr{{order:var(--d{j})}}\n"
186
+ for j in range(_SORT_COLUMNS)
187
+ ),
188
+ )
189
+
190
+
191
+ def _base_css(
192
+ root: str,
193
+ extra: str = "",
194
+ *,
195
+ theme: str = "",
196
+ restyle: dict[str, str] | None = None,
197
+ after: dict[str, str] | None = None,
198
+ ) -> str:
199
+ """
200
+ The <style> every report starts with: the shared rules for the root class `root`, then `extra`, the rules
201
+ for the service's own blocks.
202
+
203
+ A service whose reports look different (the chat's match its window) changes some shared rules: `theme` goes
204
+ first (the custom properties its rules use), `restyle` replaces a shared rule and `after` adds rules after one.
205
+ Their keys are how the shared rule starts after `.<root> ` ("h3 .badge{", "code{background:"); one that matches
206
+ no rule, or more than one, raises ValueError, so a change to the shared rules can't silently undo a restyle.
207
+ """
208
+ rules = _BASE_RULES.replace("{root}", root).split("\n")
209
+ for edits, keep in ((restyle or {}, False), (after or {}, True)):
210
+ for start, new in edits.items():
211
+ found = [i for i, rule in enumerate(rules) if rule.startswith(f".{root} {start}")]
212
+ if len(found) != 1:
213
+ raise ValueError(f"{start!r} starts {len(found)} of the shared CSS rules, not one")
214
+ rules[found[0]] = f"{rules[found[0]]}\n{new}" if keep else new
215
+ return "\n".join(["<style>", *filter(None, [theme, "\n".join(rules), extra]), "</style>"])
216
+
217
+
218
+ _NUMERIC_RE = re.compile(r"^-?(<?\$)?[\d,]+(\.\d+)?\+?( ?(B|KB|MB|GB|TB|PB|%|s))?$")
219
+ _CALL_RE = re.compile(
220
+ r"((?<![\w.])\.?(?:[A-Za-z_]\w*(?:\(\))?\.)*[A-Za-z_]\w*"
221
+ r"\((?:[^()'\"]|'[^']*'|\"[^\"]*\"|\((?:[^()'\"]|'[^']*'|\"[^\"]*\")*\))*\)"
222
+ r"|\baws [a-z0-9-]+ [a-z0-9-]+(?: --[\w-]+(?: (?!--)[^\s,;]*[^\s,;.])?)*)"
223
+ )
224
+ _TONES = ("warn", "bad", "ok")
225
+ _MARKS = {"warn": "[!] ", "ok": "[ok] "}
226
+ _INFO_MARK = "[i] "
227
+ _SELECT = ' title="Click to select, then copy"'
228
+
229
+
230
+ _LEAD_END_RE = re.compile(r"[:;](?=\s)|\.(?=\s+[A-Z0-9\"'(<$])")
231
+ _MONEY_RE = re.compile(r"(-?<?\$[\d,]+(?:\.\d+)?(?:/month|/mo\b| a month| per month)?)")
232
+
233
+
234
+ def _prose(value: Any, money: bool = False) -> str:
235
+ """
236
+ Escaped HTML for a sentence this tool wrote, with the calls in it as code that one click selects
237
+ (and, with money=True, amounts like $12.40/month stressed). The text is split on the calls and every
238
+ piece escaped before it's wrapped, so nothing in it becomes markup.
239
+
240
+ A call is what _CALL_RE finds: a Python call (kb_info(), documents(status='FAILED'), .core.find(...),
241
+ S3View().preview('s3://..')) or an AWS CLI command with its options (aws dynamodb update-table --table-name
242
+ orders --deletion-protection-enabled).
243
+ """
244
+ pieces = _CALL_RE.split("" if value is None else str(value))
245
+ return "".join(
246
+ f"<code{_SELECT}>{_esc(piece)}</code>"
247
+ if i % 2
248
+ else "".join(
249
+ f'<span class="m">{_esc(part)}</span>' if k % 2 else _esc(part)
250
+ for k, part in enumerate(_MONEY_RE.split(piece))
251
+ )
252
+ if money
253
+ else _esc(piece)
254
+ for i, piece in enumerate(pieces)
255
+ )
256
+
257
+
258
+ def _masked(text: str) -> str:
259
+ """
260
+ The text with its commands and anything in brackets blanked out (same length), so a sentence is never
261
+ split inside them: 'Something went wrong (RuntimeError: boom)' has no lead. Brackets inside brackets are
262
+ blanked from the innermost out.
263
+ """
264
+ masked, before = _CALL_RE.sub(lambda m: "x" * len(m.group(0)), text), None
265
+ while masked != before:
266
+ before, masked = masked, re.sub(r"[(\[][^()\[\]]*[)\]]", lambda m: "x" * len(m.group(0)), masked)
267
+ return masked
268
+
269
+
270
+ def _split_lead(text: str, shortest: int = 8, longest: int = 160) -> tuple[str, str, str]:
271
+ """
272
+ 'Versioning is on: every deleted file is kept.' -> ('Versioning is on', ':', 'every deleted file is
273
+ kept.'): what a sentence says first, the mark after it and the rest (why it matters, what to do).
274
+ ('', '', text) when it has no lead that short. Commands and brackets in it are never split.
275
+ """
276
+ masked = _masked(text)
277
+ for match in _LEAD_END_RE.finditer(masked):
278
+ if match.start() < shortest:
279
+ continue
280
+ rest = text[match.end() :].strip()
281
+ if match.start() > longest or not rest:
282
+ break
283
+ return text[: match.start()], text[match.start()], rest
284
+ return "", "", text
285
+
286
+
287
+ def _prose_sentences(text: str) -> list[str]:
288
+ """
289
+ Split this tool's prose into sentences, never inside a command or brackets.
290
+ """
291
+ masked = _masked(text)
292
+ cuts = [m.end() for m in re.finditer(r"\.(?=\s+[A-Z0-9\"'(<$])", masked)]
293
+ return [text[a:b].strip() for a, b in zip([0] + cuts, cuts + [len(text)]) if text[a:b].strip()]
294
+
295
+
296
+ def _capitalized(text: str) -> str:
297
+ """
298
+ 'every file is kept' -> 'Every file is kept', but 'invoices/a.pdf', 'uploads(...)' or a command to copy
299
+ ('aws s3 ls ...') stay as they are.
300
+ """
301
+ first = text.split(" ", 1)[0]
302
+ if _CALL_RE.match(text):
303
+ return text
304
+ return text[0].upper() + text[1:] if re.fullmatch(r"[a-z][a-z']*,?", first) else text
305
+
306
+
307
+ def _lead(value: Any) -> str:
308
+ """
309
+ _prose with the sentence's lead in bold and amounts of money stressed:
310
+ '<span class="ld">Versioning is on:</span> every deleted file is kept.' For notes and Warnings cells.
311
+ """
312
+ text = "" if value is None else str(value)
313
+ head, mark, rest = _split_lead(text)
314
+ if not head:
315
+ return _prose(text, money=True)
316
+ return f'<span class="ld">{_prose(head + mark, money=True)}</span> {_prose(rest, money=True)}'
317
+
318
+
319
+ def _finding_html(level: str, message: str) -> str:
320
+ """
321
+ One finding as a headline (what's wrong) over its detail (why it matters, what it costs, what to do),
322
+ the detail's sentences as points when there are several.
323
+ """
324
+ head, _, rest = _split_lead(message)
325
+ if not head:
326
+ head, rest = (message, "") if len(message) <= 200 else ("", message)
327
+ head = head.rstrip().removesuffix(".") if not head.endswith("..") else head
328
+ parts = _prose_sentences(_capitalized(rest)) if rest else []
329
+ if len(parts) > 1:
330
+ detail = '<ul class="dt">' + "".join(f"<li>{_prose(p, money=True)}</li>" for p in parts) + "</ul>"
331
+ else:
332
+ detail = f'<div class="dt">{_prose(parts[0], money=True)}</div>' if parts else ""
333
+ headline = f'<div class="hd">{_prose(head, money=True)}</div>' if head else ""
334
+ return f'<div class="fi {"warn" if level == "warn" else "info"}">{headline}{detail}</div>'
335
+
336
+
337
+ def _call(name: str, *args: Any, **kwargs: Any) -> str:
338
+ """
339
+ _call('tree', 's3://b/', depth=2) -> "tree('s3://b/', depth=2)": a next step, ready to copy.
340
+ """
341
+ arguments = [_literal(a) for a in args] + [f"{k}={_literal(v)}" for k, v in kwargs.items()]
342
+ return f"{name}({', '.join(arguments)})"
343
+
344
+
345
+ def _literal(value: Any) -> str:
346
+ """
347
+ A value as Python to copy: its repr, except that DynamoDB numbers read 42 rather than Decimal('42'), inside
348
+ lists, tuples and dicts too.
349
+ """
350
+ if type(value).__name__ == "Decimal":
351
+ return str(value)
352
+ if isinstance(value, dict):
353
+ return "{" + ", ".join(f"{_literal(k)}: {_literal(v)}" for k, v in value.items()) + "}"
354
+ if isinstance(value, (list, tuple)):
355
+ inner = ", ".join(map(_literal, value)) + ("," if isinstance(value, tuple) and len(value) == 1 else "")
356
+ return f"[{inner}]" if isinstance(value, list) else f"({inner})"
357
+ return repr(value)
358
+
359
+
360
+ def _signature(function: Callable) -> str:
361
+ """
362
+ '(uri, *, top_n=10, limit=None)': a command's parameters without self or type hints.
363
+ """
364
+ sig = inspect.signature(function)
365
+ params = [p.replace(annotation=inspect.Parameter.empty) for name, p in sig.parameters.items() if name != "self"]
366
+ return str(sig.replace(parameters=params, return_annotation=inspect.Signature.empty))
367
+
368
+
369
+ _SORT_UNITS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4, "PB": 1024**5, "ms": 1}
370
+ _SORT_NUMBER_RE = re.compile(r"^(-?)(<?)\$?([\d,]*\.?\d+)\+?(?: ?(B|KB|MB|GB|TB|PB|%|ms))?(?: of [\d,]+| \([^()]*\))?$")
371
+ _SORT_AGE_RE = re.compile(r"^(\d+)(y|mo|d|h|m) ago$")
372
+ _SORT_DATE_RE = re.compile(
373
+ r"^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?: [A-Z]{2,5}(?:[+-]\d{2}(?::\d{2})?)?)?)?$"
374
+ )
375
+ _AGE_SECONDS = {"y": 365 * 86400, "mo": 30 * 86400, "d": 86400, "h": 3600, "m": 60}
376
+ _SORT_WORDS = {
377
+ "number": ("largest first", "smallest first"),
378
+ "age": ("oldest first", "newest first"),
379
+ "date": ("newest first", "oldest first"),
380
+ "text": ("A to Z", "Z to A"),
381
+ }
382
+ _FILTER_VALUES = 12
383
+ _FILTER_COLUMNS = 3
384
+ _FILTER_MIN_ROWS = 6
385
+ _SORT_MIN_ROWS = 3
386
+ _CHOOSER_MIN_COLUMNS = 3
387
+ _SCROLL_ROWS = 30
388
+ _SHORT_CELL = 16
389
+ _HIDE = ' hidden="hidden"'
390
+
391
+
392
+ def _sort_key(text: str) -> tuple[int, Any] | None:
393
+ """
394
+ What a table cell sorts by: (0, number) for sizes, money, counts, percentages and ages ('2.1 GB' is
395
+ bytes, '<$0.01' half a cent, '3d ago' seconds), (1, text) for dates, (2, parts) for the rest in natural
396
+ order ('epoch-2' before 'epoch-10'). None for an empty cell, which sorts last both ways. A date is
397
+ '2024-05-01', '2024-05-01 14:02', or a time with its zone's name: '2024-05-01 14:02:31 IST', '... UTC+05:30'.
398
+ """
399
+ text = text.split("\n", 1)[0].strip().removeprefix("📁 ")
400
+ if text in ("", "-", "—"):
401
+ return None
402
+ number = _SORT_NUMBER_RE.match(text)
403
+ if number:
404
+ sign, below, digits, unit = number.groups()
405
+ value = float(digits.replace(",", "")) * _SORT_UNITS.get(unit or "", 1) / (2 if below else 1)
406
+ return 0, -value if sign else value
407
+ age = _SORT_AGE_RE.match(text)
408
+ if age or text == "just now":
409
+ return 0, float(int(age.group(1)) * _AGE_SECONDS[age.group(2)]) if age else 0.0
410
+ if _SORT_DATE_RE.match(text):
411
+ return 1, text
412
+ parts = re.split(r"(\d+)", text.casefold())
413
+ return 2, tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in parts if part)
414
+
415
+
416
+ def _sort_kind(values: list[str], keys: list[tuple[int, Any]]) -> str:
417
+ """
418
+ 'number', 'age', 'date' or 'text': what most of a column's cells hold, which decides its first sort
419
+ (_SORT_WORDS says what its first and second header click show).
420
+ """
421
+ cls = Counter(key[0] for key in keys).most_common(1)[0][0]
422
+ if cls:
423
+ return "date" if cls == 1 else "text"
424
+ ages = sum(bool(_SORT_AGE_RE.match(v.strip())) or v.strip() == "just now" for v in values)
425
+ return "age" if ages * 2 > len(keys) else "number"
426
+
427
+
428
+ def _ranks(keys: list[Any]) -> tuple[list[int], list[int]]:
429
+ """
430
+ Each row's place when the column is sorted up and when it's sorted down. Rows that tie share a place,
431
+ so they keep their order; empty cells come last both ways.
432
+ """
433
+ present = sorted(key for key in keys if key is not None)
434
+ first: dict[Any, int] = {}
435
+ for i, key in enumerate(present):
436
+ first.setdefault(key, i)
437
+ counts, last = Counter(present), len(keys)
438
+ up = [last if key is None else first[key] for key in keys]
439
+ down = [last if key is None else len(present) - first[key] - counts[key] for key in keys]
440
+ return up, down
441
+
442
+
443
+ def _filters(block: _Table, columns: list[list[str]], numeric: list[bool]) -> list[tuple[int, list[tuple[str, int]]]]:
444
+ """
445
+ The columns that get a filter: a few different values, some repeated (a storage class, a status, a
446
+ region), never numbers, dates, keys or sentences -> [(column, [(value, rows), ...] most rows first)].
447
+
448
+ A column gets one when it holds 2 to _FILTER_VALUES different values, some repeated, in a table of
449
+ _FILTER_MIN_ROWS rows or more; at most _FILTER_COLUMNS per table, the leftmost columns first.
450
+ """
451
+ found: list[tuple[int, list[tuple[str, int]]]] = []
452
+ for j, column in enumerate(columns):
453
+ skip = set(block.prose_cols) | set(block.code_cols) | set(block.path_cols) | ({0} if block.tree else set())
454
+ if len(found) == _FILTER_COLUMNS or len(column) < _FILTER_MIN_ROWS:
455
+ break
456
+ values = [value.strip() for value in column if value.strip()]
457
+ if j in skip or numeric[j] or any("\n" in value or ", " in value for value in values):
458
+ continue
459
+ counts = Counter(values)
460
+ keys = [key for key in map(_sort_key, counts) if key is not None]
461
+ if 2 <= len(counts) <= _FILTER_VALUES and len(counts) < len(values) and all(key[0] == 2 for key in keys):
462
+ found.append((j, counts.most_common()))
463
+ return found
464
+
465
+
466
+ _PATH_CHARACTERS = 86
467
+ _FOLDER_CHARACTERS = 56
468
+ _SHORTEST_FOLDER = 12
469
+ _ICONS = {
470
+ **dict.fromkeys(("csv", "tsv", "tab", "psv", "parquet", "pq", "orc", "feather", "arrow", "ipc", "avro"), "📊"),
471
+ **dict.fromkeys(("xlsx", "xlsm", "xls"), "📗"),
472
+ **dict.fromkeys(("json", "jsonl", "ndjson", "yaml", "yml", "toml", "xml", "ini", "cfg", "conf"), "📋"),
473
+ **dict.fromkeys(("png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg"), "🖼️"),
474
+ **dict.fromkeys(("wav", "mp3", "flac", "ogg", "m4a", "aac"), "🎵"),
475
+ **dict.fromkeys(("mp4", "webm", "mov", "m4v", "avi", "mkv"), "🎬"),
476
+ **dict.fromkeys(("zip", "tar", "tgz", "7z", "rar", "gz", "bz2", "xz", "zst"), "📦"),
477
+ **dict.fromkeys(
478
+ ("safetensors", "pt", "pth", "ckpt", "onnx", "h5", "keras", "pkl", "pickle", "joblib", "gguf", "bin"), "🧠"
479
+ ),
480
+ **dict.fromkeys(("npy", "npz"), "🔢"),
481
+ **dict.fromkeys(("docx", "docm", "doc", "dotx", "rtf"), "📘"),
482
+ **dict.fromkeys(("pptx", "pptm", "ppt", "ppsx"), "📙"),
483
+ "pdf": "📕",
484
+ "ipynb": "📓",
485
+ }
486
+
487
+
488
+ def _file_icon(key: str) -> str:
489
+ """
490
+ 📁 for a folder, else the file's icon by its extension (_ICONS, which s3_explorer's list uses too): 📊 tables,
491
+ 📕 PDF, 🖼️ pictures, 🧠 models, ..., 📄 for anything else, a file without an extension too. 'data.csv.gz' is a
492
+ table and 'logs.tar.gz' an archive.
493
+ """
494
+ if key.endswith("/"):
495
+ return "📁"
496
+ ext = file_extension(key)
497
+ if ext == "(none)":
498
+ return "📄"
499
+ return _ICONS.get(ext.split(".")[0], _ICONS.get(ext.rsplit(".", 1)[-1], "📄"))
500
+
501
+
502
+ def _path_html(text: str) -> str:
503
+ """
504
+ A key in a table cell: its icon (📁 for a folder, 📊 for a table, ... by _file_icon), its folder dimmed and,
505
+ when long, shortened from the left (the nearest folders stay), then the file name in full. Each line of the
506
+ cell is one key; a line that isn't one ('… and 3 more', '(none)') shows as it is. The icon is only drawn: the
507
+ cell's text, which sorts and copies, is the key alone.
508
+
509
+ A folder takes up to _FOLDER_CHARACTERS (its 30em); a long name leaves it less, so the line stays within about
510
+ _PATH_CHARACTERS, but never less than _SHORTEST_FOLDER.
511
+ """
512
+ lines = []
513
+ for line in text.split("\n"):
514
+ icon = ""
515
+ if line.startswith("📁 "):
516
+ icon, line = "📁", line[2:]
517
+ is_key = not line.startswith(("…", "("))
518
+ if not icon and is_key and line not in ("", "-"):
519
+ icon = _file_icon(line)
520
+ folder, _, name = line.rstrip("/").rpartition("/")
521
+ name += line[len(line.rstrip("/")) :]
522
+ folder = f"{folder}/" if folder and is_key else ""
523
+ if not folder:
524
+ name = line
525
+ room = _PATH_CHARACTERS - len(name)
526
+ cap = f' style="max-width:{max(room, _SHORTEST_FOLDER)}ch"' if room < _FOLDER_CHARACTERS else ""
527
+ lines.append(
528
+ '<span class="pl">'
529
+ + (f'<span class="pi">{icon}</span>' if icon else "")
530
+ + (f'<span class="pd"{cap}>{_esc(folder)}</span>' if folder else "")
531
+ + f'<span class="pn">{_esc(name)}</span></span>'
532
+ )
533
+ return "".join(lines)
534
+
535
+
536
+ def _clip_path(text: str, width: int = 90) -> str:
537
+ """
538
+ A key for a text table: when it's too long the start of its folder goes, never the file name.
539
+ """
540
+ if len(text) <= width:
541
+ return text
542
+ tail = text[-(width - 1) :]
543
+ cut = tail.find("/")
544
+ return "…" + (tail[cut:] if 0 <= cut < len(tail) - 1 else tail)
545
+
546
+
547
+ def _tone(item: tuple[str, ...]) -> str:
548
+ """
549
+ The tone of a card: its third element, when it's one this renderer colours.
550
+ """
551
+ return item[2] if len(item) > 2 and item[2] in _TONES else ""
552
+
553
+
554
+ def _ordered(findings: list[tuple[str, str]]) -> list[tuple[str, str]]:
555
+ """
556
+ Warnings first, then notes, each in the order they were found.
557
+ """
558
+ return sorted(findings, key=lambda f: {"warn": 0, "info": 1}.get(f[0], 2))
559
+
560
+
561
+ def _counts(findings: list[tuple[str, str]], sep: str) -> str:
562
+ """
563
+ '2 warnings · 3 notes'.
564
+ """
565
+ warns = sum(level == "warn" for level, _ in findings)
566
+ return sep.join(
567
+ filter(
568
+ None,
569
+ [
570
+ _plural(warns, "warning") if warns else "",
571
+ _plural(len(findings) - warns, "note") if len(findings) > warns else "",
572
+ ],
573
+ )
574
+ )
575
+
576
+
577
+ def _visible_rows(table: _Table, default_max: int) -> tuple[list[list[Any]], int]:
578
+ cap = default_max if table.max_rows is None else table.max_rows
579
+ rows = table.rows if not cap else table.rows[:cap]
580
+ return rows, len(table.rows) - len(rows)
581
+
582
+
583
+ def _hidden(count: int, table: _Table, default_max: int) -> str:
584
+ """
585
+ The line under a table that was cut short, and how to see the rest when the view's max_rows cut it.
586
+ """
587
+ text = f"... {count:,} more rows not shown"
588
+ return text + (f" (the view shows {default_max:,}; ui.max_rows = 0 shows all)" if table.max_rows is None else "")
589
+
590
+
591
+ def _undrawn(block: Any) -> _Note:
592
+ """
593
+ The note shown in place of a block that nothing draws, rather than a traceback.
594
+ """
595
+ return _Note(
596
+ f"Part of this report isn't shown: there's no way to draw a {type(block).__name__} here "
597
+ "(a bug in aws-analyzer).",
598
+ "warn",
599
+ )
600
+
601
+
602
+ @_draws(_Title, "html")
603
+ def _title_html(block: _Title, report: _Report) -> str:
604
+ sub = f'<div class="sub">{_prose(block.sub)}</div>' if block.sub else ""
605
+ return f'<h3><span class="badge">{_esc(report.badge)}</span>{_esc(block.text)}</h3>{sub}'
606
+
607
+
608
+ @_draws(_Cards, "html")
609
+ def _cards_html(block: _Cards, report: _Report) -> str:
610
+ cards = "".join(
611
+ f'<div class="{" ".join(filter(None, ["card", _tone(item)]))}"><div class="l">'
612
+ f'{_esc(item[0])}</div><div class="v">{_esc(item[1])}</div></div>'
613
+ for item in block.items
614
+ )
615
+ return f'<div class="cards">{cards}</div>'
616
+
617
+
618
+ @_draws(_Note, "html")
619
+ def _note_html(block: _Note, report: _Report) -> str:
620
+ return f'<div class="note {block.level}">{_lead(block.text)}</div>'
621
+
622
+
623
+ @_draws(_Findings, "html")
624
+ def _findings_html(block: _Findings, report: _Report) -> str:
625
+ """
626
+ The findings panel: warnings first, with a chip counting each kind, every finding a headline over its detail
627
+ (_finding_html); block.empty (an ok note) when there's none.
628
+ """
629
+ items = _ordered(block.items)
630
+ if not items:
631
+ return f'<div class="note ok">{_lead(block.empty)}</div>' if block.empty else ""
632
+ warns = sum(level == "warn" for level, _ in items)
633
+ chips = (f'<span class="chip warn">{_plural(warns, "warning")}</span>' if warns else "") + (
634
+ f'<span class="chip info">{_plural(len(items) - warns, "note")}</span>' if len(items) > warns else ""
635
+ )
636
+ head = f'<div class="fh"><span class="fl">Findings</span>{chips}</div>'
637
+ return f'<div class="fd">{head}{"".join(_finding_html(level, msg) for level, msg in items)}</div>'
638
+
639
+
640
+ @_draws(_Next, "html")
641
+ def _next_html(block: _Next, report: _Report) -> str:
642
+ if not block.items:
643
+ return ""
644
+ calls = "".join(
645
+ f'<span class="ni"><code{_SELECT}>{_esc(call)}</code>'
646
+ + (f'<span class="nw">{_esc(why)}</span>' if why else "")
647
+ + "</span>"
648
+ for call, why in block.items
649
+ )
650
+ return f'<div class="next"><span class="nl">{_esc(block.title)}</span>{calls}</div>'
651
+
652
+
653
+ @_draws(_Tone, "cell")
654
+ def _tone_cell_html(cell: _Tone, text: str) -> str:
655
+ return f'<span class="pill {cell.tone}">{_esc(text)}</span>' if cell.tone in _TONES and text else _esc(text)
656
+
657
+
658
+ def _cell_html(cell: Any, text: str) -> str:
659
+ """
660
+ A table cell's HTML: its text, escaped, or what's registered for its type (a _Tone's coloured pill).
661
+ """
662
+ draw = _drawer(_CELLS, cell)
663
+ return draw(cell, text) if draw else _esc(text)
664
+
665
+
666
+ def _column_texts(block: _Table, rows: list[list[Any]]) -> list[list[str]]:
667
+ """
668
+ Each column's cells as text: '' where a row is short of it or the cell is None.
669
+ """
670
+ return [["" if j >= len(row) or row[j] is None else str(row[j]) for row in rows] for j in range(len(block.headers))]
671
+
672
+
673
+ def _is_numeric(column: list[str]) -> bool:
674
+ """
675
+ Whether most of a column's filled cells are numbers, sizes, money or percentages, which are right-aligned.
676
+ """
677
+ numbers = sum(bool(_NUMERIC_RE.match(value)) for value in column if value)
678
+ filled = sum(bool(value) for value in column)
679
+ return numbers * 2 > filled > 0
680
+
681
+
682
+ def _table_sorts(
683
+ block: _Table, rows: list[list[Any]], columns: list[list[str]]
684
+ ) -> dict[int, tuple[list[int], list[int], str]]:
685
+ """
686
+ The columns a click on their header sorts by -> each row's place sorted up and sorted down, and the column's
687
+ kind (_sort_kind). A table sorts when it has _SORT_MIN_ROWS rows or more and isn't a tree or a list of calls
688
+ (help() keeps its order). A column sorts unless it holds sentences or all its cells are the same; the bar column
689
+ sorts by its bars.
690
+ """
691
+ sorts_at_all = block.sortable and not block.tree and 0 not in block.code_cols and len(rows) >= _SORT_MIN_ROWS
692
+ if not sorts_at_all:
693
+ return {}
694
+ width = len(block.headers)
695
+ sorts: dict[int, tuple[list[int], list[int], str]] = {}
696
+ for j in range(min(width + (block.bars is not None), _SORT_COLUMNS)):
697
+ if j in block.prose_cols:
698
+ continue
699
+ values = columns[j] if j < width else []
700
+ keys = [_sort_key(v) for v in values] if j < width else [(0, float(b)) for b in block.bars[: len(rows)]]
701
+ present = [key for key in keys if key is not None]
702
+ if len(set(present)) > 1:
703
+ sorts[j] = (*_ranks(keys), _sort_kind(values, present))
704
+ return sorts
705
+
706
+
707
+ def _sort_labels(header: str, kind: str) -> str:
708
+ """
709
+ A sortable header: three labels over radio buttons, one showing at a time, that step through the column's
710
+ first sort, the other way, and back to the original order.
711
+ """
712
+ first, second = _SORT_WORDS[kind]
713
+ radios = ("ra", "rd") if kind == "text" else ("rd", "ra")
714
+ steps = (
715
+ (1, radios[0], f"Sort: {first}"),
716
+ (2, radios[1], f"Sort: {second}"),
717
+ (3, "rn", "Back to the original order"),
718
+ )
719
+ return "".join(
720
+ f'<label class="o{n}" title="{tip}"{_HIDE if n > 1 else ""}>'
721
+ f'<input type="radio" name="s" class="{radio}"{_HIDE}>{_esc(header)}</label>'
722
+ for n, radio, tip in steps
723
+ )
724
+
725
+
726
+ def _header_html(j: int, header: str, numeric: bool, sort: tuple[list[int], list[int], str] | None) -> str:
727
+ """
728
+ The header of column j: right-aligned over numbers, and the labels a click sorts by when it sorts.
729
+ """
730
+ css = ["n"] if numeric else []
731
+ inner = _esc(header)
732
+ if sort:
733
+ inner = _sort_labels(header, sort[2])
734
+ css = ["srt", f"k{j}", *css]
735
+ return f'<th class="{" ".join(css)}">{inner}</th>' if css else f"<th>{inner}</th>"
736
+
737
+
738
+ def _cell_td(block: _Table, j: int, cell: Any) -> str:
739
+ """
740
+ One cell of column j: a tree label, a call to copy, a sentence with its lead in bold, a key, a number
741
+ (right-aligned), or text, which doesn't wrap when it's _SHORT_CELL characters or fewer.
742
+ """
743
+ text = "" if cell is None else str(cell)
744
+ inner, attrs = _cell_html(cell, text), ""
745
+ if block.tree and j == 0:
746
+ css = "tree"
747
+ elif j in block.code_cols and text:
748
+ css, inner = "c", f"<code{_SELECT}>{inner}</code>"
749
+ elif j in block.prose_cols:
750
+ css, inner = "", _lead(text)
751
+ elif j in block.path_cols and text:
752
+ css, inner = "p", _path_html(text)
753
+ attrs = f' title="{_esc(text)}"' if "/" in text.rstrip("/") else ""
754
+ elif _NUMERIC_RE.match(text):
755
+ css = "n"
756
+ else:
757
+ css = "s" if len(text) <= _SHORT_CELL and "\n" not in text else ""
758
+ return f'<td class="{css}"{attrs}>{inner}</td>' if css else f"<td>{inner}</td>"
759
+
760
+
761
+ def _bar_td(fraction: float) -> str:
762
+ """
763
+ The bar column's cell: the fraction as a bar and a percentage.
764
+ """
765
+ pct = max(0.0, min(1.0, fraction)) * 100
766
+ return (
767
+ f'<td class="bar"><span class="track"><span class="fill" style="width:{pct:.1f}%"></span></span>{pct:.1f}%</td>'
768
+ )
769
+
770
+
771
+ def _row_cells(block: _Table, i: int, row: list[Any]) -> str:
772
+ """
773
+ Row i's cells, and its bar when the table has them.
774
+ """
775
+ cells = [_cell_td(block, j, cell) for j, cell in enumerate(row)]
776
+ if block.bars is not None:
777
+ cells.append(_bar_td(block.bars[i]))
778
+ return "".join(cells)
779
+
780
+
781
+ def _row_attributes(
782
+ i: int,
783
+ columns: list[list[str]],
784
+ sorts: dict[int, tuple[list[int], list[int], str]],
785
+ positions: dict[int, dict[str, int]],
786
+ ) -> str:
787
+ """
788
+ Row i's class, naming the value it holds in each filtered column (f<column>v<value's place>), and its style, its
789
+ place in each sortable column sorted up and down (--a<column>, --d<column>), which the CSS orders rows by.
790
+ """
791
+ marks = [f"f{j}v{places[columns[j][i].strip()]}" for j, places in positions.items() if columns[j][i].strip()]
792
+ order = ";".join(f"--a{j}:{up[i]};--d{j}:{down[i]}" for j, (up, down, _) in sorts.items())
793
+ return (f' class="{" ".join(marks)}"' if marks else "") + (f' style="{order}"' if order else "")
794
+
795
+
796
+ def _columns_menu(heads: list[str], report: _Report) -> str:
797
+ """
798
+ The Columns menu: a checkbox per column that hides the column when it's ticked off, and a button that resets
799
+ the table. The rules that hide them go into report.rules.
800
+ """
801
+ root = report.root
802
+ report.rules.update(
803
+ f".{root} form.tbl:has(.cv{j}:not(:checked)) tr>:nth-child({j + 1}){{display:none}}" for j in range(len(heads))
804
+ )
805
+ boxes = "".join(
806
+ f'<label><input type="checkbox" class="cv{j}" checked>{_esc(header)}</label>' for j, header in enumerate(heads)
807
+ )
808
+ return (
809
+ '<details class="cols"><summary title="Choose the columns to show">Columns</summary><div class="menu">'
810
+ + boxes
811
+ + '<button type="reset" title="Every column, the original order and no filters">Reset table'
812
+ "</button></div></details>"
813
+ )
814
+
815
+
816
+ def _filter_controls(filters: list[tuple[int, list[tuple[str, int]]]], heads: list[str], report: _Report) -> str:
817
+ """
818
+ A select per filtered column, its values with how many rows hold each. The rules that hide the other rows go
819
+ into report.rules.
820
+ """
821
+ root = report.root
822
+ for j, values in filters:
823
+ report.rules.update(
824
+ f".{root} form.tbl:has(.f{j} .v{i}:checked) tbody tr:not(.f{j}v{i}){{display:none}}"
825
+ for i in range(len(values))
826
+ )
827
+ selects = "".join(
828
+ f'<label class="flt">{_esc(heads[j])}<select class="f{j}"><option class="all" selected>All</option>'
829
+ + "".join(f'<option class="v{i}">{_esc(value)} ({count:,})</option>' for i, (value, count) in enumerate(values))
830
+ + "</select></label>"
831
+ for j, values in filters
832
+ )
833
+ return '<span class="tl">Filter</span>' + selects
834
+
835
+
836
+ def _filter_foot(row_count: int) -> str:
837
+ """
838
+ The line under a filtered table: how many rows match, and a button that clears the filters.
839
+ """
840
+ return (
841
+ f'<div class="tf"{_HIDE}><span><span class="cnt"></span> of {row_count:,} rows match</span>'
842
+ '<button type="reset">Clear filters</button></div>'
843
+ )
844
+
845
+
846
+ def _table_frame(block: _Table, tools: str, content: str) -> str:
847
+ """
848
+ The table under its title, with its controls beside the title, or folded under its title when it's collapsed.
849
+ """
850
+ if block.collapsed:
851
+ return (
852
+ f'<details class="sec"><summary>{_prose(block.title or "Details")} ({len(block.rows):,})</summary>'
853
+ f"{tools}{content}</details>"
854
+ )
855
+ title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
856
+ head = f'<div class="tt">{title}{tools}</div>' if title and tools else title + tools
857
+ return head + content
858
+
859
+
860
+ @_draws(_Table, "html")
861
+ def _table_html(block: _Table, report: _Report) -> str:
862
+ """
863
+ A _Table as HTML. Over _SCROLL_ROWS rows it scrolls under a sticky header. With 3 rows or more, a click on a
864
+ header sorts by that column (largest, newest or A to Z first; again for the other way; a third click
865
+ for the original order); a column of a few repeated values gets a filter; with 3 columns or more, a
866
+ Columns menu ticks columns off. All plain HTML and CSS: radio buttons, selects and checkboxes in a form,
867
+ read by :has() rules (the per-table ones go into report.rules), so they keep working in a reopened notebook,
868
+ where scripts don't run. Without the stylesheet the controls stay hidden and the table is plain.
869
+ """
870
+ if not block.rows:
871
+ return (f"<h4>{_prose(block.title)}</h4>" if block.title else "") + '<div class="more">(none)</div>'
872
+
873
+ rows, hidden = _visible_rows(block, report.max_rows)
874
+ columns = _column_texts(block, rows)
875
+ numeric = [_is_numeric(column) for column in columns]
876
+ sorts = _table_sorts(block, rows, columns)
877
+ filters = _filters(block, columns, numeric)
878
+ heads = list(block.headers) + ([block.bar_label] if block.bars is not None else [])
879
+ chooses_columns = len(heads) >= _CHOOSER_MIN_COLUMNS
880
+ interactive = bool(sorts or filters or chooses_columns)
881
+
882
+ header_cells = "".join(
883
+ _header_html(j, header, j < len(block.headers) and numeric[j], sorts.get(j)) for j, header in enumerate(heads)
884
+ )
885
+ positions = {j: {value: i for i, (value, _) in enumerate(values)} for j, values in filters}
886
+ body = "".join(
887
+ f"<tr{_row_attributes(i, columns, sorts, positions)}>{_row_cells(block, i, row)}</tr>"
888
+ for i, row in enumerate(rows)
889
+ )
890
+ grid = f' style="--gc:repeat({len(heads)},auto)"' if interactive else ""
891
+ scroll = " scroll" if len(rows) > _SCROLL_ROWS else ""
892
+ table = (
893
+ f'<div class="tw{scroll}"><table class="t"{grid}><thead><tr>{header_cells}'
894
+ f"</tr></thead><tbody>{body}</tbody></table></div>"
895
+ )
896
+
897
+ tools = _columns_menu(heads, report) if chooses_columns else ""
898
+ foot = ""
899
+ if filters:
900
+ tools += _filter_controls(filters, heads, report)
901
+ foot = _filter_foot(len(rows))
902
+ tools = f'<div class="tb"{_HIDE}>{tools}</div>' if tools else ""
903
+ more = ""
904
+ if hidden:
905
+ also = f"; sorting and filters use the {len(rows):,} shown" if sorts or filters else ""
906
+ more = f'<div class="more">{_esc(_hidden(hidden, block, report.max_rows) + also)}</div>'
907
+
908
+ out = _table_frame(block, tools, table + foot + more)
909
+ return f'<form class="tbl" method="dialog" autocomplete="off">{out}</form>' if interactive else out
910
+
911
+
912
+ @_draws(_Text, "html")
913
+ def _text_html(block: _Text, report: _Report) -> str:
914
+ css = " ".join(filter(None, ["wrap" if block.wrap else "", "code" if block.code else ""]))
915
+ pre = (
916
+ f'<pre class="{css}"{_SELECT if block.code else ""}>{_esc(block.text)}</pre>'
917
+ if css
918
+ else f"<pre>{_esc(block.text)}</pre>"
919
+ )
920
+ if block.collapsed:
921
+ return f'<details class="sec"><summary>{_prose(block.title or "Details")}</summary>{pre}</details>'
922
+ hint = '<span class="hint">click it to select all, then copy</span>' if block.code else ""
923
+ return (f"<h4>{_prose(block.title)}{hint}</h4>" if block.title or hint else "") + pre
924
+
925
+
926
+ @_draws(_Title, "text")
927
+ def _title_lines(block: _Title, report: _Report) -> list[str]:
928
+ return ["", block.text, "=" * min(len(block.text), 100)] + ([block.sub] if block.sub else [])
929
+
930
+
931
+ @_draws(_Cards, "text")
932
+ def _cards_lines(block: _Cards, report: _Report) -> list[str]:
933
+ out, line = [], ""
934
+ for entry in block.items:
935
+ item = f"{entry[0]}: {entry[1]}" + (" (!)" if _tone(entry) in ("warn", "bad") else "")
936
+ if line and len(line) + len(item) > 100:
937
+ out.append(line)
938
+ line = ""
939
+ line += (" " if line else "") + item
940
+ return out + [line]
941
+
942
+
943
+ @_draws(_Note, "text")
944
+ def _note_lines(block: _Note, report: _Report) -> list[str]:
945
+ return [_MARKS.get(block.level, _INFO_MARK) + block.text]
946
+
947
+
948
+ @_draws(_Findings, "text")
949
+ def _findings_lines(block: _Findings, report: _Report) -> list[str]:
950
+ items = _ordered(block.items)
951
+ if items:
952
+ return ["", f"-- Findings: {_counts(items, ', ')} --"] + [
953
+ _MARKS.get(level, _INFO_MARK) + message for level, message in items
954
+ ]
955
+ return ["[ok] " + block.empty] if block.empty else []
956
+
957
+
958
+ @_draws(_Next, "text")
959
+ def _next_lines(block: _Next, report: _Report) -> list[str]:
960
+ if not block.items:
961
+ return []
962
+ width = max(len(call) for call, _ in block.items)
963
+ return ["", f"{block.title}:"] + [f" {call.ljust(width)} {why}".rstrip() for call, why in block.items]
964
+
965
+
966
+ @_draws(_Table, "text")
967
+ def _table_lines(block: _Table, report: _Report) -> list[str]:
968
+ out = [""] + ([f"-- {block.title} --"] if block.title else [])
969
+ if not block.rows:
970
+ return out + ["(none)"]
971
+ rows, hidden = _visible_rows(block, report.max_rows)
972
+ headers = list(block.headers) + ([block.bar_label] if block.bars is not None else [])
973
+ cells = [
974
+ [
975
+ ", ".join(map(_clip_path, text.split("\n"))) if j in block.path_cols else _clip(text.replace("\n", ", "))
976
+ for j, text in enumerate("" if c is None else str(c) for c in row)
977
+ ]
978
+ + ([_text_bar(block.bars[i])] if block.bars is not None else [])
979
+ for i, row in enumerate(rows)
980
+ ]
981
+ widths = [max([_width(h)] + [_width(r[j]) for r in cells]) for j, h in enumerate(headers)]
982
+
983
+ def line_of(values: list[str]) -> str:
984
+ return " ".join(_pad(v, w, right=bool(_NUMERIC_RE.match(v))) for v, w in zip(values, widths)).rstrip()
985
+
986
+ out += [line_of(headers), " ".join("-" * w for w in widths)] + [line_of(r) for r in cells]
987
+ return out + ([_hidden(hidden, block, report.max_rows)] if hidden else [])
988
+
989
+
990
+ @_draws(_Text, "text")
991
+ def _text_lines(block: _Text, report: _Report) -> list[str]:
992
+ return (["", f"-- {block.title} --"] if block.title else []) + [block.text]
993
+
994
+
995
+ def _block_html(block: Any, report: _Report) -> str:
996
+ """
997
+ One block as HTML, drawn by what's registered for its type.
998
+ """
999
+ draw = _drawer(_HTML, block)
1000
+ return draw(block, report) if draw else _note_html(_undrawn(block), report)
1001
+
1002
+
1003
+ def _rules_html(report: _Report) -> str:
1004
+ """
1005
+ The <style> holding the CSS rules the report's blocks added (its tables' filters and columns), if any.
1006
+ """
1007
+ return "<style>" + "\n".join(sorted(report.rules)) + "</style>" if report.rules else ""
1008
+
1009
+
1010
+ def _blocks_html(blocks: list[Any], max_rows: int = 0, root: str = "") -> str:
1011
+ """
1012
+ Blocks as HTML without a report's stylesheet and box, for a window that has both inside a box of the root
1013
+ class `root` (a title gets no badge).
1014
+ """
1015
+ report = _Report(max_rows, root=root, blocks=tuple(blocks))
1016
+ body = "".join(_block_html(block, report) for block in blocks)
1017
+ return _rules_html(report) + body
1018
+
1019
+
1020
+ def _report_html(blocks: list[Any], max_rows: int, css: str, root: str, badge: str) -> str:
1021
+ """
1022
+ A report as HTML: the service's stylesheet, the rules its blocks added, then its blocks in a box of the
1023
+ service's root class.
1024
+ """
1025
+ report = _Report(max_rows, badge, root, tuple(blocks))
1026
+ body = [_block_html(block, report) for block in blocks]
1027
+ return "".join([css, _rules_html(report), f'<div class="{root}">', *body, "</div>"])
1028
+
1029
+
1030
+ def _block_lines(block: Any, report: _Report) -> list[str]:
1031
+ """
1032
+ One block as lines of text, drawn by what's registered for its type.
1033
+ """
1034
+ draw = _drawer(_TEXT, block)
1035
+ return draw(block, report) if draw else _note_lines(_undrawn(block), report)
1036
+
1037
+
1038
+ def _render_text(blocks: list[Any], max_rows: int) -> str:
1039
+ """
1040
+ A report as text: each block's lines, one after the other.
1041
+ """
1042
+ report = _Report(max_rows)
1043
+ return "\n".join(line for block in blocks for line in _block_lines(block, report))
1044
+
1045
+
1046
+ _PAGE_CSS = """\
1047
+ :root{color-scheme:light dark}
1048
+ body{margin:0;padding:24px clamp(16px,4vw,48px) 48px;background:Canvas;color:CanvasText}
1049
+ """
1050
+
1051
+
1052
+ def _page_document(report_html: str, title: str, head: str = "") -> str:
1053
+ """
1054
+ A report's HTML as a page of its own, to open in a browser tab: a whole HTML document with the report in it, in
1055
+ the browser's own light or dark colours. Still no JavaScript. `head` is added to the <head> (a favicon, say).
1056
+ """
1057
+ return "".join(
1058
+ [
1059
+ '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n',
1060
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n',
1061
+ head,
1062
+ f"<title>{_esc(title)}</title>\n<style>\n{_PAGE_CSS}</style>\n</head>\n<body>\n",
1063
+ report_html,
1064
+ "\n</body>\n</html>\n",
1065
+ ]
1066
+ )