symbex 0.2__py3-none-any.whl → 0.3__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.
symbex/cli.py CHANGED
@@ -8,7 +8,12 @@ from .lib import code_for_node, find_symbol_nodes
8
8
  @click.version_option()
9
9
  @click.argument("symbols", nargs=-1)
10
10
  @click.option(
11
- "files", "-f", "--file", type=click.File("r"), multiple=True, help="Files to search"
11
+ "files",
12
+ "-f",
13
+ "--file",
14
+ type=click.Path(file_okay=True, dir_okay=False),
15
+ multiple=True,
16
+ help="Files to search",
12
17
  )
13
18
  @click.option(
14
19
  "directories",
@@ -18,7 +23,18 @@ from .lib import code_for_node, find_symbol_nodes
18
23
  multiple=True,
19
24
  help="Directories to search",
20
25
  )
21
- def cli(symbols, files, directories):
26
+ @click.option(
27
+ "-s",
28
+ "--signatures",
29
+ is_flag=True,
30
+ help="Show just function and class signatures",
31
+ )
32
+ @click.option(
33
+ "--silent",
34
+ is_flag=True,
35
+ help="Silently ignore Python files with parse errors",
36
+ )
37
+ def cli(symbols, files, directories, signatures, silent):
22
38
  """
23
39
  Find symbols in Python code and print the code for them.
24
40
 
@@ -30,6 +46,15 @@ def cli(symbols, files, directories):
30
46
  \b
31
47
  # Search using a wildcard
32
48
  symbex 'test_*'
49
+
50
+ \b
51
+ # Find a specific class method
52
+ symbex 'MyClass.my_method'
53
+
54
+ \b
55
+ # Find class methods using wildcards
56
+ symbex '*View.handle_*'
57
+
33
58
  \b
34
59
  # Search a specific file
35
60
  symbex MyClass -f my_file.py
@@ -37,24 +62,42 @@ def cli(symbols, files, directories):
37
62
  # Search within a specific directory and its subdirectories
38
63
  symbex Database -d ~/projects/datasette
39
64
 
65
+ \b
66
+ # View signatures for all symbols in current directory and subdirectories
67
+ symbex -s
68
+
69
+ \b
70
+ # View signatures for all test functions
71
+ symbex 'test_*' -s
40
72
  """
73
+ if signatures and not symbols:
74
+ symbols = ["*"]
41
75
  if not files and not directories:
42
76
  directories = ["."]
43
- files = list(files)
77
+ files = [pathlib.Path(f) for f in files]
44
78
  for directory in directories:
45
79
  files.extend(pathlib.Path(directory).rglob("*.py"))
46
80
  pwd = pathlib.Path(".").resolve()
47
81
  for file in files:
48
82
  code = file.read_text("utf-8") if hasattr(file, "read_text") else file.read()
49
- nodes = find_symbol_nodes(code, symbols)
50
- for node in nodes:
83
+ try:
84
+ nodes = find_symbol_nodes(code, symbols)
85
+ except SyntaxError as ex:
86
+ if not silent:
87
+ click.secho(f"# Syntax error in {file}: {ex}", err=True, fg="yellow")
88
+ continue
89
+ for node, class_name in nodes:
51
90
  # If file is within pwd, print relative path
52
91
  # else print absolute path
53
92
  if pwd in file.resolve().parents:
54
93
  path = file.resolve().relative_to(pwd)
55
94
  else:
56
95
  path = file.resolve()
57
- snippet, line_no = code_for_node(code, node)
58
- print("# File:", path, "Line:", line_no)
96
+ snippet, line_no = code_for_node(code, node, class_name, signatures)
97
+ bits = ["# File:", path]
98
+ if class_name:
99
+ bits.extend(["Class:", class_name])
100
+ bits.extend(["Line:", line_no])
101
+ print(*bits)
59
102
  print(snippet)
60
103
  print()
symbex/lib.py CHANGED
@@ -1,41 +1,181 @@
1
1
  import fnmatch
2
- from ast import parse, AST
3
- from typing import Iterable, Tuple
2
+ from ast import literal_eval, parse, AST, FunctionDef, ClassDef
3
+ from itertools import zip_longest
4
+ import textwrap
5
+ from typing import Iterable, List, Optional, Tuple
4
6
 
5
7
 
6
- def find_symbol_nodes(code: str, symbols: Iterable[str]) -> Iterable[AST]:
8
+ def find_symbol_nodes(
9
+ code: str, symbols: Iterable[str]
10
+ ) -> List[Tuple[AST, Optional[str]]]:
7
11
  "Returns ast Nodes matching symbols"
