Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ repos:
files: tests/.*

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.15.21
hooks:
- id: ruff-check
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
- id: ruff-format

- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.25.0
rev: v2.25.2
hooks:
- id: pyproject-fmt
args: [--keep-full-version, --no-print-diff]
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ requires = [ "hatchling" ]

[project]
name = "ast-explore"
version = "0.1.0"
version = "0.2.0"
description = "Tool for exploring the AST of given Python source code."
readme = "README.md"
keywords = [
Expand All @@ -19,7 +19,7 @@ authors = [
]
requires-python = ">=3.11"
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
Expand All @@ -36,7 +36,7 @@ scripts.ast-explore = "ast_explore.cli:main"

[dependency-groups]
dev = [
"mypy>=2.1.0",
"mypy>=2.2.0",
"pre-commit>=4.6.0",
]

Expand Down
33 changes: 26 additions & 7 deletions src/ast_explore/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,39 @@ def get_parser() -> argparse.ArgumentParser:
)

parser.add_argument(
'-i',
'--interactive',
action='store_true',
help=(
'whether to run in interactive mode, which allows the user to decide '
'whether they want to visit a given node'
),
)

node_type_group = parser.add_argument_group(
'node type specification (mutually-exclusive)'
).add_mutually_exclusive_group()
node_type_group.add_argument(
'-t',
'--types',
metavar='TYPE',
nargs='*',
help=(
'node types to explore (e.g., --types Try ExceptionHandler). '
"If you don't provide any specific types, all will be explored."
),
type=validate_node_type,
)

parser.add_argument(
'--interactive',
action='store_true',
node_type_group.add_argument(
'-x',
'--skip',
metavar='TYPE',
nargs='*',
help=(
'whether to run in interactive mode, which allows the user to decide '
'whether they want to visit a given node.'
'node types to skip over (e.g., --skip Module). '
"If you don't provide any specific types, none will be skipped over."
),
type=validate_node_type,
)

return parser
Expand All @@ -105,7 +122,9 @@ def main(argv: Sequence[str] | None = None) -> int:
args = get_parser().parse_args(argv)

try:
visitor = NodeExplorer(args.source_code_file_path, args.types, args.interactive)
visitor = NodeExplorer(
args.source_code_file_path, args.types, args.skip, args.interactive
)
except (FileNotFoundError, SyntaxError):
return 1
except Exception as exc:
Expand Down
82 changes: 62 additions & 20 deletions src/ast_explore/explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,52 @@
from .display import print_header, print_section_divider, print_source_code


def _process_node_types(
node_types: Sequence[str] | Sequence[type[ast.AST]] | None,
*,
default_to_empty: bool,
) -> set[str]:
"""
Process input node types into a set of validated node names.

Parameters
----------
node_types : Sequence[str] | Sequence[type[ast.AST]] | None
The node types to process.
default_to_empty : bool
Whether the a value of ``None`` for ``node_types`` should be interpreted as
an empty set or a set of all possible node types.

Returns
-------
set[str]
The validated node names.

Raises
------
TypeError
When ``node_types`` cannot be interpreted.
"""
match node_types:
case None:
return set() if default_to_empty else set(ast_node_types_generator())
case [str(), *other_node_types] if all(
isinstance(node_type, str) for node_type in other_node_types
):
return {
cast('str', node_type).removeprefix('ast.') for node_type in node_types
}
case [type(), *other_node_types] if all(
issubclass(cast('type', node_type), ast.AST)
for node_type in other_node_types
):
return {cast('type', node_type).__name__ for node_type in node_types}
case _:
raise TypeError(
'node types must be a sequence of strings or AST classes, if not None'
)


class NodeExplorer(ast.NodeVisitor):
"""
Node visitor capable of interactively exploring nodes of the AST during traversal.
Expand All @@ -27,6 +73,8 @@ class NodeExplorer(ast.NodeVisitor):
each node encountered. Provide a sequence of strings (*e.g.*, ``['Try', 'Assert']``)
or :mod:`ast` types (*e.g.*, ``[ast.Try, ast.Assert]``) to only explore specific
node types.
nodes_to_skip : Sequence[str] | Sequence[type[ast.AST]] | None, optional
The types of nodes to skip over. By default, none are skipped over.
interactive : bool, default=False
Whether to explore the AST interactively, in which case the traversal will stop
to show you information about the node and wait for you to decide the next step.
Expand All @@ -36,6 +84,7 @@ def __init__(
self,
source_code_file_path: str | os.PathLike[str],
nodes_to_explore: Sequence[str] | Sequence[type[ast.AST]] | None,
nodes_to_skip: Sequence[str] | Sequence[type[ast.AST]] | None = None,
interactive: bool = False,
) -> None:
self._nodes_visited = itertools.count(1)
Expand All @@ -56,25 +105,9 @@ def __init__(
print('⚠️ Input source code is not syntactically-correct')
raise

match nodes_to_explore:
case None:
self._nodes_to_explore = set(ast_node_types_generator())
case [str(), *other_node_types] if all(
isinstance(node_type, str) for node_type in other_node_types
):
self._nodes_to_explore = {
node_type.removeprefix('ast.') for node_type in nodes_to_explore
}
case [type(), *other_node_types] if all(
issubclass(node_type, ast.AST) for node_type in other_node_types
):
self._nodes_to_explore = {
cast('str', node_type.__name__) for node_type in nodes_to_explore
}
case _:
raise TypeError(
'nodes_to_explore must be a sequence of strings or AST classes, if not None'
)
self._nodes_to_explore = _process_node_types(
nodes_to_explore, default_to_empty=False
) - _process_node_types(nodes_to_skip, default_to_empty=True)

self.stack: list[str] = []
self._interactive = interactive
Expand Down Expand Up @@ -246,6 +279,15 @@ def generic_visit(self, node: ast.AST) -> None:
def run(self) -> None:
"""Traverse the AST from the root to the leaves."""
print('✅ Ready to explore the AST! Starting depth-first traversal...\n')

self.visit(self.tree)

print_section_divider('*')
print('🏆 Traversal completed!')

if next(self._nodes_visited) > 1:
print('🏆 Traversal completed!')
else:
print(
'📭 Did not encounter any of the requested node type(s): '
f'{", ".join(self._nodes_to_explore)}'
)
Loading
Loading