symbex 0.2__tar.gz → 0.3__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -45,6 +45,20 @@ Wildcards are supported - to search for every `test_` function run this (note th
45
45
  ```bash
46
46
  symbex 'test_*'
47
47
  ```
48
+ To search for methods within classes, use `class.method` notation:
49
+ ```bash
50
+ symbex Entry.get_absolute_url
51
+ ```
52
+ Wildcards are supported here as well:
53
+ ```bash
54
+ symbex 'Entry.*'
55
+ symbex '*.get_absolute_url'
56
+ symbex '*.get_*'
57
+ ```
58
+ Or to view every method of every class:
59
+ ```bash
60
+ symbex '*.*'
61
+ ```
48
62
  To search within a specific file, pass that file using the `-f` option. You can pass this more than once to search multiple files.
49
63
 
50
64
  ```bash
@@ -54,8 +68,15 @@ To search within a specific directory and all of its subdirectories, use the `-d
54
68
  ```bash
55
69
  symbex Database -d ~/projects/datasette
56
70
  ```
57
-
58
- ## Example output
71
+ If `symbex` encounters any Python code that it cannot parse, it will print a warning message and continue searching:
72
+ ```
73
+ # Syntax error in path/badcode.py: expected ':' (<unknown>, line 1)
74
+ ```
75
+ Pass `--silent` to suppress these warnings:
76
+ ```bash
77
+ symbex MyClass --silent
78
+ ```
79
+ ### Example output
59
80
 
60
81
  In a fresh checkout of [Datasette](https://github.com/simonw/datasette) I ran this command:
61
82
 
@@ -84,6 +105,50 @@ class PatternPortfolioView(View):
84
105
  )
85
106
  )
86
107
  ```
108
+ ### Just the signatures
109
+
110
+ The `-s/--signatures` option will list just the signatures of the functions and classes, for example:
111
+ ```bash
112
+ symbex -s -d symbex
113
+ ```
114
+
115
+ <!-- [[[cog
116
+ import cog
117
+ from click.testing import CliRunner
118
+ import pathlib
119
+ from symbex.cli import cli
120
+
121
+ path = pathlib.Path("symbex").resolve()
122
+ runner = CliRunner()
123
+ result = runner.invoke(cli, ["-s", "-d", str(path)])
124
+ # Need a consistent sort order
125
+ chunks = result.stdout.strip().split("\n\n")
126
+ chunks.sort()
127
+ cog.out(
128
+ "```\n{}\n```\n".format("\n\n".join(chunks))
129
+ )
130
+ ]]] -->
131
+ ```
132
+ # File: symbex/cli.py Line: 37
133
+ def cli(symbols, files, directories, signatures, silent)
134
+
135
+ # File: symbex/lib.py Line: 150
136
+ def class_definition(class_def)
137
+
138
+ # File: symbex/lib.py Line: 32
139
+ def code_for_node(code: str, node: AST, class_name: str, signatures: bool)
140
+
141
+ # File: symbex/lib.py Line: 63
142
+ def match(name: str, symbols) -> bool
143
+
144
+ # File: symbex/lib.py Line: 8
145
+ def find_symbol_nodes(code: str, symbols)
146
+
147
+ # File: symbex/lib.py Line: 88
148
+ def function_definition(function_node: AST)
149
+ ```
150
+ <!-- [[[end]]] -->
151
+ This can be combined with other options, or you can run `symbex -s` to see every symbol in the current directory and its subdirectories.
87
152
 
88
153
  ## Using with LLM
89
154
 
@@ -30,6 +30,20 @@ Wildcards are supported - to search for every `test_` function run this (note th
30
30
  ```bash
31
31
  symbex 'test_*'
32
32
  ```
33
+ To search for methods within classes, use `class.method` notation:
34
+ ```bash
35
+ symbex Entry.get_absolute_url
36
+ ```
37
+ Wildcards are supported here as well:
38
+ ```bash
39
+ symbex 'Entry.*'
40
+ symbex '*.get_absolute_url'
41
+ symbex '*.get_*'
42
+ ```
43
+ Or to view every method of every class:
44
+ ```bash
45
+ symbex '*.*'
46
+ ```
33
47
  To search within a specific file, pass that file using the `-f` option. You can pass this more than once to search multiple files.
34
48
 
35
49
  ```bash