12
+ # list of (AST, None-or-class-name)
8
13
  matches = []
9
14
  module = parse(code)
10
15
  for node in module.body:
16
+ if not isinstance(node, (ClassDef, FunctionDef)):
17
+ continue
11
18
  name = getattr(node, "name", None)
12
19
  if match(name, symbols):
13
- matches.append(node)
20
+ matches.append((node, None))
21
+ # If it's a class search its methods too
22
+ if isinstance(node, ClassDef):
23
+ for child in node.body:
24
+ if isinstance(child, FunctionDef):
25
+ qualified_name = f"{name}.{child.name}"
26
+ if match(qualified_name, symbols):
27
+ matches.append((child, name))
28
+
14
29
  return matches
15
30
 
16
31
 
17
- def code_for_node(code: str, node: AST) -> Tuple[str, int]:
32
+ def code_for_node(
33
+ code: str, node: AST, class_name: str, signatures: bool
34
+ ) -> Tuple[str, int]:
18
35
  "Returns the code for a given node"
19
36
  lines = code.split("\n")
20
- # If the node has decorator_list, include those too
21
- if getattr(node, "decorator_list", None):
22
- start = node.decorator_list[0].lineno - 1
37
+ start = None
38
+ end = None
39
+ if signatures:
40
+ if isinstance(node, FunctionDef):
41
+ definition, lineno = function_definition(node), node.lineno
42
+ if class_name:
43
+ definition = " " + definition
44
+ return definition, lineno
45
+ elif isinstance(node, ClassDef):
46
+ return class_definition(node), node.lineno
47
+ else:
48
+ # Not a function or class, fall back on just the line
49
+ start = node.lineno - 1
50
+ end = node.lineno
23
51
  else:
24
- start = node.lineno - 1
25
- end = node.end_lineno
26
- return "\n".join(lines[start:end]), start + 1
52
+ # If the node has decorator_list, include those too
53
+ if getattr(node, "decorator_list", None):
54
+ start = node.decorator_list[0].lineno - 1
55
+ else:
56
+ start = node.lineno - 1
57
+ end = node.end_lineno
58
+ output = "\n".join(lines[start:end])
59
+ # If it's in a class, indent it 4 spaces
60
+ return output, start + 1
27
61
 
28
62
 
29
63
  def match(name: str, symbols: Iterable[str]) -> bool:
30
64
  "Returns True if name matches any of the symbols, resolving wildcards"
31
65
  if name is None:
32
66
  return False
33
- for symbol in symbols:
34
- if "*" not in symbol:
35
- if name == symbol:
67
+ for search in symbols:
68
+ if "*" not in search:
69
+ # Exact matches only
70
+ if name == search:
36
71
  return True
72
+ elif search.count(".") == 1:
73
+ # wildcards are supported either side of the dot
74
+ if "." in name:
75
+ class_match, method_match = search.split(".")
76
+ class_name, method_name = name.split(".")
77
+ if fnmatch.fnmatch(class_name, class_match) and fnmatch.fnmatch(
78
+ method_name, method_match
79
+ ):
80
+ return True
37
81
  else:
38
- if fnmatch.fnmatch(name, symbol):
82
+ if fnmatch.fnmatch(name, search) and "." not in name:
39
83
  return True
40
84
 
41
85
  return False
