antoine-cli 0.9.1__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.
antoine/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """antoine — codebase lookup and indexing for agent skills (greenfield AgentCulture sibling)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, packages_distributions
4
+ from importlib.metadata import version as _v
5
+
6
+
7
+ def _resolve_version() -> str:
8
+ """Resolve the installed-distribution version of the ``antoine`` package.
9
+
10
+ The same source ships under multiple PyPI distribution names
11
+ (``antoine-cli``, ``kata-cli``, ``code-lens-cli``) per the lean
12
+ dual-publish slice of issue #17. Look up which distribution provides
13
+ the ``antoine`` top-level package
14
+ via ``importlib.metadata.packages_distributions()`` and return its
15
+ version. Falls back through a known-name allowlist if that lookup is
16
+ unavailable, and finally to ``0.0.0+local`` for editable installs without
17
+ metadata.
18
+ """
19
+ dist_map = packages_distributions()
20
+ for dist in dist_map.get("antoine") or []:
21
+ try:
22
+ return _v(dist)
23
+ except PackageNotFoundError:
24
+ continue
25
+ for fallback in ("antoine-cli", "kata-cli", "code-lens-cli"):
26
+ try:
27
+ return _v(fallback)
28
+ except PackageNotFoundError:
29
+ continue
30
+ return "0.0.0+local" # pragma: no cover — editable install without metadata
31
+
32
+
33
+ __version__ = _resolve_version()
34
+
35
+ __all__ = ["__version__"]
antoine/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow running antoine as ``python -m antoine``."""
2
+
3
+ import sys
4
+
5
+ from antoine.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,117 @@
1
+ """Unified CLI entry point for antoine.
2
+
3
+ Error-propagation contract: every handler raises
4
+ :class:`antoine.cli._errors.AntoineError` on failure; ``main()`` catches it via
5
+ :func:`_dispatch` and routes through :mod:`antoine.cli._output`. Unknown
6
+ exceptions are wrapped into a ``AntoineError`` so no Python traceback leaks.
7
+
8
+ Argparse errors (unknown verb, missing required arg) also route through the
9
+ structured format — :class:`_AntoineArgumentParser` overrides ``.error()``.
10
+ Whether errors render as text or JSON depends on whether ``--json`` appears in
11
+ the raw argv (:func:`main` sets ``_AntoineArgumentParser._json_hint`` before
12
+ ``parse_args``).
13
+ """
14
+
15
+ # pylint: disable=duplicate-code # _dispatch mirrors antoine.repo.__main__ by design
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+
22
+ from antoine import __version__
23
+ from antoine.cli._errors import EXIT_USER_ERROR, AntoineError
24
+ from antoine.cli._output import emit_error
25
+
26
+
27
+ class _AntoineArgumentParser(argparse.ArgumentParser):
28
+ """ArgumentParser that routes errors through :func:`emit_error`."""
29
+
30
+ _json_hint: bool = False
31
+
32
+ def error(self, message: str) -> None: # type: ignore[override]
33
+ err = AntoineError(
34
+ code=EXIT_USER_ERROR,
35
+ message=message,
36
+ remediation=f"run '{self.prog} --help' to see valid arguments",
37
+ )
38
+ emit_error(err, json_mode=type(self)._json_hint)
39
+ raise SystemExit(err.code)
40
+
41
+
42
+ def _argv_has_json(argv: list[str] | None) -> bool:
43
+ tokens = argv if argv is not None else sys.argv[1:]
44
+ return any(t == "--json" or t.startswith("--json=") for t in tokens)
45
+
46
+
47
+ def _build_parser() -> argparse.ArgumentParser:
48
+ parser = _AntoineArgumentParser(
49
+ prog="antoine",
50
+ description="antoine — codebase lookup and indexing for agent skills (greenfield).",
51
+ )
52
+ parser.add_argument(
53
+ "--version",
54
+ action="version",
55
+ version=f"%(prog)s {__version__}",
56
+ )
57
+ sub = parser.add_subparsers(dest="command", parser_class=_AntoineArgumentParser)
58
+
59
+ # pylint: disable=import-outside-toplevel
60
+ from antoine.cli._commands import classify as _classify_cmd
61
+ from antoine.cli._commands import explain as _explain_cmd
62
+ from antoine.cli._commands import grep as _grep_cmd
63
+ from antoine.cli._commands import learn as _learn_cmd
64
+ from antoine.cli._commands import recent as _recent_cmd
65
+ from antoine.cli._commands import whoami as _whoami_cmd
66
+
67
+ # pylint: enable=import-outside-toplevel
68
+
69
+ _learn_cmd.register(sub)
70
+ _explain_cmd.register(sub)
71
+ _whoami_cmd.register(sub)
72
+ _classify_cmd.register(sub)
73
+ _grep_cmd.register(sub)
74
+ _recent_cmd.register(sub)
75
+
76
+ return parser
77
+
78
+
79
+ def _dispatch(args: argparse.Namespace) -> int: # pylint: disable=duplicate-code
80
+ """Invoke the registered handler and translate exceptions to exit codes.
81
+
82
+ A handler may return ``None`` (treated as success, exit 0) or an ``int``
83
+ used directly as the exit code. Failures MUST raise :class:`AntoineError`;
84
+ any other exception is wrapped so no Python traceback leaks.
85
+ """
86
+ json_mode = bool(getattr(args, "json", False))
87
+ try:
88
+ rc = args.func(args)
89
+ except AntoineError as err:
90
+ emit_error(err, json_mode=json_mode)
91
+ return err.code
92
+ except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
93
+ wrapped = AntoineError(
94
+ code=EXIT_USER_ERROR,
95
+ message=f"unexpected: {err.__class__.__name__}: {err}",
96
+ remediation="file a bug at https://github.com/agentculture/antoine/issues",
97
+ )
98
+ emit_error(wrapped, json_mode=json_mode)
99
+ return wrapped.code
100
+ return rc if rc is not None else 0
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ """Parse *argv* (defaults to ``sys.argv[1:]``) and dispatch to a verb handler."""
105
+ _AntoineArgumentParser._json_hint = _argv_has_json(argv) # pylint: disable=protected-access
106
+ parser = _build_parser()
107
+ args = parser.parse_args(argv)
108
+
109
+ if args.command is None:
110
+ parser.print_help()
111
+ return 0
112
+
113
+ return _dispatch(args)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """antoine CLI verb modules — one module per verb, each exposing ``register()``."""
@@ -0,0 +1,40 @@
1
+ """``antoine classify [path]`` — project-type classifier.
2
+
3
+ Returns a deterministic list of tags describing what kind of project the
4
+ repo at *path* is (cli / library / dockerized / tested / packaged-pypi / …),
5
+ each paired with concrete file-grounded evidence.
6
+ """
7
+
8
+ # pylint: disable=duplicate-code # verb-registration boilerplate
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from pathlib import Path
14
+
15
+ from antoine.cli._output import emit_result
16
+ from antoine.lookup.classify import classify
17
+ from antoine.lookup.render import render_classify_markdown
18
+
19
+
20
+ def cmd_classify(args: argparse.Namespace) -> int:
21
+ """Handle the ``classify`` verb."""
22
+ path = Path(args.path).resolve()
23
+ data = classify(path)
24
+ json_mode = bool(getattr(args, "json", False))
25
+ if json_mode:
26
+ emit_result({"ok": True, "data": data}, json_mode=True)
27
+ else:
28
+ emit_result(render_classify_markdown(data), json_mode=False)
29
+ return 0
30
+
31
+
32
+ def register(sub: argparse._SubParsersAction) -> None:
33
+ """Register the ``classify`` sub-command on *sub*."""
34
+ p = sub.add_parser(
35
+ "classify",
36
+ help="Classify a repo by project-type tags (cli / library / dockerized / …).",
37
+ )
38
+ p.add_argument("path", nargs="?", default=".", help="Path to the repo (default: cwd).")
39
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
40
+ p.set_defaults(func=cmd_classify)
@@ -0,0 +1,44 @@
1
+ """``antoine explain`` — placeholder verb.
2
+
3
+ See :mod:`antoine.cli._commands.learn` for why the verbs are stubs. ``explain``
4
+ will eventually print docs for a given topic / command path; today it prints
5
+ an honest "not yet implemented" line.
6
+ """
7
+
8
+ # pylint: disable=duplicate-code # intentional: three stub verbs share the same structure
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+
14
+ from antoine import __version__
15
+ from antoine.cli._output import emit_result
16
+
17
+ _TEXT = "antoine explain — not yet implemented; antoine is greenfield. See CLAUDE.md."
18
+
19
+
20
+ def _json_payload() -> dict[str, object]:
21
+ return {
22
+ "tool": "antoine",
23
+ "version": __version__,
24
+ "status": "greenfield",
25
+ "verb": "explain",
26
+ "message": _TEXT,
27
+ }
28
+
29
+
30
+ def cmd_explain(args: argparse.Namespace) -> int:
31
+ """Handle the ``explain`` verb — print status and return 0."""
32
+ json_mode = bool(getattr(args, "json", False))
33
+ if json_mode:
34
+ emit_result(_json_payload(), json_mode=True)
35
+ else:
36
+ emit_result(_TEXT, json_mode=False)
37
+ return 0
38
+
39
+
40
+ def register(sub: argparse._SubParsersAction) -> None:
41
+ """Register the ``explain`` sub-command on *sub*."""
42
+ p = sub.add_parser("explain", help="Explain a antoine topic or command (stub).")
43
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
44
+ p.set_defaults(func=cmd_explain)
@@ -0,0 +1,44 @@
1
+ """``antoine grep <pattern> [path]`` — AST-scope-augmented ripgrep search.
2
+
3
+ Runs ``rg --json <pattern> <path>`` and pairs every match with the enclosing
4
+ Python function or class name via the AST scope resolver.
5
+ """
6
+
7
+ # pylint: disable=duplicate-code # verb-registration boilerplate
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ from pathlib import Path
13
+
14
+ from antoine.cli._output import emit_result
15
+ from antoine.lookup.grep_context import grep_with_context, render_grep_markdown
16
+
17
+
18
+ def cmd_grep(args: argparse.Namespace) -> int:
19
+ """Handle the ``grep`` verb."""
20
+ path = Path(args.path).resolve()
21
+ data = grep_with_context(args.pattern, path)
22
+ json_mode = bool(getattr(args, "json", False))
23
+ if json_mode:
24
+ emit_result(data, json_mode=True)
25
+ else:
26
+ emit_result(render_grep_markdown(data), json_mode=False)
27
+ return 0
28
+
29
+
30
+ def register(sub: argparse._SubParsersAction) -> None:
31
+ """Register the ``grep`` sub-command on *sub*."""
32
+ p = sub.add_parser(
33
+ "grep",
34
+ help="Search a codebase with rg and annotate each match with its AST scope.",
35
+ )
36
+ p.add_argument("pattern", help="Regex pattern to search for.")
37
+ p.add_argument(
38
+ "path",
39
+ nargs="?",
40
+ default=".",
41
+ help="File or directory to search (default: cwd).",
42
+ )
43
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
44
+ p.set_defaults(func=cmd_grep)
@@ -0,0 +1,49 @@
1
+ """``antoine learn`` — placeholder verb.
2
+
3
+ antoine is a greenfield AgentCulture sibling: the scaffold (package, CLI
4
+ chassis, CI, vendored skills) is in place but the codebase lookup and
5
+ indexing engine itself is not implemented yet. This verb prints an honest
6
+ status line so a probing agent or human gets a clear signal rather than a
7
+ misleading response.
8
+ """
9
+
10
+ # pylint: disable=duplicate-code # intentional: three stub verbs share the same structure
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+
16
+ from antoine import __version__
17
+ from antoine.cli._output import emit_result
18
+
19
+ _TEXT = (
20
+ "antoine — codebase lookup and indexing for agent skills. Not yet implemented; "
21
+ "antoine is a greenfield AgentCulture sibling. See CLAUDE.md."
22
+ )
23
+
24
+
25
+ def _json_payload() -> dict[str, object]:
26
+ return {
27
+ "tool": "antoine",
28
+ "version": __version__,
29
+ "status": "greenfield",
30
+ "verb": "learn",
31
+ "message": _TEXT,
32
+ }
33
+
34
+
35
+ def cmd_learn(args: argparse.Namespace) -> int:
36
+ """Handle the ``learn`` verb — print status and return 0."""
37
+ json_mode = bool(getattr(args, "json", False))
38
+ if json_mode:
39
+ emit_result(_json_payload(), json_mode=True)
40
+ else:
41
+ emit_result(_TEXT, json_mode=False)
42
+ return 0
43
+
44
+
45
+ def register(sub: argparse._SubParsersAction) -> None: # pylint: disable=duplicate-code
46
+ """Register the ``learn`` sub-command on *sub*."""
47
+ p = sub.add_parser("learn", help="Print antoine's self-teaching status line.")
48
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
49
+ p.set_defaults(func=cmd_learn)
@@ -0,0 +1,52 @@
1
+ """``antoine recent [path] [-n N]`` — git commit log paired with AST symbol diffs.
2
+
3
+ Runs ``git log -n N`` in *path*, and for each commit pairs every changed file
4
+ with a structural symbol-diff at the AST level (functions/classes added /
5
+ removed / modified).
6
+ """
7
+
8
+ # pylint: disable=duplicate-code # verb-registration boilerplate
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from pathlib import Path
14
+
15
+ from antoine.cli._output import emit_result
16
+ from antoine.lookup.recent_outline import recent_with_outline, render_recent_markdown
17
+
18
+
19
+ def cmd_recent(args: argparse.Namespace) -> int:
20
+ """Handle the ``recent`` verb."""
21
+ path = Path(args.path).resolve()
22
+ data = recent_with_outline(n=args.count, path=path)
23
+ json_mode = bool(getattr(args, "json", False))
24
+ if json_mode:
25
+ emit_result(data, json_mode=True)
26
+ else:
27
+ emit_result(render_recent_markdown(data), json_mode=False)
28
+ return 0
29
+
30
+
31
+ def register(sub: argparse._SubParsersAction) -> None:
32
+ """Register the ``recent`` sub-command on *sub*."""
33
+ p = sub.add_parser(
34
+ "recent",
35
+ help="Show recent git commits paired with AST symbol diffs per file.",
36
+ )
37
+ p.add_argument(
38
+ "path",
39
+ nargs="?",
40
+ default=".",
41
+ help="Path to the git repository (default: cwd).",
42
+ )
43
+ p.add_argument(
44
+ "-n",
45
+ "--count",
46
+ type=int,
47
+ default=20,
48
+ metavar="N",
49
+ help="Number of commits to show (default: 20).",
50
+ )
51
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
52
+ p.set_defaults(func=cmd_recent)
@@ -0,0 +1,42 @@
1
+ """``antoine whoami`` — placeholder verb.
2
+
3
+ See :mod:`antoine.cli._commands.learn` for why the verbs are stubs. ``whoami``
4
+ will eventually be the smallest identity / auth probe; today it prints an
5
+ honest "not yet implemented" line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from antoine import __version__
13
+ from antoine.cli._output import emit_result
14
+
15
+ _TEXT = "antoine — not yet implemented; antoine is greenfield. See CLAUDE.md."
16
+
17
+
18
+ def _json_payload() -> dict[str, object]:
19
+ return {
20
+ "tool": "antoine",
21
+ "version": __version__,
22
+ "status": "greenfield",
23
+ "verb": "whoami",
24
+ "message": _TEXT,
25
+ }
26
+
27
+
28
+ def cmd_whoami(args: argparse.Namespace) -> int:
29
+ """Handle the ``whoami`` verb — print status and return 0."""
30
+ json_mode = bool(getattr(args, "json", False))
31
+ if json_mode:
32
+ emit_result(_json_payload(), json_mode=True)
33
+ else:
34
+ emit_result(_TEXT, json_mode=False)
35
+ return 0
36
+
37
+
38
+ def register(sub: argparse._SubParsersAction) -> None:
39
+ """Register the ``whoami`` sub-command on *sub*."""
40
+ p = sub.add_parser("whoami", help="Print antoine's identity probe (stub).")
41
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
42
+ p.set_defaults(func=cmd_whoami)
antoine/cli/_errors.py ADDED
@@ -0,0 +1,59 @@
1
+ """AntoineError and exit-code policy.
2
+
3
+ Every failure inside antoine raises :class:`AntoineError`. The top-level
4
+ ``main()`` catches it, formats via :mod:`antoine.cli._output`, and exits with
5
+ :attr:`AntoineError.code`. This centralises the exit-code policy and guarantees
6
+ no Python traceback leaks to stderr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ # Exit-code policy:
14
+ # 0 = success
15
+ # 1 = user-input error (bad flag, missing required arg, unknown path)
16
+ # 2 = environment / setup error (tool not installed, file unreadable)
17
+ # 3 = internal bug (uncaught exception wrapped by _dispatch)
18
+ # 4+ = reserved for future categorisation
19
+ EXIT_SUCCESS = 0
20
+ EXIT_USER_ERROR = 1
21
+ EXIT_ENV_ERROR = 2
22
+ EXIT_INTERNAL = 3
23
+
24
+
25
+ @dataclass
26
+ class AntoineError(Exception):
27
+ """Structured error raised within antoine.
28
+
29
+ Fields:
30
+ code: exit code (see constants above)
31
+ message: one-sentence plain-English description of what went wrong
32
+ remediation: optional concrete next step for the user/agent
33
+ reason: optional root-cause sentence (what was tried, why it failed)
34
+ kind: optional short tag — "user_error" | "env_error" | "bug"
35
+
36
+ Renderers (:func:`antoine.cli._output.emit_error`) skip empty optional
37
+ fields so older call sites that only set code+message+remediation keep
38
+ producing the same output.
39
+ """
40
+
41
+ code: int
42
+ message: str
43
+ remediation: str = ""
44
+ reason: str = ""
45
+ kind: str = ""
46
+
47
+ def __post_init__(self) -> None:
48
+ super().__init__(self.message)
49
+
50
+ def to_dict(self) -> dict[str, object]:
51
+ """Serialise to a plain dict suitable for JSON output."""
52
+ out: dict[str, object] = {"code": self.code, "message": self.message}
53
+ if self.kind:
54
+ out["kind"] = self.kind
55
+ if self.reason:
56
+ out["reason"] = self.reason
57
+ if self.remediation:
58
+ out["remediation"] = self.remediation
59
+ return out
antoine/cli/_output.py ADDED
@@ -0,0 +1,47 @@
1
+ """stdout / stderr helpers with a strict split.
2
+
3
+ Rule: **results go to stdout, diagnostics and errors go to stderr.** Agents
4
+ parsing antoine output can rely on this invariant. JSON mode routes structured
5
+ payloads to the same streams — it never mixes them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import Any, TextIO
13
+
14
+ from antoine.cli._errors import AntoineError
15
+
16
+
17
+ def emit_result(data: Any, *, json_mode: bool, stream: TextIO | None = None) -> None:
18
+ """Write a command result to stdout (text or JSON), newline-terminated."""
19
+ s = stream if stream is not None else sys.stdout
20
+ if json_mode:
21
+ json.dump(data, s, ensure_ascii=False)
22
+ s.write("\n")
23
+ return
24
+ text = data if isinstance(data, str) else str(data)
25
+ s.write(text)
26
+ if not text.endswith("\n"):
27
+ s.write("\n")
28
+
29
+
30
+ def emit_error(err: AntoineError, *, json_mode: bool, stream: TextIO | None = None) -> None:
31
+ """Write a :class:`AntoineError` to stderr (text or JSON)."""
32
+ s = stream if stream is not None else sys.stderr
33
+ if json_mode:
34
+ json.dump(err.to_dict(), s, ensure_ascii=False)
35
+ s.write("\n")
36
+ return
37
+ s.write(f"error: {err.message}\n")
38
+ if err.reason:
39
+ s.write(f"reason: {err.reason}\n")
40
+ if err.remediation:
41
+ s.write(f"hint: {err.remediation}\n")
42
+
43
+
44
+ def emit_diagnostic(message: str, *, stream: TextIO | None = None) -> None:
45
+ """Write a human diagnostic (progress, summary) to stderr."""
46
+ s = stream if stream is not None else sys.stderr
47
+ s.write(message if message.endswith("\n") else message + "\n")
@@ -0,0 +1,25 @@
1
+ """antoine.lookup — codebase classification + lookup verbs.
2
+
3
+ This package is the sibling of `antoine.repo`: it answers "what kind of project
4
+ is this?" / "where is X?" rather than "tell me about this repo."
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from antoine.lookup.ast_scope import Scope, find_enclosing, list_symbols
10
+ from antoine.lookup.classify import classify
11
+ from antoine.lookup.grep_context import grep_with_context, render_grep_markdown
12
+ from antoine.lookup.recent_outline import recent_with_outline, render_recent_markdown
13
+ from antoine.lookup.render import render_classify_markdown
14
+
15
+ __all__ = [
16
+ "classify",
17
+ "find_enclosing",
18
+ "grep_with_context",
19
+ "list_symbols",
20
+ "recent_with_outline",
21
+ "render_classify_markdown",
22
+ "render_grep_markdown",
23
+ "render_recent_markdown",
24
+ "Scope",
25
+ ]
@@ -0,0 +1,74 @@
1
+ """antoine.lookup.ast_scope — AST-based scope resolver (stdlib ast only).
2
+
3
+ Provides:
4
+ Scope — frozen dataclass describing a named code scope.
5
+ list_symbols — collect all module-level + class-method scopes.
6
+ find_enclosing — smallest scope whose line range contains a given line.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from dataclasses import dataclass
13
+
14
+ __all__ = ["Scope", "list_symbols", "find_enclosing"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Scope:
19
+ kind: str # "function" | "async_function" | "class"
20
+ name: str # qualified, e.g. "Foo.method_a"
21
+ start_line: int
22
+ end_line: int
23
+
24
+
25
+ def _scope_kind(node: ast.AST) -> str | None:
26
+ """Return the scope kind string for *node*, or ``None`` if not a named scope."""
27
+ if isinstance(node, ast.AsyncFunctionDef):
28
+ return "async_function"
29
+ if isinstance(node, ast.ClassDef):
30
+ return "class"
31
+ if isinstance(node, ast.FunctionDef):
32
+ return "function"
33
+ return None
34
+
35
+
36
+ def list_symbols(tree: ast.AST) -> list[Scope]:
37
+ """Walk *tree* and return one :class:`Scope` per named scope.
38
+
39
+ Covers:
40
+ - Module-level functions, async functions, and classes.
41
+ - Methods defined directly inside a class (one level of nesting per
42
+ class, but classes inside classes recurse so ``Outer.Inner.method``
43
+ is emitted correctly).
44
+
45
+ Does **not** recurse into function bodies.
46
+ """
47
+ out: list[Scope] = []
48
+
49
+ def visit(node: ast.AST, prefix: str = "") -> None:
50
+ for child in ast.iter_child_nodes(node):
51
+ kind = _scope_kind(child)
52
+ if kind is None:
53
+ continue
54
+ name = f"{prefix}{child.name}" # type: ignore[attr-defined]
55
+ end = child.end_lineno or child.lineno # type: ignore[attr-defined]
56
+ start = child.lineno # type: ignore[attr-defined]
57
+ out.append(Scope(kind=kind, name=name, start_line=start, end_line=end))
58
+ if isinstance(child, ast.ClassDef):
59
+ visit(child, prefix=f"{name}.")
60
+
61
+ visit(tree)
62
+ return out
63
+
64
+
65
+ def find_enclosing(tree: ast.AST, line: int) -> Scope | None:
66
+ """Return the smallest :class:`Scope` whose ``[start_line, end_line]``
67
+ contains *line*, or ``None`` for module-level lines.
68
+ """
69
+ best: Scope | None = None
70
+ for s in list_symbols(tree):
71
+ if s.start_line <= line <= s.end_line:
72
+ if best is None or (s.end_line - s.start_line) < (best.end_line - best.start_line):
73
+ best = s
74
+ return best