@@ -39,8 +53,15 @@ To search within a specific directory and all of its subdirectories, use the `-d
39
53
  ```bash
40
54
  symbex Database -d ~/projects/datasette
41
55
  ```
42
-
43
- ## Example output
56
+ If `symbex` encounters any Python code that it cannot parse, it will print a warning message and continue searching:
57
+ ```
58
+ # Syntax error in path/badcode.py: expected ':' (<unknown>, line 1)
59
+ ```
60
+ Pass `--silent` to suppress these warnings:
61
+ ```bash
62
+ symbex MyClass --silent
63
+ ```
64
+ ### Example output
44
65
 
45
66
  In a fresh checkout of [Datasette](https://github.com/simonw/datasette) I ran this command:
46
67
 
@@ -69,6 +90,50 @@ class PatternPortfolioView(View):
69
90
  )
70
91
  )
71
92
  ```
93
+ ### Just the signatures
94
+
95
+ The `-s/--signatures` option will list just the signatures of the functions and classes, for example:
96
+ ```bash
97
+ symbex -s -d symbex
98
+ ```
99
+
100
+ <!-- [[[cog
101
+ import cog
102
+ from click.testing import CliRunner
103
+ import pathlib
104
+ from symbex.cli import cli
105
+
106
+ path = pathlib.Path("symbex").resolve()
107
+ runner = CliRunner()
108
+ result = runner.invoke(cli, ["-s", "-d", str(path)])
109
+ # Need a consistent sort order
110
+ chunks = result.stdout.strip().split("\n\n")
111
+ chunks.sort()
112
+ cog.out(
113
+ "```\n{}\n```\n".format("\n\n".join(chunks))
114
+ )
115
+ ]]] -->
116
+ ```
117
+ # File: symbex/cli.py Line: 37
118
+ def cli(symbols, files, directories, signatures, silent)
119
+
120
+ # File: symbex/lib.py Line: 150
121
+ def class_definition(class_def)
122
+
123
+ # File: symbex/lib.py Line: 32
124
+ def code_for_node(code: str, node: AST, class_name: str, signatures: bool)
125
+
126
+ # File: symbex/lib.py Line: 63
127
+ def match(name: str, symbols) -> bool
128
+
129
+ # File: symbex/lib.py Line: 8
130
+ def find_symbol_nodes(code: str, symbols)
131
+
132
+ # File: symbex/lib.py Line: 88
133
+ def function_definition(function_node: AST)
134
+ ```
135
+ <!-- [[[end]]] -->
136
+ This can be combined with other options, or you can run `symbex -s` to see every symbol in the current directory and its subdirectories.
72
137
 
73
138
  ## Using with LLM
74
139
 
@@ -1,7 +1,7 @@
1
1
  from setuptools import setup
2
2
  import os
3
3
 
4
- VERSION = "0.2"
4
+ VERSION = "0.3"
5
5
 
6
6
 
7
7
  def get_long_description():