86
+
87
+
88
+ def function_definition(function_node: AST):
89
+ function_name = function_node.name
90
+
91
+ all_args = [
92
+ *function_node.args.posonlyargs,
93
+ *function_node.args.args,
94
+ *function_node.args.kwonlyargs,
95
+ ]
96
+
97
+ # For position only args like "def foo(a, /, b, c)"
98
+ # we can look at the length of args.posonlyargs to see
99
+ # if any are set and, if so, at what index the `/` should go
100
+ position_of_slash = len(function_node.args.posonlyargs)
101
+
102
+ # For func_keyword_only_args(a, *, b, c) the length of
103
+ # the kwonlyargs tells us how many spaces back from the
104
+ # end the star should be displayed
105
+ position_of_star = len(all_args) - len(function_node.args.kwonlyargs)
106
+
107
+ # function_node.args.defaults may have defaults
108
+ # corresponding to function_node.args.args - but
109
+ # if defaults has 2 and args has 3 then those
110
+ # defaults correspond to the last two args
111
+ defaults = [None] * (len(all_args) - len(function_node.args.defaults))
112
+ defaults.extend(literal_eval(default) for default in function_node.args.defaults)
113
+
114
+ arguments = []
115
+
116
+ for i, (arg, default) in enumerate(zip_longest(all_args, defaults)):
117
+ if position_of_slash and i == position_of_slash:
118
+ arguments.append("/")
119
+ if position_of_star and i == position_of_star:
120
+ arguments.append("*")
121
+ if getattr(arg.annotation, "id", None):
122
+ arg_str = f"{arg.arg}: {arg.annotation.id}"
123
+ else:
124
+ arg_str = arg.arg
125
+
126
+ if default:
127
+ arg_str = f"{arg_str}={default}"
128
+
129
+ arguments.append(arg_str)
130
+
131
+ if function_node.args.vararg:
132
+ arguments.append(f"*{function_node.args.vararg.arg}")
133
+
134
+ if function_node.args.kwarg:
135
+ arguments.append(f"**{function_node.args.kwarg.arg}")
136
+
137
+ arguments_str = ", ".join(arguments)
138
+
139
+ return_annotation = ""
140
+ if function_node.returns:
141
+ if hasattr(function_node.returns, "id"):
142
+ return_annotation = f" -> {function_node.returns.id}"
143
+ elif function_node.returns.value is None:
144
+ # None shows as returns.value is None
145
+ return_annotation = " -> None"
146
+
147
+ return f"def {function_name}({arguments_str}){return_annotation}"
148
+
149
+
150
+ def class_definition(class_def):
151
+ # Base classes
152
+ base_classes = []
153
+ for base in class_def.bases:
154
+ if getattr(base, "id", None):
155
+ base_classes.append(base.id)
156
+ base_classes_str = ", ".join(base_classes)
157
+
158
+ # Keywords (including metaclass)
159
+ keywords = {k.arg: getattr(k.value, "id", str(k.value)) for k in class_def.keywords}
160
+ metaclass = keywords.pop("metaclass", None)
161
+ keyword_str = ", ".join([f"{k}=..." for k in keywords])
162
+
163
+ if base_classes_str and keyword_str:
164
+ signature = f"{base_classes_str}, {keyword_str}"
165
+ elif base_classes_str:
166
+ signature = base_classes_str
167
+ elif keyword_str:
168
+ signature = keyword_str
169
+ else:
170
+ signature = ""
171
+
172
+ if metaclass:
173
+ sep = ", " if signature else ""
174
+ signature = f"{signature}{sep}metaclass={metaclass}"
175
+
176
+ if signature:
177
+ signature = f"({signature})"
178
+
179
+ class_definition = f"class {class_def.name}{signature}"
180
+
181
+ return class_definition
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: symbex
3
- Version: 0.2
3
+ Version: 0.3
4
4
  Summary: Find the Python code for specified symbols
5
5
  Home-page: https://github.com/simonw/symbex
6
6
  Author: Simon Willison
@@ -15,6 +15,7 @@ Requires-Dist: click
15
15
  Provides-Extra: test
16
16
  Requires-Dist: pytest ; extra == 'test'
17
17
  Requires-Dist: pytest-icdiff ; extra == 'test'
18
+ Requires-Dist: cogapp ; extra == 'test'
18
19
 
19
20
  # symbex
20
21
 
@@ -48,6 +49,20 @@ Wildcards are supported - to search for every `test_` function run this (note th
48
49
  ```bash
49
50
  symbex 'test_*'
50
51
  ```
52
+ To search for methods within classes, use `class.method` notation:
53
+ ```bash
54
+ symbex Entry.get_absolute_url
55
+ ```
56
+ Wildcards are supported here as well:
57
+ ```bash
58
+ symbex 'Entry.*'
59
+ symbex '*.get_absolute_url'
60
+ symbex '*.get_*'
61
+ ```
62
+ Or to view every method of every class:
63
+ ```bash
64
+ symbex '*.*'
65
+ ```
51
66
  To search within a specific file, pass that file using the `-f` option. You can pass this more than once to search multiple files.
52
67
 
53
68
  ```bash
@@ -57,8 +72,15 @@ To search within a specific directory and all of its subdirectories, use the `-d
57
72
  ```bash
58
73
  symbex Database -d ~/projects/datasette
59
74
  ```
60
-
61
- ## Example output
75
+ If `symbex` encounters any Python code that it cannot parse, it will print a warning message and continue searching:
76
+ ```
77
+ # Syntax error in path/badcode.py: expected ':' (<unknown>, line 1)
78
+ ```
79
+ Pass `--silent` to suppress these warnings:
80
+ ```bash
81
+ symbex MyClass --silent
82
+ ```
83
+ ### Example output
62
84
 
63
85
  In a fresh checkout of [Datasette](https://github.com/simonw/datasette) I ran this command:
64
86
 
@@ -87,6 +109,50 @@ class PatternPortfolioView(View):
87
109
  )
88
110
  )
