diff --git a/CLAUDE.md b/CLAUDE.md index 1864960..4a4c46b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,8 @@ cd src && for f in aws_analyzer/[!_]*.py aws_analyzer/[!_]*/__init__.py; do m="$ makes (`monkeypatch.setattr(s3mod, "_require", ...)`, `monkeypatch.setattr(chatmod, "_cell_number", ...)`: the chat's View asks it), on the `_kit` (or `_bedrock`) module for a call it makes (`monkeypatch.setattr(viewmod, "_progress_bar_class", ...)` with `from aws_analyzer._kit import view as viewmod`: - `_BaseView._progress` makes that call, and `_BaseView.__init__` asks `_in_notebook` there too; an explorer's - once-per-cell display asks `_cell_number` on `_kit.window`). + `_ProgressLine`, the line `_BaseView._progress` shows, makes that call, and `_BaseView.__init__` asks + `_in_notebook` there too; an explorer's once-per-cell display asks `_cell_number` on `_kit.window`). The exception is a companion (`COMPANIONS` in `.claude/skills/check/rules.py`): `s3_explorer.py` imports `s3` (lazily, inside `_s3_module()`), reuses its helpers instead of copying them. Its public commands are guarded by `_kit`'s `@_window_errors`, through its `_ready` (nothing runs without s3.py) and diff --git a/ruff.toml b/ruff.toml index 3c3dcbc..e1e544d 100644 --- a/ruff.toml +++ b/ruff.toml @@ -29,13 +29,6 @@ select = ["E4", "E7", "E9", "F", "B006", "BLE001", "ERA001", "C901", "PLR0912", ".claude/skills/demo/shots.py" = ["C901", "PLR0912", "PLR0913", "PLR0915"] ".claude/skills/release/release.py" = ["BLE001", "C901", "PLR0912", "PLR0915"] "src/aws_analyzer/_bedrock.py" = ["C901", "PLR0912", "PLR0915"] -"src/aws_analyzer/_kit/fmt.py" = ["C901"] -"src/aws_analyzer/_kit/highlight.py" = ["C901"] -"src/aws_analyzer/_kit/jsontree.py" = ["PLR0913"] -"src/aws_analyzer/_kit/progress.py" = ["BLE001"] -"src/aws_analyzer/_kit/render.py" = ["C901", "PLR0912", "PLR0915"] -"src/aws_analyzer/_kit/view.py" = ["BLE001", "C901", "PLR0915"] -"src/aws_analyzer/_kit/window.py" = ["BLE001", "C901", "PLR0913"] "src/aws_analyzer/agent/analyzer.py" = ["PLR0913"] "src/aws_analyzer/agent/harness.py" = ["BLE001", "C901", "PLR0913"] "src/aws_analyzer/agent/llm.py" = ["PLR0913"] diff --git a/src/aws_analyzer/_kit/analyzer.py b/src/aws_analyzer/_kit/analyzer.py index 9e82fc8..df754dc 100644 --- a/src/aws_analyzer/_kit/analyzer.py +++ b/src/aws_analyzer/_kit/analyzer.py @@ -20,20 +20,28 @@ class _BaseAnalyzer: """ What every Analyzer starts with: a boto3 session (session=, or one made from region= and profile=), the retry settings its clients use (self._config), and its prices, the service's list prices with prices= over - them. Nothing here talks to AWS until the service asks. + them (a copy, so the module's price table never changes). Nothing here talks to AWS until the service asks. + + What the service sets: + + _LIST_PRICES its price table (S3_PRICES, ...), which prices= overrides + client its main client + _clients the clients _cached_client made, by service name + _lock held while _cached_client makes a client + max_workers how many threads _map uses; 1 runs in order, as a Stubber answers """ - _LIST_PRICES: dict[str, Any] = {} # the service's price table (S3_PRICES, ...), which prices= overrides - client: Any # the service's main client, which the service makes - _clients: dict[Any, Any] # _cached_client's clients by service name (the service sets it up, with its _lock) - _lock: Any # held while _cached_client makes a client: boto3 sessions aren't thread-safe - max_workers: int # how many threads _map uses (the service sets it); 1 runs in order, as a Stubber answers + _LIST_PRICES: dict[str, Any] = {} + client: Any + _clients: dict[Any, Any] + _lock: Any + max_workers: int def __init__(self, session: Any = None, *, region: str | None = None, profile: str | None = None, prices: dict[str, Any] | None = None): self.session = session or boto3.Session(profile_name=profile, region_name=region) self._config = Config(retries={"max_attempts": 10, "mode": "adaptive"}, max_pool_connections=50) - self.prices = {**self._LIST_PRICES, **(prices or {})} # a copy: the module's table never changes + self.prices = {**self._LIST_PRICES, **(prices or {})} @property def region(self) -> str: @@ -41,10 +49,11 @@ def region(self) -> str: def _cached_client(self, service: str, make: Callable[[], Any]) -> Any: """ - The client for `service`, made by make() the first time it's needed (or the one passed in clients=). + The client for `service`, made by make() the first time it's needed (or the one passed in clients=). It's + made under the lock: boto3 sessions aren't thread-safe, and the windows read from threads. """ if service not in self._clients: - with self._lock: # sessions aren't thread-safe, and the windows read from threads + with self._lock: if service not in self._clients: self._clients[service] = make() return self._clients[service] diff --git a/src/aws_analyzer/_kit/blocks.py b/src/aws_analyzer/_kit/blocks.py index 9be72ea..afedd35 100644 --- a/src/aws_analyzer/_kit/blocks.py +++ b/src/aws_analyzer/_kit/blocks.py @@ -5,6 +5,9 @@ each block by what's registered for its type: its HTML, or its lines of text. These are the blocks every service uses (render.py draws them); a service's own blocks (_Passage, _Steps, _Wiring, ...) are defined in its module and register how they're drawn with @_draws when it's imported. + +The registries @_draws fills: _HTML (block type -> its HTML), _TEXT (block type -> its lines in text mode) and +_CELLS (table cell type -> its HTML, given the cell's text). """ from __future__ import annotations @@ -15,66 +18,110 @@ @dataclass class _Title: + """ + The report's title, with a line under it (sub). + """ + text: str sub: str = "" @dataclass class _Cards: - items: list[tuple[str, ...]] # (label, value), or (label, value, tone) with tone 'warn' | 'bad' | 'ok' + """ + The few numbers that matter, as cards. Each item is (label, value), or (label, value, tone) with tone 'warn', + 'bad' or 'ok'. + """ + + items: list[tuple[str, ...]] @dataclass class _Table: + """ + A table of rows under its headers. + + rows cells are text, or _Tone for a coloured status (or another type registered as a cell) + bars 0..1 per row, drawn as an extra column headed bar_label + tree the first column holds indented tree labels + max_rows None for the view's default, 0 for no cap + code_cols columns holding calls to copy, shown as code + prose_cols columns of sentences this tool wrote (findings): the calls in them shown as code + collapsed a secondary view: folded under its title in HTML + path_cols columns of keys and paths: the file name always shows, a long folder is shortened + sortable in HTML a click on a column's header sorts by it (tree tables never sort) + """ + headers: list[str] - rows: list[list[Any]] # cells are text, or _Tone for a coloured status (or another type registered as a cell) + rows: list[list[Any]] title: str = "" - bars: list[float] | None = None # 0..1 per row, drawn as an extra column + bars: list[float] | None = None bar_label: str = "Share" - tree: bool = False # first column holds indented tree labels - max_rows: int | None = None # None = view default, 0 = no cap - code_cols: tuple[int, ...] = () # columns holding calls to copy, shown as code - prose_cols: tuple[int, ...] = () # columns of sentences this tool wrote (findings): calls in them shown as code - collapsed: bool = False # a secondary view: folded under its title in HTML - path_cols: tuple[int, ...] = () # columns of keys / paths: the file name always shows, a long folder is shortened - sortable: bool = True # in HTML a click on a column's header sorts by it (tree tables never sort) + tree: bool = False + max_rows: int | None = None + code_cols: tuple[int, ...] = () + prose_cols: tuple[int, ...] = () + collapsed: bool = False + path_cols: tuple[int, ...] = () + sortable: bool = True @dataclass class _Note: + """ + A sentence on its own, at a level: 'info', 'warn' or 'ok'. + """ + text: str - level: str = "info" # 'info' | 'warn' | 'ok' + level: str = "info" @dataclass class _Text: + """ + Text shown as it is. + + wrap prose: wrap long lines instead of scrolling sideways + code a snippet to copy: in HTML one click selects all of it + collapsed a secondary view (raw JSON): folded under its title in HTML + """ + text: str title: str = "" - wrap: bool = False # prose: wrap long lines instead of scrolling sideways - code: bool = False # a snippet to copy: in HTML one click selects all of it - collapsed: bool = False # a secondary view (raw JSON): folded under its title in HTML + wrap: bool = False + code: bool = False + collapsed: bool = False @dataclass class _Findings: - items: list[tuple[str, str]] # (level, message) pairs from a *_findings function - empty: str = "" # said (as an ok note) when there are none; nothing when blank + """ + The (level, message) pairs from a *_findings function, and what to say (as an ok note) when there are none; + nothing when `empty` is blank. + """ + + items: list[tuple[str, str]] + empty: str = "" @dataclass class _Next: - items: list[tuple[str, str]] # (call, what it shows): the commands worth running next, arguments filled in + """ + The commands worth running next, as (call, what it shows), with their arguments filled in. + """ + + items: list[tuple[str, str]] title: str = "Next" @dataclass class _Tone: """ - A table cell with a status colour: a pill in HTML, plain text elsewhere. + A table cell with a status colour (tone: 'warn', 'bad' or 'ok'): a pill in HTML, plain text elsewhere. """ text: str - tone: str = "warn" # 'warn' | 'bad' | 'ok' + tone: str = "warn" def __str__(self) -> str: return self.text @@ -84,15 +131,21 @@ def __str__(self) -> str: class _Report: """ One report being drawn: what each block's drawing function gets besides the block. + + max_rows the view's cap on table rows; 0 for no cap + badge the chip before each title (HTML), so reports from different services are easy to tell apart + root the service's root class, which scopes the CSS rules + blocks the whole report, for a block that needs to know what else is in it + rules CSS rules the blocks need beyond the stylesheet (a table's filters and columns): a "]) -# ----------------------------------------------------------------------------- sentences and calls - -_NUMERIC_RE = re.compile(r"^-?( str: Escaped HTML for a sentence this tool wrote, with the calls in it as code that one click selects (and, with money=True, amounts like $12.40/month stressed). The text is split on the calls and every piece escaped before it's wrapped, so nothing in it becomes markup. + + A call is what _CALL_RE finds: a Python call (kb_info(), documents(status='FAILED'), .core.find(...), + S3View().preview('s3://..')) or an AWS CLI command with its options (aws dynamodb update-table --table-name + orders --deletion-protection-enabled). """ pieces = _CALL_RE.split("" if value is None else str(value)) return "".join( @@ -227,10 +232,11 @@ def _prose(value: Any, money: bool = False) -> str: def _masked(text: str) -> str: """ The text with its commands and anything in brackets blanked out (same length), so a sentence is never - split inside them: 'Something went wrong (RuntimeError: boom)' has no lead. + split inside them: 'Something went wrong (RuntimeError: boom)' has no lead. Brackets inside brackets are + blanked from the innermost out. """ masked, before = _CALL_RE.sub(lambda m: "x" * len(m.group(0)), text), None - while masked != before: # innermost brackets first + while masked != before: before, masked = masked, re.sub(r"[(\[][^()\[\]]*[)\]]", lambda m: "x" * len(m.group(0)), masked) return masked @@ -306,18 +312,23 @@ def _call(name: str, *args: Any, **kwargs: Any) -> str: """ _call('tree', 's3://b/', depth=2) -> "tree('s3://b/', depth=2)": a next step, ready to copy. """ + arguments = [_literal(a) for a in args] + [f"{k}={_literal(v)}" for k, v in kwargs.items()] + return f"{name}({', '.join(arguments)})" - def literal(value: Any) -> str: # repr, but DynamoDB numbers read 42 rather than Decimal('42') - if type(value).__name__ == "Decimal": - return str(value) - if isinstance(value, dict): - return "{" + ", ".join(f"{literal(k)}: {literal(v)}" for k, v in value.items()) + "}" - if isinstance(value, (list, tuple)): - inner = ", ".join(map(literal, value)) + ("," if isinstance(value, tuple) and len(value) == 1 else "") - return f"[{inner}]" if isinstance(value, list) else f"({inner})" - return repr(value) - return f"{name}({', '.join([literal(a) for a in args] + [f'{k}={literal(v)}' for k, v in kwargs.items()])})" +def _literal(value: Any) -> str: + """ + A value as Python to copy: its repr, except that DynamoDB numbers read 42 rather than Decimal('42'), inside + lists, tuples and dicts too. + """ + if type(value).__name__ == "Decimal": + return str(value) + if isinstance(value, dict): + return "{" + ", ".join(f"{_literal(k)}: {_literal(v)}" for k, v in value.items()) + "}" + if isinstance(value, (list, tuple)): + inner = ", ".join(map(_literal, value)) + ("," if isinstance(value, tuple) and len(value) == 1 else "") + return f"[{inner}]" if isinstance(value, list) else f"({inner})" + return repr(value) def _signature(function: Callable) -> str: @@ -329,26 +340,26 @@ def _signature(function: Callable) -> str: return str(sig.replace(parameters=params, return_annotation=inspect.Signature.empty)) -# ----------------------------------------------------------------------------- tables: sorting, filters, keys - _SORT_UNITS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4, "PB": 1024**5, "ms": 1} _SORT_NUMBER_RE = re.compile( r"^(-?)( what its first and second header click show +_SORT_WORDS = { "number": ("largest first", "smallest first"), "age": ("oldest first", "newest first"), "date": ("newest first", "oldest first"), "text": ("A to Z", "Z to A"), } -_FILTER_VALUES = 12 # a column gets a filter when it holds 2 to this many different values, some repeated -_FILTER_COLUMNS = 3 # filters per table at most, the leftmost columns first -# Hides the table controls until the stylesheet shows them, so a notebook opened without its styles (JupyterLab -# strips