symbex 0.2__py3-none-any.whl → 0.3__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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