89
111
  ```
112
+ ### Just the signatures
113
+
114
+ The `-s/--signatures` option will list just the signatures of the functions and classes, for example:
115
+ ```bash
116
+ symbex -s -d symbex
117
+ ```
118
+
119
+ <!-- [[[cog
120
+ import cog
121
+ from click.testing import CliRunner
122
+ import pathlib
123
+ from symbex.cli import cli
124
+
125
+ path = pathlib.Path("symbex").resolve()
126
+ runner = CliRunner()
127
+ result = runner.invoke(cli, ["-s", "-d", str(path)])
128
+ # Need a consistent sort order
129
+ chunks = result.stdout.strip().split("\n\n")
130
+ chunks.sort()
131
+ cog.out(
132
+ "```\n{}\n```\n".format("\n\n".join(chunks))
133
+ )
134
+ ]]] -->
135
+ ```
136
+ # File: symbex/cli.py Line: 37
137
+ def cli(symbols, files, directories, signatures, silent)
138
+
139
+ # File: symbex/lib.py Line: 150
140
+ def class_definition(class_def)
141
+
142
+ # File: symbex/lib.py Line: 32
143
+ def code_for_node(code: str, node: AST, class_name: str, signatures: bool)
144
+
145
+ # File: symbex/lib.py Line: 63
146
+ def match(name: str, symbols) -> bool
147
+
148
+ # File: symbex/lib.py Line: 8
149
+ def find_symbol_nodes(code: str, symbols)
150
+
151
+ # File: symbex/lib.py Line: 88
152
+ def function_definition(function_node: AST)
153
+ ```
154
+ <!-- [[[end]]] -->
155
+ This can be combined with other options, or you can run `symbex -s` to see every symbol in the current directory and its subdirectories.
90
156
 
91
157
  ## Using with LLM
92
158
 
@@ -0,0 +1,10 @@
1
+ symbex/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ symbex/__main__.py,sha256=8hDtWlaFZK24KhfNq_ZKgtXqYHsDQDetukOCMlsbW0Q,59
3
+ symbex/cli.py,sha256=tWzT49q-fPN34HQH2sEFHBV3MXFKy9J5eh2EzOo5_CA,2857
4
+ symbex/lib.py,sha256=q8WhjbPLiXuGnbD3scwCvJrqVvs-RHAMbBEXIYyFqeI,6121
5
+ symbex-0.3.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
6
+ symbex-0.3.dist-info/METADATA,sha256=Yn6vp7j1nWfTn_GYWXmeWhD7NFXHw2OxI3YnpMRVvew,5693
7
+ symbex-0.3.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
8
+ symbex-0.3.dist-info/entry_points.txt,sha256=YgMSEfEGqNMHM9RysFObH8lkQKVZKyymKLnXbVue_Uk,42
9
+ symbex-0.3.dist-info/top_level.txt,sha256=qwle8HjAaYgpdMIHlJcTcN4gaG4wmDqUvkt54beTBTs,7
10
+ symbex-0.3.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- symbex/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- symbex/__main__.py,sha256=8hDtWlaFZK24KhfNq_ZKgtXqYHsDQDetukOCMlsbW0Q,59
3
- symbex/cli.py,sha256=tJhsnbWEiIN9LSZGGcqrmpY5U9TgISvknXsMCpijlSs,1770
4
- symbex/lib.py,sha256=1YViD5fJ_NP1Y32LTFM7o8UCTzUiz3yTldVvJb8owgs,1190
5
- symbex-0.2.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
6
- symbex-0.2.dist-info/METADATA,sha256=QMsejWdFVPSvRODCyd09SRl_mcH0dLvXk3tOX2GNbts,3917
7
- symbex-0.2.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
8
- symbex-0.2.dist-info/entry_points.txt,sha256=YgMSEfEGqNMHM9RysFObH8lkQKVZKyymKLnXbVue_Uk,42
9
- symbex-0.2.dist-info/top_level.txt,sha256=qwle8HjAaYgpdMIHlJcTcN4gaG4wmDqUvkt54beTBTs,7
10
- symbex-0.2.dist-info/RECORD,,
File without changes
File without changes