@@ -32,6 +32,6 @@ setup(
32
32
  symbex=symbex.cli:cli
33
33
  """,
34
34
  install_requires=["click"],
35
- extras_require={"test": ["pytest", "pytest-icdiff"]},
35
+ extras_require={"test": ["pytest", "pytest-icdiff", "cogapp"]},
36
36
  python_requires=">=3.8",
37
37
  )
@@ -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()
@@ -0,0 +1,181 @@
1
+ import fnmatch
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
6
+
7
+
8
+ def find_symbol_nodes(
9
+ code: str, symbols: Iterable[str]
10
+ ) -> List[Tuple[AST, Optional[str]]]:
11
+ "Returns ast Nodes matching symbols"
12
+ # list of (AST, None-or-class-name)
13
+ matches = []
14
+ module = parse(code)
15
+ for node in module.body:
16
+ if not isinstance(node, (ClassDef, FunctionDef)):
17
+ continue
18
+ name = getattr(node, "name", None)
19
+ if match(name, symbols):
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
+
29
+ return matches
30
+
31
+
32
+ def code_for_node(
33
+ code: str, node: AST, class_name: str, signatures: bool
34
+ ) -> Tuple[str, int]:
35
+ "Returns the code for a given node"
36
+ lines = code.split("\n")
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
51
+ else:
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
61
+
62
+
63
+ def match(name: str, symbols: Iterable[str]) -> bool:
64
+ "Returns True if name matches any of the symbols, resolving wildcards"
65
+ if name is None:
66
+ return False
67
+ for search in symbols:
68
+ if "*" not in search:
69
+ # Exact matches only
70
+ if name == search:
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
81
+ else:
82
+ if fnmatch.fnmatch(name, search) and "." not in name:
83
+ return True
84
+
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
@@ -45,6 +45,20 @@ Wildcards are supported - to search for every `test_` function run this (note th
45
45
  ```bash
46
46
  symbex 'test_*'
47
47
  ```
48
+ To search for methods within classes, use `class.method` notation:
49
+ ```bash
50
+ symbex Entry.get_absolute_url
51
+ ```
52
+ Wildcards are supported here as well:
53
+ ```bash
54
+ symbex 'Entry.*'
55
+ symbex '*.get_absolute_url'
56
+ symbex '*.get_*'
57
+ ```
58
+ Or to view every method of every class:
59
+ ```bash
60
+ symbex '*.*'
61
+ ```
48
62
  To search within a specific file, pass that file using the `-f` option. You can pass this more than once to search multiple files.
49
63
 
50
64
  ```bash
@@ -54,8 +68,15 @@ To search within a specific directory and all of its subdirectories, use the `-d
54
68
  ```bash
55
69
  symbex Database -d ~/projects/datasette
56
70
  ```
57
-
58
- ## Example output
71
+ If `symbex` encounters any Python code that it cannot parse, it will print a warning message and continue searching:
72
+ ```
73
+ # Syntax error in path/badcode.py: expected ':' (<unknown>, line 1)
74
+ ```
75
+ Pass `--silent` to suppress these warnings:
76
+ ```bash
77
+ symbex MyClass --silent
78
+ ```
79
+ ### Example output
59
80
 
60
81
  In a fresh checkout of [Datasette](https://github.com/simonw/datasette) I ran this command:
61
82
 
@@ -84,6 +105,50 @@ class PatternPortfolioView(View):
84
105
  )
85
106
  )
86
107
  ```
108
+ ### Just the signatures
109
+
110
+ The `-s/--signatures` option will list just the signatures of the functions and classes, for example:
111
+ ```bash
112
+ symbex -s -d symbex
113
+ ```
114
+
115
+ <!-- [[[cog
116
+ import cog
117
+ from click.testing import CliRunner
118
+ import pathlib
119
+ from symbex.cli import cli
120
+
121
+ path = pathlib.Path("symbex").resolve()
122
+ runner = CliRunner()
123
+ result = runner.invoke(cli, ["-s", "-d", str(path)])
124
+ # Need a consistent sort order
125
+ chunks = result.stdout.strip().split("\n\n")
126
+ chunks.sort()
127
+ cog.out(
128
+ "```\n{}\n```\n".format("\n\n".join(chunks))
129
+ )
130
+ ]]] -->
131
+ ```
132
+ # File: symbex/cli.py Line: 37
133
+ def cli(symbols, files, directories, signatures, silent)
134
+
135
+ # File: symbex/lib.py Line: 150
136
+ def class_definition(class_def)
137
+
138
+ # File: symbex/lib.py Line: 32
139
+ def code_for_node(code: str, node: AST, class_name: str, signatures: bool)
140
+
141
+ # File: symbex/lib.py Line: 63
142
+ def match(name: str, symbols) -> bool
143
+
144
+ # File: symbex/lib.py Line: 8
145
+ def find_symbol_nodes(code: str, symbols)
146
+
147
+ # File: symbex/lib.py Line: 88
148
+ def function_definition(function_node: AST)
149
+ ```
150
+ <!-- [[[end]]] -->
151
+ This can be combined with other options, or you can run `symbex -s` to see every symbol in the current directory and its subdirectories.
87
152
 
88
153
  ## Using with LLM
89
154
 
@@ -11,4 +11,5 @@ symbex.egg-info/dependency_links.txt
11
11
  symbex.egg-info/entry_points.txt
12
12
  symbex.egg-info/requires.txt
13
13
  symbex.egg-info/top_level.txt
14
- tests/test_symbex.py
14
+ tests/test_symbex.py
15
+ tests/test_symbols.py
@@ -3,3 +3,4 @@ click
3
3
  [test]
4
4
  pytest
5
5
  pytest-icdiff
6
+ cogapp
@@ -0,0 +1,143 @@
1
+ import pathlib
2
+ import pytest
3
+ import textwrap
4
+ from click.testing import CliRunner
5
+
6
+ from symbex.cli import cli
7
+
8
+
9
+ @pytest.fixture
10
+ def directory_full_of_code(tmpdir):
11
+ for path, content in (
12
+ ("foo.py", "def foo1():\n pass\n\n@decorated\ndef foo2():\n pass\n\n"),
13
+ ("bar.py", "class BarClass:\n pass\n\n"),
14
+ ("nested/baz.py", "def baz():\n pass\n\n"),
15
+ ("nested/error.py", "def baz_error()" + "bug:\n pass\n\n"),
16
+ (
17
+ "methods.py",
18
+ textwrap.dedent(
19
+ """
20
+ class MyClass:
21
+ def __init__(self, a):
22
+ self.a = a
23
+
24
+ def method1(self, a=1):
25
+ pass
26
+ """
27
+ ),
28
+ ),
29
+ ):
30
+ p = pathlib.Path(tmpdir / path)
31
+ p.parent.mkdir(parents=True, exist_ok=True)
32
+ p.write_text(content, "utf-8")
33
+ return tmpdir
34
+
35
+
36
+ @pytest.mark.parametrize(
37
+ "args,expected",
38
+ (
39
+ (["foo1", "--silent"], "# File: foo.py Line: 1\ndef foo1():\n pass\n\n"),
40
+ (
41
+ ["foo*", "--silent"],
42
+ "# File: foo.py Line: 1\ndef foo1():\n pass\n\n# File: foo.py Line: 4\n@decorated\ndef foo2():\n pass\n\n",
43
+ ),
44
+ (
45
+ ["BarClass", "--silent"],
46
+ "# File: bar.py Line: 1\nclass BarClass:\n pass\n\n",
47
+ ),
48
+ (
49
+ ["baz", "--silent"],
50
+ "# File: nested/baz.py Line: 1\ndef baz():\n pass\n\n",
51
+ ),
52
+ # The -f option
53
+ (
54
+ ["baz", "-f", "nested/baz.py", "--silent"],
55
+ "# File: nested/baz.py Line: 1\ndef baz():\n pass\n\n",
56
+ ),
57
+ # The -d option
58
+ (
59
+ ["baz", "-d", "nested", "--silent"],
60
+ "# File: nested/baz.py Line: 1\ndef baz():\n pass\n\n",
61
+ ),
62
+ # Classes
63
+ (
64
+ ["MyClass", "--silent"],
65
+ "# File: methods.py Line: 2\n"
66
+ "class MyClass:\n"
67
+ " def __init__(self, a):\n"
68
+ " self.a = a\n"
69
+ "\n"
70
+ " def method1(self, a=1):\n"
71
+ " pass\n"
72
+ "\n",
73
+ ),
74
+ (
75
+ ["MyClass.__init__", "--silent"],
76
+ "# File: methods.py Class: MyClass Line: 3\n"
77
+ " def __init__(self, a):\n"
78
+ " self.a = a\n"
79
+ "\n",
80
+ ),
81
+ (
82
+ ["MyClass.*", "--silent"],
83
+ "# File: methods.py Class: MyClass Line: 3\n"
84
+ " def __init__(self, a):\n"
85
+ " self.a = a\n"
86
+ "\n"
87
+ "# File: methods.py Class: MyClass Line: 6\n"
88
+ " def method1(self, a=1):\n"
89
+ " pass\n"
90
+ "\n",
91
+ ),
92
+ (
93
+ ["*.method*", "--silent"],
94
+ "# File: methods.py Class: MyClass Line: 6\n"
95
+ " def method1(self, a=1):\n"
96
+ " pass\n"
97
+ "\n",
98
+ ),
99
+ ),
100
+ )
101
+ def test_fixture(directory_full_of_code, monkeypatch, args, expected):
102
+ runner = CliRunner()
103
+ monkeypatch.chdir(directory_full_of_code)
104
+ result = runner.invoke(cli, args, catch_exceptions=False)
105
+ assert result.exit_code == 0
106
+ assert result.stdout == expected
107
+
108
+
109
+ @pytest.mark.parametrize(
110
+ "args,expected",
111
+ (
112
+ (
113
+ ["foo*", "--silent"],
114
+ "# File: foo.py Line: 1\n"
115
+ "def foo1()\n"
116
+ "\n"
117
+ "# File: foo.py Line: 5\n"
118
+ "def foo2()",
119
+ ),
120
+ (["BarClass", "--silent"], "# File: bar.py Line: 1\n" "class BarClass"),
121
+ (["baz", "--silent"], ("# File: nested/baz.py Line: 1\n" "def baz()")),
122
+ ),
123
+ )
124
+ def test_symbex_symbols(directory_full_of_code, monkeypatch, args, expected):
125
+ runner = CliRunner()
126
+ monkeypatch.chdir(directory_full_of_code)
127
+ result = runner.invoke(cli, args + ["-s"], catch_exceptions=False)
128
+ assert result.exit_code == 0
129
+ # Here expected is just the first two lines
130
+ assert result.stdout.strip() == expected
131
+
132
+
133
+ def test_errors(directory_full_of_code, monkeypatch):
134
+ # Test without --silent to see errors
135
+ runner = CliRunner(mix_stderr=False)
136
+ monkeypatch.chdir(directory_full_of_code)
137
+ result = runner.invoke(cli, ["baz"], catch_exceptions=False)
138
+ assert result.exit_code == 0
139
+ assert result.stdout == (
140
+ "# File: nested/baz.py Line: 1\n" "def baz():\n" " pass\n\n"
141
+ )
142
+ # This differs between different Python versions
143
+ assert result.stderr.startswith("# Syntax error in nested/error.py:")
@@ -0,0 +1,47 @@
1
+ # Tests for "symbex -s", using content of example_code.py
2
+ import pathlib
3
+ import pytest
4
+ from click.testing import CliRunner
5
+
6
+ from symbex.cli import cli
7
+
8
+
9
+ @pytest.fixture
10
+ def symbols_text():
11
+ runner = CliRunner()
12
+ args = ["-s", "-f", str(pathlib.Path(__file__).parent / "example_symbols.py")]
13
+ result = runner.invoke(cli, args, catch_exceptions=False)
14
+ assert result.exit_code == 0
15
+ return result.stdout
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ "name,expected",
20
+ (
21
+ ("func_no_args", "def func_no_args()"),
22
+ ("func_positional_args", "def func_positional_args(a, b, c)"),
23
+ ("func_default_args", "def func_default_args(a, b=2, c=3)"),
24
+ ("func_arbitrary_positional_args", "def func_arbitrary_positional_args(*args)"),
25
+ ("func_arbitrary_keyword_args", "def func_arbitrary_keyword_args(**kwargs)"),
26
+ ("func_arbitrary_args", "def func_arbitrary_args(*args, **kwargs)"),
27
+ ("func_positional_only_args", "def func_positional_only_args(a, /, b, c)"),
28
+ ("func_keyword_only_args", "def func_keyword_only_args(a, *, b, c)"),
29
+ ("func_type_annotations", "def func_type_annotations(a: int, b: str) -> bool"),
30
+ ("ClassNoBase", "class ClassNoBase"),
31
+ ("ClassSingleBase", "class ClassSingleBase(int)"),
32
+ ("ClassMultipleBase", "class ClassMultipleBase(int, str)"),
33
+ ("ClassWithMeta", "class ClassWithMeta(metaclass=type)"),
34
+ ),
35
+ )
36
+ def test_symbols(name, expected, symbols_text):
37
+ # For error reporting try and find the relevant bit
38
+ likely_line = [
39
+ line
40
+ for line in symbols_text.split("\n")
41
+ if (f"{name}(" in line or line.startswith(f"class {name}"))
42
+ ][0]
43
+ assert expected in symbols_text, "\nexpected:\t{}\ngot:\t\t{}".format(
44
+ expected, likely_line
45
+ )
46
+ # Special case to ensure we don't get ClassNoBase()
47
+ assert "ClassNoBase()" not in symbols_text
symbex-0.2/symbex/lib.py DELETED
@@ -1,41 +0,0 @@
1
- import fnmatch
2
- from ast import parse, AST
3
- from typing import Iterable, Tuple
4
-
5
-
6
- def find_symbol_nodes(code: str, symbols: Iterable[str]) -> Iterable[AST]:
7
- "Returns ast Nodes matching symbols"
8
- matches = []
9
- module = parse(code)
10
- for node in module.body:
11
- name = getattr(node, "name", None)
12
- if match(name, symbols):
13
- matches.append(node)
14
- return matches
15
-
16
-
17
- def code_for_node(code: str, node: AST) -> Tuple[str, int]:
18
- "Returns the code for a given node"
19
- 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
23
- else:
24
- start = node.lineno - 1
25
- end = node.end_lineno
26
- return "\n".join(lines[start:end]), start + 1
27
-
28
-
29
- def match(name: str, symbols: Iterable[str]) -> bool:
30
- "Returns True if name matches any of the symbols, resolving wildcards"
31
- if name is None:
32
- return False
33
- for symbol in symbols:
34
- if "*" not in symbol:
35
- if name == symbol:
36
- return True
37
- else:
38
- if fnmatch.fnmatch(name, symbol):
39
- return True
40
-
41
- return False
@@ -1,38 +0,0 @@
1
- import pathlib
2
- import pytest
3
- from click.testing import CliRunner
4
-
5
- from symbex.cli import cli
6
-
7
-
8
- @pytest.fixture
9
- def directory_full_of_code(tmpdir):
10
- for path, content in (
11
- ("foo.py", "def foo1():\n pass\n\n@decorated\ndef foo2():\n pass\n\n"),
12
- ("bar.py", "class BarClass:\n pass\n\n"),
13
- ("nested/baz.py", "def baz():\n pass\n\n"),
14
- ):
15
- p = pathlib.Path(tmpdir / path)
16
- p.parent.mkdir(parents=True, exist_ok=True)
17
- p.write_text(content, "utf-8")
18
- return tmpdir
19
-
20
-
21
- @pytest.mark.parametrize(
22
- "args,expected",
23
- (
24
- (["foo1"], "# File: foo.py Line: 1\ndef foo1():\n pass\n\n"),
25
- (
26
- ["foo*"],
27
- "# File: foo.py Line: 1\ndef foo1():\n pass\n\n# File: foo.py Line: 4\n@decorated\ndef foo2():\n pass\n\n",
28
- ),
29
- (["BarClass"], "# File: bar.py Line: 1\nclass BarClass:\n pass\n\n"),
30
- (["baz"], "# File: nested/baz.py Line: 1\ndef baz():\n pass\n\n"),
31
- ),
32
- )
33
- def test_fixture(directory_full_of_code, args, expected, monkeypatch):
34
- runner = CliRunner()
35
- monkeypatch.chdir(directory_full_of_code)
36
- result = runner.invoke(cli, args, catch_exceptions=False)
37
- assert result.exit_code == 0
38
- assert result.stdout == expected
File without changes
File without changes
File without changes
File without changes
File without changes