├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── pyq ├── __init__.py ├── astmatch.py └── pyq.py ├── setup.py ├── sizzle ├── __init__.py ├── match.py └── selector.py ├── test_cmd.py ├── test_pyq.py ├── test_sizzle.py └── testfiles ├── assign.py ├── attrs.py ├── calls.py ├── classes.py ├── cmd ├── .test_hidden_dir │ └── foo.py ├── cmd.py ├── file2.py ├── ignoredir │ ├── ignoredir2 │ │ └── test.py │ └── test.py └── notpyfile.txt ├── ids.py └── imports.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Current 4 | 5 | ### New features 6 | 7 | * Added `--expand` option that allows showing multiples matches in the same 8 | line 9 | 10 | ## 0.0.6 / 2016-02-26 11 | 12 | ### New features 13 | 14 | * Added type `attr` (eg. `attr#foo` will match `a.foo.bar` and `a.bar.foo`) 15 | * Added support for wildcards 16 | * Added special attribute `full` to match imports: `import[full=x.z]` will 17 | match `from x import y as z` 18 | * Added ability to match calls with certain `arg` or `kwarg` (eg. 19 | `call[arg=foo]` will match `bar(x, y, foo)`; `[kwarg=bar]` will match 20 | `foo(bar=1, z=2)` 21 | * Added `--ignore-dir` and `--no-recurse` options 22 | 23 | ### Bug fixed 24 | 25 | * Fixed bug when `node.body` is not an iterator (eg. lambdas) 26 | 27 | 28 | ## 0.0.5 / 2016-02-11 29 | 30 | ### New features 31 | 32 | * Added CHANGELOG.md 33 | * Added support for the type `assign` (eg. `assign#foo` will match `foo = 1`) 34 | * Changed `extends()` pseudo-selector to receive a selector as argument 35 | * Added support for the type `call` (eg. `call#foo` will match `foo(1)`) 36 | 37 | 38 | ### Bug fixes 39 | 40 | * Fixed bug when using `:extends()` without the type `class` specified 41 | * Fixed bug when trying to match `#name` with a node that doesn't have a name 42 | attribute 43 | 44 | 45 | ## 0.0.4 / 2016-02-10 46 | 47 | * Added `-l/--files` option to print only files names 48 | 49 | 50 | ## 0.0.3 / 2016-02-09 51 | 52 | * Added support for Python 2 53 | 54 | 55 | ## 0.0.2 / 2016-02-08 56 | 57 | * Changed package name from pyq to pyqtool, to avoid conflicts w/ existing 58 | packages 59 | 60 | 61 | ## 0.0.1 / 2016-02-07 62 | 63 | * Initial release 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Caio Ariede 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyq 2 | 3 | A command-line tool to search for Python code using jQuery-like selectors 4 | 5 | [![PyPI version](https://badge.fury.io/py/pyqtool.svg)](https://badge.fury.io/py/pyqtool) 6 | 7 | 8 | ## Installation 9 | 10 | pip install pyqtool 11 | 12 | **Notice:** As the tool is still under heavy development, you may see that some features are not yet available in the version distributed over PyPI. If you want to have a fresh taste, you can get it directly from source: 13 | 14 | pip install https://github.com/caioariede/pyq/archive/master.zip -U 15 | 16 | Please report any possible issues, we expect the master branch to be stable. 17 | 18 | 19 | ## Usage 20 | 21 | Usage: pyq3 [OPTIONS] SELECTOR [PATH]... 22 | 23 | Options: 24 | -l / --files Only print filenames containing matches. 25 | --ignore-dir TEXT Ignore directory. 26 | -n / --no-recurse No descending into subdirectories. 27 | -e / --expand Show multiple matches in the same line. 28 | --help Show this message and exit. 29 | 30 | The executable name will vary depending on the Python version: `pyq2` `pyq3` 31 | 32 | 33 | ## Available selectors 34 | 35 | ##### Type selectors 36 | 37 | | Name | Attributes | Additional notes | 38 | | ------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------- | 39 | | class | class `name` | | 40 | | def | def `name` | | 41 | | import | import `name`
import `name` as `name`
from `from` import `name` | It's also possible to match the full import using
the special attribute `full` | 42 | | assign | `name` [, `name` ...] = value | | 43 | | call | `name`(`arg`, `arg`, ..., `kwarg`=, `kwarg`=, ...) | | 44 | | attr | foo.`name`.`name` | | 45 | 46 | ##### ID/Name selector 47 | 48 | | Syntax | Applied to | 49 | | -------- | ---------------------------------------- | 50 | | #`name` | `class`, `def`, `assign`, `call`, `attr` | 51 | 52 | 53 | #### Attribute selectors 54 | 55 | | Syntax | Description | 56 | | ----------------- | ------------------------------------------ | 57 | | [`name`=`value`] | Attribute `name` is equal to `value` | 58 | | [`name`!=`value`] | Attribute `name` is not equal to `value` | 59 | | [`name`*=`value`] | Attribute `name` contains `value` | 60 | | [`name`^=`value`] | Attribute `name` starts with `value` | 61 | | [`name`$=`value`] | Attribute `name` endswith `value` | 62 | 63 | 64 | #### Pseudo selectors 65 | 66 | | Syntax | Applies to | Description | 67 | | --------------------- | ----------------- | -------------------------------------------------- | 68 | | :extends(`selector`) | `class` | Selects classes that its bases matches `selector` | 69 | | :has(`selector`) | _all_ | Selects everything that its body match `selector` | 70 | | :not(`selector`) | _all_ | Selects everything that do not match `selector` | 71 | 72 | #### Combinators 73 | 74 | | Syntax | Description | 75 | | --------------------- | -------------------------------------- | 76 | | `parent` > `child` | Select direct `child` from `parent` | 77 | | `parent` `descendant` | Selects all `descendant` from `parent` | 78 | 79 | 80 | ## Examples 81 | 82 | Search for classes that extends the `IntegerField` class: 83 | 84 | ```python 85 | ❯ pyq3 'class:extends(#IntegerField)' django/forms 86 | django/forms/fields.py:278 class FloatField(IntegerField): 87 | django/forms/fields.py:315 class DecimalField(IntegerField): 88 | ``` 89 | 90 | Search for classes with the name `FloatField`: 91 | 92 | ```python 93 | ❯ pyq3 'class[name=FloatField]' django/forms 94 | django/forms/fields.py:278 class FloatField(IntegerField): 95 | ``` 96 | 97 | Search for methods under the `FloatField` class: 98 | 99 | ```python 100 | ❯ pyq3 'class[name=FloatField] > def' django/forms 101 | django/forms/fields.py:283 def to_python(self, value): 102 | django/forms/fields.py:299 def validate(self, value): 103 | django/forms/fields.py:308 def widget_attrs(self, widget): 104 | ``` 105 | 106 | Search for methods whose name starts with `to` under the `FloatField` class: 107 | 108 | ```python 109 | ❯ pyq3 'class[name=FloatField] > def[name^=to]' django/forms 110 | django/forms/fields.py:283 def to_python(self, value): 111 | ``` 112 | 113 | Search for import statements importing `Counter`: 114 | 115 | ```python 116 | ❯ pyq3 'import[from=collections][name=Counter]' django/ 117 | django/apps/registry.py:5 from collections import Counter, OrderedDict, defaultdict 118 | django/template/utils.py:3 from collections import Counter, OrderedDict 119 | django/test/testcases.py:14 from collections import Counter 120 | ... 121 | ``` 122 | 123 | Search for classes without methods: 124 | 125 | ```python 126 | ❯ pyq3 'class:not(:has(> def))' django/core 127 | django/core/exceptions.py:8 class FieldDoesNotExist(Exception): 128 | django/core/exceptions.py:13 class DjangoRuntimeWarning(RuntimeWarning): 129 | django/core/exceptions.py:17 class AppRegistryNotReady(Exception): 130 | ... 131 | ``` 132 | -------------------------------------------------------------------------------- /pyq/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caioariede/pyq/29248ab285dfd43c5a634dc895021eacaf61d71f/pyq/__init__.py -------------------------------------------------------------------------------- /pyq/astmatch.py: -------------------------------------------------------------------------------- 1 | from sizzle.match import MatchEngine 2 | 3 | import ast 4 | import astor 5 | 6 | 7 | class ASTMatchEngine(MatchEngine): 8 | def __init__(self): 9 | super(ASTMatchEngine, self).__init__() 10 | self.register_pseudo('extends', self.pseudo_extends) 11 | 12 | def match(self, selector, filename): 13 | module = astor.parsefile(filename) 14 | for match in super(ASTMatchEngine, self).match(selector, module.body): 15 | lineno = match.lineno 16 | if isinstance(match, (ast.ClassDef, ast.FunctionDef)): 17 | for d in match.decorator_list: 18 | lineno += 1 19 | yield match, lineno 20 | 21 | @staticmethod 22 | def pseudo_extends(matcher, node, value): 23 | if not isinstance(node, ast.ClassDef): 24 | return False 25 | 26 | if not value: 27 | return node.bases == [] 28 | 29 | bases = node.bases 30 | selectors = value.split(',') 31 | 32 | for selector in selectors: 33 | matches = matcher.match_data( 34 | matcher.parse_selector(selector)[0], bases) 35 | if any(matches): 36 | return True 37 | 38 | def match_type(self, typ, node): 39 | if typ == 'class': 40 | return isinstance(node, ast.ClassDef) 41 | 42 | if typ == 'def': 43 | return isinstance(node, ast.FunctionDef) 44 | 45 | if typ == 'import': 46 | return isinstance(node, (ast.Import, ast.ImportFrom)) 47 | 48 | if typ == 'assign': 49 | return isinstance(node, ast.Assign) 50 | 51 | if typ == 'attr': 52 | return isinstance(node, ast.Attribute) 53 | 54 | if typ == 'call': 55 | if isinstance(node, ast.Call): 56 | return True 57 | 58 | # Python 2.x compatibility 59 | return hasattr(ast, 'Print') and isinstance(node, ast.Print) 60 | 61 | def match_id(self, id_, node): 62 | if isinstance(node, (ast.ClassDef, ast.FunctionDef)): 63 | return node.name == id_ 64 | 65 | if isinstance(node, ast.Name): 66 | return node.id == id_ 67 | 68 | if isinstance(node, ast.Attribute): 69 | return node.attr == id_ 70 | 71 | if isinstance(node, ast.Assign): 72 | for target in node.targets: 73 | if hasattr(target, 'id'): 74 | if target.id == id_: 75 | return True 76 | if hasattr(target, 'elts'): 77 | if id_ in self._extract_names_from_tuple(target): 78 | return True 79 | elif isinstance(target, ast.Subscript): 80 | if hasattr(target.value, 'id'): 81 | if target.value.id == id_: 82 | return True 83 | 84 | if isinstance(node, ast.Call): 85 | if isinstance(node.func, ast.Name) and node.func.id == id_: 86 | return True 87 | 88 | if id_ == 'print' \ 89 | and hasattr(ast, 'Print') and isinstance(node, ast.Print): 90 | # Python 2.x compatibility 91 | return True 92 | 93 | def match_attr(self, lft, op, rgt, node): 94 | values = [] 95 | 96 | if lft == 'from': 97 | if isinstance(node, ast.ImportFrom) and node.module: 98 | values.append(node.module) 99 | 100 | elif lft == 'full': 101 | if isinstance(node, (ast.Import, ast.ImportFrom)): 102 | module = '' 103 | if isinstance(node, ast.ImportFrom): 104 | if node.module: 105 | module = node.module + '.' 106 | 107 | for n in node.names: 108 | values.append(module + n.name) 109 | if n.asname: 110 | values.append(module + n.asname) 111 | 112 | elif lft == 'name': 113 | if isinstance(node, (ast.Import, ast.ImportFrom)): 114 | for alias in node.names: 115 | if alias.asname: 116 | values.append(alias.asname) 117 | values.append(alias.name) 118 | 119 | elif isinstance(node, ast.Call): 120 | if hasattr(node.func, 'id'): 121 | values.append(node.func.id) 122 | 123 | elif hasattr(ast, 'Print') and isinstance(node, ast.Print): 124 | values.append('print') 125 | 126 | elif isinstance(node, ast.Assign): 127 | for target in node.targets: 128 | if hasattr(target, 'id'): 129 | values.append(target.id) 130 | elif hasattr(target, 'elts'): 131 | values.extend(self._extract_names_from_tuple(target)) 132 | elif isinstance(target, ast.Subscript): 133 | if hasattr(target.value, 'id'): 134 | values.append(target.value.id) 135 | 136 | elif hasattr(node, lft): 137 | values.append(getattr(node, lft)) 138 | 139 | elif lft in ('kwarg', 'arg'): 140 | if isinstance(node, ast.Call): 141 | if lft == 'kwarg': 142 | values = [kw.arg for kw in node.keywords] 143 | elif lft == 'arg': 144 | values = [arg.id for arg in node.args] 145 | 146 | if op == '=': 147 | return any(value == rgt for value in values) 148 | 149 | if op == '!=': 150 | return any(value != rgt for value in values) 151 | 152 | if op == '*=': 153 | return any(rgt in value for value in values) 154 | 155 | if op == '^=': 156 | return any(value.startswith(rgt) for value in values) 157 | 158 | if op == '$=': 159 | return any(value.endswith(rgt) for value in values) 160 | 161 | raise Exception('Attribute operator {} not implemented'.format(op)) 162 | 163 | def iter_data(self, data): 164 | for node in data: 165 | for n in self.iter_node(node): 166 | yield n 167 | 168 | def iter_node(self, node): 169 | silence = (ast.Expr,) 170 | 171 | if not isinstance(node, silence): 172 | try: 173 | body = node.body 174 | 175 | # check if is iterable 176 | list(body) 177 | 178 | except TypeError: 179 | body = [node.body] 180 | 181 | except AttributeError: 182 | body = None 183 | 184 | yield node, body 185 | 186 | for attr in ('value', 'func', 'right', 'left'): 187 | if hasattr(node, attr): 188 | value = getattr(node, attr) 189 | # reversed is used here so matches are returned in the 190 | # sequence they are read, eg.: foo.bar.bang 191 | for n in reversed(list(self.iter_node(value))): 192 | yield n 193 | 194 | @classmethod 195 | def _extract_names_from_tuple(cls, tupl): 196 | r = [] 197 | for item in tupl.elts: 198 | if hasattr(item, 'elts'): 199 | r.extend(cls._extract_names_from_tuple(item)) 200 | else: 201 | r.append(item.id) 202 | return r 203 | -------------------------------------------------------------------------------- /pyq/pyq.py: -------------------------------------------------------------------------------- 1 | import click 2 | import os 3 | 4 | from .astmatch import ASTMatchEngine 5 | from pygments import highlight 6 | from pygments.lexers.python import PythonLexer 7 | from pygments.formatters.terminal import TerminalFormatter 8 | 9 | 10 | @click.command() 11 | @click.argument('selector') 12 | @click.option('-l/--files', is_flag=True, 13 | help='Only print filenames containing matches.') 14 | @click.option('--ignore-dir', multiple=True, 15 | help='Ignore directory.') 16 | @click.option('-n/--no-recurse', is_flag=True, default=False, 17 | help='No descending into subdirectories.') 18 | @click.option('-e/--expand', is_flag=True, default=False, 19 | help='Show multiple matches in the same line.') 20 | @click.argument('path', nargs=-1) 21 | @click.pass_context 22 | def main(ctx, selector, path, **opts): 23 | m = ASTMatchEngine() 24 | 25 | if len(path) == 0: 26 | path = ['.'] 27 | 28 | ignore_dir = (opts['ignore_dir'], not opts['n']) 29 | for fn in walk_files(ctx, path, ignore_dir): 30 | if fn.endswith('.py'): 31 | display_matches(m, selector, os.path.relpath(fn), opts) 32 | 33 | 34 | def walk_files(ctx, paths, ignore_dir): 35 | for i, p in enumerate(paths): 36 | p = click.format_filename(p) 37 | 38 | if p == '.' or os.path.isdir(p): 39 | for root, dirs, files in os.walk(p): 40 | if is_dir_ignored(root.lstrip('./'), *ignore_dir): 41 | continue 42 | for fn in files: 43 | yield os.path.join(root, fn) 44 | 45 | elif os.path.exists(p): 46 | yield p 47 | 48 | elif i == 0: 49 | ctx.fail('{}: No such file or directory'.format(p)) 50 | break 51 | 52 | 53 | def display_matches(m, selector, filename, opts): 54 | matches = matching_lines(m.match(selector, filename), filename) 55 | 56 | if opts.get('l'): 57 | files = {} 58 | for line, no, _ in matches: 59 | if opts.get('l'): 60 | if filename not in files: 61 | click.echo(filename) 62 | # do not repeat files 63 | files[filename] = True 64 | 65 | else: 66 | lines = {} 67 | for line, no, col in matches: 68 | text = highlight(line.strip(), PythonLexer(), TerminalFormatter()) 69 | if not opts['e']: 70 | if no not in lines: 71 | lines[no] = True 72 | click.echo('{}:{} {}'.format(filename, no, text), 73 | nl=False) 74 | else: 75 | click.echo('{}:{}:{} {}'.format(filename, no, col, text), 76 | nl=False) 77 | 78 | 79 | def matching_lines(matches, filename): 80 | fp = None 81 | for match, lineno in matches: 82 | if fp is None: 83 | fp = open(filename, 'rb') 84 | else: 85 | fp.seek(0) 86 | 87 | i = 1 88 | while True: 89 | line = fp.readline() 90 | 91 | if not line: 92 | break 93 | 94 | if i == lineno: 95 | text = line.decode('utf-8') 96 | yield text, lineno, match.col_offset 97 | break 98 | 99 | i += 1 100 | 101 | if fp is not None: 102 | fp.close() 103 | 104 | 105 | def is_dir_ignored(path, ignore_dir, recurse): 106 | path = path.split(os.sep) 107 | if not recurse: 108 | return path[0] in ignore_dir 109 | for p in path: 110 | if p in ignore_dir: 111 | return True 112 | return False 113 | 114 | 115 | if __name__ == '__main__': 116 | main() 117 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | import sys 4 | 5 | 6 | VERSION = '0.0.6' 7 | PYVERSION = sys.version_info.major 8 | 9 | 10 | setup( 11 | name='pyqtool', 12 | version=VERSION, 13 | description="Search Python code using jQuery-like selectors", 14 | author="Caio Ariede", 15 | author_email="caio.ariede@gmail.com", 16 | url="http://github.com/caioariede/pyq", 17 | license="MIT", 18 | zip_safe=False, 19 | platforms=["any"], 20 | packages=['pyq', 'sizzle'], 21 | entry_points={ 22 | 'console_scripts': ['pyq{} = pyq.pyq:main'.format(PYVERSION)], 23 | }, 24 | classifiers=[ 25 | "Intended Audience :: Developers", 26 | "Operating System :: OS Independent", 27 | "License :: OSI Approved :: MIT License", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3.4", 31 | ], 32 | include_package_data=True, 33 | install_requires=[ 34 | 'click==6.2', 35 | 'Pygments==2.1', 36 | 'regex==2016.1.10', 37 | 'astor==0.5', 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /sizzle/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caioariede/pyq/29248ab285dfd43c5a634dc895021eacaf61d71f/sizzle/__init__.py -------------------------------------------------------------------------------- /sizzle/match.py: -------------------------------------------------------------------------------- 1 | from .selector import Selector 2 | 3 | 4 | class MatchEngine(object): 5 | pseudo_fns = {} 6 | selector_class = Selector 7 | 8 | def __init__(self): 9 | self.register_pseudo('not', self.pseudo_not) 10 | self.register_pseudo('has', self.pseudo_has) 11 | 12 | def register_pseudo(self, name, fn): 13 | self.pseudo_fns[name] = fn 14 | 15 | @staticmethod 16 | def pseudo_not(matcher, node, value): 17 | return not matcher.match_node(matcher.parse_selector(value)[0], node) 18 | 19 | @staticmethod 20 | def pseudo_has(matcher, node, value): 21 | for node, body in matcher.iter_data([node]): 22 | if body: 23 | return any( 24 | matcher.match_data(matcher.parse_selector(value)[0], body)) 25 | 26 | def parse_selector(self, selector): 27 | return self.selector_class.parse(selector) 28 | 29 | def match(self, selector, data): 30 | selectors = self.parse_selector(selector) 31 | nodeids = {} 32 | for selector in selectors: 33 | for node in self.match_data(selector, data): 34 | nodeid = id(node) 35 | if nodeid not in nodeids: 36 | nodeids[nodeid] = None 37 | yield node 38 | 39 | def match_data(self, selector, data): 40 | for node, body in self._iter_data(data): 41 | match = self.match_node(selector, node) 42 | 43 | if match: 44 | next_selector = selector.next_selector 45 | if next_selector: 46 | if body: 47 | for node in self.match_data(next_selector, body): 48 | yield node 49 | else: 50 | yield node 51 | 52 | if body and not selector.combinator == self.selector_class.CHILD: 53 | for node in self.match_data(selector, body): 54 | yield node 55 | 56 | def match_node(self, selector, node): 57 | match = all(self.match_rules(selector, node)) 58 | 59 | if match and selector.attrs: 60 | match &= all(self.match_attrs(selector, node)) 61 | 62 | if match and selector.pseudos: 63 | match &= all(self.match_pseudos(selector, node)) 64 | 65 | return match 66 | 67 | def match_rules(self, selector, node): 68 | if selector.typ: 69 | yield self.match_type(selector.typ, node) 70 | 71 | if selector.id_: 72 | yield self.match_id(selector.id_, node) 73 | 74 | def match_attrs(self, selector, node): 75 | for a in selector.attrs: 76 | lft, op, rgt = a 77 | yield self.match_attr(lft, op, rgt, node) 78 | 79 | def match_pseudos(self, selector, d): 80 | for p in selector.pseudos: 81 | name, value = p 82 | if name not in self.pseudo_fns: 83 | raise Exception('Selector not implemented: {}'.format(name)) 84 | yield self.pseudo_fns[name](self, d, value) 85 | 86 | def _iter_data(self, data): 87 | for tupl in self.iter_data(data): 88 | if len(tupl) != 2: 89 | raise Exception( 90 | 'The iter_data method must yield pair tuples containing ' 91 | 'the node and its body (empty if not available)') 92 | yield tupl 93 | 94 | def match_type(self, typ, node): 95 | raise NotImplementedError 96 | 97 | def match_id(self, id_, node): 98 | raise NotImplementedError 99 | 100 | def match_attr(self, lft, op, rgt, no): 101 | raise NotImplementedError 102 | 103 | def iter_data(self, data): 104 | raise NotImplementedError 105 | -------------------------------------------------------------------------------- /sizzle/selector.py: -------------------------------------------------------------------------------- 1 | import regex 2 | 3 | from collections import namedtuple 4 | 5 | 6 | Attr = namedtuple('Attr', 'lft op rgt') 7 | Pseudo = namedtuple('Pseudo', 'name value') 8 | 9 | 10 | class Selector(object): 11 | DESCENDANT = ' ' 12 | CHILD = '>' 13 | SIBLING = '~' 14 | ADJACENT = '+' 15 | NOT_SET = None 16 | 17 | class RE(object): 18 | id = r'_?[A-Za-z0-9_]+|_' 19 | ws = r'[\x20\t\r\n\f]*' 20 | comma = '^{ws},{ws}'.format(ws=ws) 21 | combinator = r'^{ws}([>+~ ]){ws}'.format(ws=ws) 22 | 23 | type_selector = '({id})'.format(id=id) 24 | id_selector = '#({id})'.format(id=id) 25 | class_selector = r'\.(' + id + ')' 26 | pseudo_selector = r'(:({id})\(([^()]+|(?1)?)\))'.format(id=id) 27 | attr_selector = r'\[{ws}({id}){ws}([*^$|!~]?=)(.*?)\]'.format( 28 | id=id, ws=ws) 29 | 30 | selector = '(?:(?:{typ})?({id}|{cls}|{pseudo}|{attr})+|{typ})'.format( 31 | typ=id, id=id_selector, cls=class_selector, pseudo=pseudo_selector, 32 | attr=attr_selector) 33 | 34 | def __init__(self, name, combinator=None): 35 | self.name = name 36 | self.combinator = combinator 37 | self.next_selector = None 38 | 39 | selector_patterns = { 40 | 'types': self.RE.type_selector, 41 | 'ids': self.RE.id_selector, 42 | 'classes': self.RE.class_selector, 43 | 'pseudos': self.RE.pseudo_selector, 44 | 'attrs': self.RE.attr_selector, 45 | } 46 | 47 | matches = {} 48 | 49 | while True: 50 | pattern_matched = False 51 | for key, pattern in selector_patterns.items(): 52 | match = regex.search(r'^{}'.format(pattern), name) 53 | if match: 54 | i, pos = match.span() 55 | if key not in matches: 56 | matches[key] = [] 57 | matches[key].append(match.groups()) 58 | name = name[pos:] 59 | pattern_matched = True 60 | if not pattern_matched: 61 | break 62 | 63 | self.typ = None 64 | for types in matches.pop('types', []): 65 | self.typ = types[0] 66 | 67 | self.id_ = None 68 | for ids in matches.pop('ids', []): 69 | self.id_ = ids[0] 70 | 71 | self.classes = [a[0] for a in matches.pop('classes', [])] 72 | 73 | self.attrs = [ 74 | Attr(l, o, r.strip()) 75 | for l, o, r in matches.pop('attrs', []) 76 | ] 77 | self.pseudos = [ 78 | Pseudo(*a[1:]) 79 | for a in matches.pop('pseudos', []) 80 | ] 81 | 82 | def __repr__(self): 83 | return 'Selector <{}>'.format(self.name) 84 | 85 | @classmethod 86 | def parse(cls, string): 87 | selectors = [] 88 | 89 | combinator = None 90 | prev_selector = None 91 | 92 | while True: 93 | match = regex.search(cls.RE.comma, string) 94 | if match: 95 | # skip comma 96 | _, pos = match.span() 97 | string = string[pos:] 98 | continue 99 | 100 | match = regex.search(cls.RE.combinator, string) 101 | if match: 102 | _, pos = match.span() 103 | combinator = string[:pos].strip() 104 | string = string[pos:] 105 | else: 106 | combinator = None 107 | 108 | match = regex.search(cls.RE.selector, string) 109 | if match: 110 | _, pos = match.span() 111 | seltext = string[:pos] 112 | string = string[pos:] 113 | selector = cls(seltext, combinator=combinator) 114 | if combinator is not None and prev_selector: 115 | prev_selector.next_selector = prev_selector = selector 116 | else: 117 | prev_selector = selector 118 | selectors.append(selector) 119 | continue 120 | 121 | break 122 | 123 | return selectors 124 | -------------------------------------------------------------------------------- /test_cmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from click.testing import CliRunner 5 | from pyq.pyq import main 6 | 7 | 8 | def pjoin(*path): 9 | return os.path.join(os.path.dirname(__file__), *path) 10 | 11 | 12 | class TestASTMatchEngine(unittest.TestCase): 13 | def setUp(self): 14 | self.runner = CliRunner() 15 | 16 | # chdir to testfiles/cmd 17 | self.currentdir = os.getcwd() 18 | os.chdir(pjoin('testfiles', 'cmd')) 19 | 20 | def tearDown(self): 21 | # restore cwd 22 | os.chdir(self.currentdir) 23 | 24 | def invoke(self, *args): 25 | return self.runner.invoke(*args, catch_exceptions=False) 26 | 27 | def test_noargs(self): 28 | result = self.invoke(main, []) 29 | 30 | self.assertNotEqual(result.exit_code, 0) 31 | self.assertIn('Missing argument "selector"', result.output_bytes) 32 | 33 | def test_nodir(self): 34 | result = self.invoke(main, ['def']) 35 | output = result.output_bytes.splitlines() 36 | 37 | self.assertEqual(result.exit_code, 0) 38 | self.assertEqual(len(output), 4) 39 | self.assertEqual(output[0], 'cmd.py:7 def foo(self):') 40 | self.assertEqual(output[1], 'cmd.py:11 def baz(arg1, arg2):') 41 | self.assertEqual(output[2], 'file2.py:1 def hello():') 42 | self.assertEqual(output[3], '.test_hidden_dir/foo.py:1 def bar():') 43 | 44 | def test_notpyfile(self): 45 | result = self.invoke(main, ['def', 'notpyfile.txt']) 46 | 47 | self.assertEqual(result.exit_code, 0) 48 | 49 | def test_file(self): 50 | result = self.invoke(main, ['> def', 'cmd.py']) 51 | output = result.output_bytes.splitlines() 52 | 53 | self.assertEqual(result.exit_code, 0) 54 | self.assertEqual(output[0], 'cmd.py:11 def baz(arg1, arg2):') 55 | 56 | def test_wildcard(self): 57 | result = self.invoke(main, ['def', 'cmd.py', 'file2.py', 58 | 'notpyfile.txt', 'nofile.unknown']) 59 | output = result.output_bytes.splitlines() 60 | 61 | self.assertEqual(result.exit_code, 0) 62 | self.assertEqual(len(output), 3) 63 | self.assertEqual(output[0], 'cmd.py:7 def foo(self):') 64 | self.assertEqual(output[1], 'cmd.py:11 def baz(arg1, arg2):') 65 | self.assertEqual(output[2], 'file2.py:1 def hello():') 66 | 67 | def test_print_filenames(self): 68 | result = self.invoke(main, ['-l', 'def']) 69 | output = result.output_bytes.splitlines() 70 | 71 | self.assertEqual(result.exit_code, 0) 72 | self.assertEqual(output[0], 'cmd.py') 73 | self.assertEqual(output[1], 'file2.py') 74 | 75 | def test_ignoredir(self): 76 | r = self.invoke(main, ['-l', 'class']) 77 | output = r.output_bytes.splitlines() 78 | 79 | self.assertEqual(r.exit_code, 0) 80 | self.assertTrue(any('ignoredir' in p for p in output)) 81 | 82 | r = self.invoke(main, ['-l', 'class', '--ignore-dir', 'ignoredir']) 83 | output = r.output_bytes.splitlines() 84 | 85 | self.assertEqual(r.exit_code, 0) 86 | self.assertFalse(any('ignoredir' in p for p in output)) 87 | 88 | def test_ignoredir_norecurse(self): 89 | r = self.invoke(main, ['-l', 'class', '--ignore-dir', 'ignoredir2']) 90 | output = r.output_bytes.splitlines() 91 | 92 | self.assertEqual(r.exit_code, 0) 93 | self.assertFalse(any('ignoredir2' in p for p in output)) 94 | 95 | r = self.invoke(main, ['-l', 'class', '--ignore-dir', 'ignoredir2', 96 | '-n']) 97 | output = r.output_bytes.splitlines() 98 | 99 | self.assertEqual(r.exit_code, 0) 100 | self.assertTrue(any('ignoredir2' in p for p in output)) 101 | 102 | def test_expand_matches(self): 103 | r1 = self.invoke(main, ['call', 'cmd.py']) 104 | r2 = self.invoke(main, ['-e', 'call', 'cmd.py']) 105 | r3 = self.invoke(main, ['-el', 'call', 'cmd.py']) 106 | 107 | output1 = r1.output_bytes.splitlines() 108 | output2 = r2.output_bytes.splitlines() 109 | output3 = r3.output_bytes.splitlines() 110 | 111 | self.assertEqual(r1.exit_code, 0) 112 | self.assertEqual(len(output1), 1) 113 | self.assertEqual(output1[0], 'cmd.py:15 foo() | bar()') 114 | 115 | self.assertEqual(r2.exit_code, 0) 116 | self.assertEqual(len(output2), 2) 117 | self.assertEqual(output2[0], 'cmd.py:15:0 foo() | bar()') 118 | self.assertEqual(output2[1], 'cmd.py:15:8 foo() | bar()') 119 | 120 | self.assertEqual(r3.exit_code, 0) 121 | self.assertEqual(len(output3), 1) 122 | self.assertEqual(output3[0], 'cmd.py') 123 | 124 | 125 | if __name__ == '__main__': 126 | unittest.main() 127 | -------------------------------------------------------------------------------- /test_pyq.py: -------------------------------------------------------------------------------- 1 | from pyq.astmatch import ASTMatchEngine 2 | 3 | import unittest 4 | import os.path 5 | import ast 6 | 7 | 8 | class TestASTMatchEngine(unittest.TestCase): 9 | def setUp(self): 10 | self.m = ASTMatchEngine() 11 | 12 | def filepath(self, filename): 13 | return os.path.join(os.path.dirname(__file__), 'testfiles', filename) 14 | 15 | def test_classes(self): 16 | matches = list(self.m.match('class', self.filepath('classes.py'))) 17 | self.assertEqual(len(matches), 4) 18 | 19 | # check instances 20 | self.assertIsInstance(matches[0][0], ast.ClassDef) 21 | self.assertIsInstance(matches[1][0], ast.ClassDef) 22 | self.assertIsInstance(matches[2][0], ast.ClassDef) 23 | self.assertIsInstance(matches[3][0], ast.ClassDef) 24 | 25 | # check lines 26 | self.assertEqual(matches[0][1], 1) 27 | self.assertEqual(matches[1][1], 9) 28 | self.assertEqual(matches[2][1], 13) 29 | self.assertEqual(matches[3][1], 14) 30 | 31 | def test_classes_with_specific_method(self): 32 | matches1 = list(self.m.match('class:not(:has(def))', 33 | self.filepath('classes.py'))) 34 | 35 | matches2 = list(self.m.match('class:has(def[name=bar])', 36 | self.filepath('classes.py'))) 37 | 38 | matches3 = list(self.m.match('class:has(> def)', 39 | self.filepath('classes.py'))) 40 | 41 | self.assertEqual(len(matches1), 1) 42 | self.assertIsInstance(matches1[0][0], ast.ClassDef) 43 | 44 | self.assertEqual(len(matches2), 3) 45 | self.assertIsInstance(matches2[0][0], ast.ClassDef) 46 | self.assertIsInstance(matches2[1][0], ast.ClassDef) 47 | self.assertIsInstance(matches2[2][0], ast.ClassDef) 48 | 49 | self.assertEqual(len(matches3), 2) 50 | self.assertIsInstance(matches3[0][0], ast.ClassDef) 51 | self.assertIsInstance(matches3[1][0], ast.ClassDef) 52 | self.assertEqual(matches3[0][1], 1) 53 | self.assertEqual(matches3[1][1], 14) 54 | 55 | def test_methods(self): 56 | matches = list(self.m.match('class def', self.filepath('classes.py'))) 57 | self.assertEqual(len(matches), 3) 58 | 59 | # check instances 60 | self.assertIsInstance(matches[0][0], ast.FunctionDef) 61 | self.assertIsInstance(matches[1][0], ast.FunctionDef) 62 | self.assertIsInstance(matches[2][0], ast.FunctionDef) 63 | 64 | # check lines 65 | self.assertEqual(matches[0][1], 2) 66 | self.assertEqual(matches[1][1], 5) 67 | self.assertEqual(matches[2][1], 15) 68 | 69 | def test_methods_by_name(self): 70 | matches1 = list(self.m.match('class def[name=baz]', 71 | self.filepath('classes.py'))) 72 | matches2 = list(self.m.match('class def[name!=baz]', 73 | self.filepath('classes.py'))) 74 | 75 | self.assertEqual(len(matches1), 1) 76 | self.assertEqual(matches1[0][1], 5) 77 | 78 | self.assertEqual(len(matches2), 2) 79 | self.assertEqual(matches2[0][1], 2) 80 | self.assertEqual(matches2[1][1], 15) 81 | 82 | def test_import(self): 83 | matches = list(self.m.match('import', self.filepath('imports.py'))) 84 | 85 | self.assertEqual(len(matches), 6) 86 | 87 | # check instances 88 | self.assertIsInstance(matches[0][0], ast.ImportFrom) 89 | self.assertIsInstance(matches[1][0], ast.ImportFrom) 90 | self.assertIsInstance(matches[2][0], ast.ImportFrom) 91 | self.assertIsInstance(matches[3][0], ast.ImportFrom) 92 | self.assertIsInstance(matches[4][0], ast.Import) 93 | self.assertIsInstance(matches[5][0], ast.Import) 94 | 95 | def test_import_from(self): 96 | matches = list(self.m.match('import[from=foo]', 97 | self.filepath('imports.py'))) 98 | 99 | self.assertEqual(len(matches), 2) 100 | 101 | # check instances 102 | self.assertIsInstance(matches[0][0], ast.ImportFrom) 103 | self.assertIsInstance(matches[1][0], ast.ImportFrom) 104 | 105 | # check lines 106 | self.assertEqual(matches[0][1], 1) 107 | self.assertEqual(matches[1][1], 2) 108 | 109 | def test_import_not_from(self): 110 | matches = list(self.m.match('import:not([from^=foo])', 111 | self.filepath('imports.py'))) 112 | 113 | self.assertEqual(len(matches), 3) 114 | 115 | # check instances 116 | self.assertIsInstance(matches[0][0], ast.ImportFrom) 117 | self.assertIsInstance(matches[1][0], ast.Import) 118 | self.assertIsInstance(matches[2][0], ast.Import) 119 | 120 | def test_import_name(self): 121 | matches = list(self.m.match('import[name=example2]', 122 | self.filepath('imports.py'))) 123 | 124 | self.assertEqual(len(matches), 1) 125 | 126 | matches = list(self.m.match('import[name=bar], import[name=bar2]', 127 | self.filepath('imports.py'))) 128 | 129 | self.assertEqual(len(matches), 2) 130 | 131 | def test_import_multiple(self): 132 | matches1 = list(self.m.match('import[name=xyz]', 133 | self.filepath('imports.py'))) 134 | 135 | matches2 = list(self.m.match('import[name=xyz][name=bar2]', 136 | self.filepath('imports.py'))) 137 | 138 | matches3 = list(self.m.match('import[name=foo.baz]', 139 | self.filepath('imports.py'))) 140 | 141 | matches4 = list(self.m.match('import[name^=foo]', 142 | self.filepath('imports.py'))) 143 | 144 | matches5 = list(self.m.match('import[from^=foo]', 145 | self.filepath('imports.py'))) 146 | 147 | self.assertEqual(len(matches1), 1) 148 | self.assertEqual(len(matches2), 1) 149 | self.assertEqual(len(matches3), 1) 150 | self.assertEqual(len(matches4), 1) 151 | self.assertEqual(len(matches5), 3) 152 | 153 | def test_import_special_attr(self): 154 | matches1 = list(self.m.match('import[full=foo.bar2]', 155 | self.filepath('imports.py'))) 156 | 157 | matches2 = list(self.m.match('import[full=foo.xyz]', 158 | self.filepath('imports.py'))) 159 | 160 | self.assertEqual(len(matches1), 1) 161 | self.assertEqual(len(matches2), 1) 162 | 163 | def test_match_id(self): 164 | matches = list(self.m.match('#foo,#bar', self.filepath('ids.py'))) 165 | 166 | self.assertEqual(len(matches), 5) 167 | 168 | def test_assign(self): 169 | matches1 = list(self.m.match('assign', self.filepath('assign.py'))) 170 | matches2 = list(self.m.match('assign#foo', self.filepath('assign.py'))) 171 | matches3 = list( 172 | self.m.match('assign[name=bar]', self.filepath('assign.py'))) 173 | matches4 = list(self.m.match('assign#b', self.filepath('assign.py'))) 174 | matches5 = list( 175 | self.m.match('#abc,[name=abc]', self.filepath('assign.py'))) 176 | 177 | self.assertEqual(len(matches1), 6) 178 | 179 | self.assertEqual(len(matches2), 1) 180 | self.assertEqual(matches2[0][1], 1) 181 | 182 | self.assertEqual(len(matches3), 1) 183 | self.assertEqual(matches3[0][1], 2) 184 | 185 | self.assertEqual(len(matches4), 1) 186 | self.assertEqual(matches4[0][1], 4) 187 | 188 | self.assertEqual(len(matches5), 1) 189 | self.assertEqual(matches5[0][1], 5) 190 | 191 | def test_calls(self): 192 | matches1 = list( 193 | self.m.match('[name=print]', self.filepath('calls.py'))) 194 | matches2 = list(self.m.match('call#foo', self.filepath('calls.py'))) 195 | matches3 = list(self.m.match('call', self.filepath('calls.py'))) 196 | matches4 = list(self.m.match('[name=foo]', self.filepath('calls.py'))) 197 | 198 | self.assertEqual(len(matches1), 1) 199 | self.assertEqual(len(matches2), 2) 200 | self.assertEqual(len(matches3), 5) 201 | self.assertEqual(len(matches4), 2) 202 | 203 | def test_call_arg_kwarg(self): 204 | matches1 = list(self.m.match('call[kwarg=a]', 205 | self.filepath('calls.py'))) 206 | matches2 = list(self.m.match('call[kwarg=x]', 207 | self.filepath('calls.py'))) 208 | matches3 = list(self.m.match('call[arg=bar]', 209 | self.filepath('calls.py'))) 210 | matches4 = list(self.m.match('[arg=bang]', self.filepath('calls.py'))) 211 | 212 | self.assertEqual(len(matches1), 1) 213 | self.assertEqual(len(matches2), 2) 214 | self.assertEqual(len(matches3), 2) 215 | self.assertEqual(len(matches4), 2) 216 | 217 | self.assertIsInstance(matches1[0][0], ast.Call) 218 | self.assertIsInstance(matches2[0][0], ast.Call) 219 | self.assertIsInstance(matches2[1][0], ast.Call) 220 | self.assertIsInstance(matches3[0][0], ast.Call) 221 | self.assertIsInstance(matches3[1][0], ast.Call) 222 | self.assertIsInstance(matches4[0][0], ast.Call) 223 | 224 | def test_attrs(self): 225 | matches1 = list(self.m.match('#bang', self.filepath('attrs.py'))) 226 | matches2 = list(self.m.match('attr#z', self.filepath('attrs.py'))) 227 | matches3 = list(self.m.match('attr', self.filepath('attrs.py'))) 228 | matches4 = list(self.m.match('#y', self.filepath('attrs.py'))) 229 | 230 | self.assertEqual(len(matches1), 1) 231 | self.assertEqual(len(matches2), 1) 232 | self.assertEqual(len(matches3), 4) 233 | self.assertEqual(len(matches4), 1) 234 | 235 | self.assertEqual(matches3[0][0].attr, 'bar') 236 | self.assertEqual(matches3[1][0].attr, 'bang') 237 | self.assertEqual(matches3[2][0].attr, 'y') 238 | self.assertEqual(matches3[3][0].attr, 'z') 239 | 240 | def test_pseudo_extends(self): 241 | matches1 = list(self.m.match(':extends(#object)', 242 | self.filepath('classes.py'))) 243 | 244 | matches2 = list(self.m.match(':extends()', 245 | self.filepath('classes.py'))) 246 | 247 | matches3 = list(self.m.match(':extends(#Unknown)', 248 | self.filepath('classes.py'))) 249 | 250 | matches4 = list(self.m.match(':extends(#object, #X)', 251 | self.filepath('classes.py'))) 252 | 253 | matches5 = list(self.m.match(':extends(#X):extends(#Y)', 254 | self.filepath('classes.py'))) 255 | 256 | matches6 = list(self.m.match(':extends(#foo)', 257 | self.filepath('classes.py'))) 258 | 259 | matches7 = list(self.m.match(':extends(attr#B)', 260 | self.filepath('classes.py'))) 261 | 262 | matches8 = list(self.m.match(':extends(#A):extends(attr#B)', 263 | self.filepath('classes.py'))) 264 | 265 | self.assertEqual(len(matches1), 3) 266 | self.assertEqual(len(matches2), 1) 267 | self.assertEqual(len(matches3), 0) 268 | self.assertEqual(len(matches4), 3) 269 | self.assertEqual(len(matches5), 1) 270 | self.assertEqual(len(matches6), 1) 271 | self.assertEqual(len(matches7), 1) 272 | self.assertEqual(len(matches8), 1) 273 | 274 | 275 | unittest.main(failfast=True) 276 | -------------------------------------------------------------------------------- /test_sizzle.py: -------------------------------------------------------------------------------- 1 | from sizzle.selector import Selector 2 | from sizzle.match import MatchEngine 3 | 4 | import unittest 5 | 6 | 7 | class TestSObjs(unittest.TestCase): 8 | def test_type_selector(self): 9 | sobjs = Selector.parse('class') 10 | 11 | self.assertEqual(len(sobjs), 1) 12 | self.assertEqual(sobjs[0].name, 'class') 13 | 14 | def test_multiple_selectors(self): 15 | sobjs = Selector.parse('class, def') 16 | 17 | self.assertEqual(len(sobjs), 2) 18 | self.assertEqual(sobjs[0].name, 'class') 19 | self.assertEqual(sobjs[1].name, 'def') 20 | 21 | def test_composed_selector(self): 22 | sobjs = Selector.parse('class.foo.bar') 23 | 24 | self.assertEqual(len(sobjs), 1) 25 | self.assertIsInstance(sobjs[0], Selector) 26 | self.assertEqual(sobjs[0].typ, 'class') 27 | self.assertEqual(sobjs[0].classes, ['foo', 'bar']) 28 | 29 | def test_class_selector(self): 30 | sobjs = Selector.parse('.class > def') 31 | 32 | self.assertEqual(len(sobjs), 1) 33 | self.assertEqual(sobjs[0].name, '.class') 34 | 35 | def test_class_selector_child(self): 36 | sobjs = Selector.parse('.class > def') 37 | 38 | self.assertEqual(len(sobjs), 1) 39 | self.assertEqual(sobjs[0].name, '.class') 40 | self.assertIsNotNone(sobjs[0].next_selector) 41 | 42 | self.assertEqual(sobjs[0].next_selector.name, 'def') 43 | self.assertEqual(sobjs[0].next_selector.combinator, 44 | Selector.CHILD) 45 | 46 | def test_child_selector(self): 47 | sobjs = Selector.parse('class > def') 48 | 49 | self.assertEqual(len(sobjs), 1) 50 | self.assertIsInstance(sobjs[0], Selector) 51 | self.assertEqual(sobjs[0].name, 'class') 52 | self.assertIsNotNone(sobjs[0].next_selector) 53 | 54 | self.assertEqual(sobjs[0].next_selector.name, 'def') 55 | self.assertEqual(sobjs[0].next_selector.combinator, Selector.CHILD) 56 | 57 | def test_deep_selector(self): 58 | sobjs = Selector.parse('class > def foo > bar') 59 | 60 | self.assertEqual(len(sobjs), 1) 61 | self.assertEqual(sobjs[0].name, 'class') 62 | self.assertEqual(sobjs[0].next_selector.name, 'def') 63 | self.assertEqual(sobjs[0].next_selector.next_selector.name, 'foo') 64 | self.assertEqual( 65 | sobjs[0].next_selector.next_selector.next_selector.name, 'bar') 66 | 67 | self.assertEqual(sobjs[0].next_selector.name, 'def') 68 | self.assertEqual(sobjs[0].next_selector.combinator, Selector.CHILD) 69 | 70 | def test_nonchild_selector(self): 71 | sobjs = Selector.parse('class def') 72 | 73 | self.assertEqual(len(sobjs), 1) 74 | self.assertEqual(sobjs[0].name, 'class') 75 | self.assertIsNotNone(sobjs[0].next_selector) 76 | 77 | self.assertEqual(sobjs[0].next_selector.name, 'def') 78 | 79 | def test_pseudos(self): 80 | sobjs = Selector.parse(':not(1)') 81 | 82 | self.assertEqual(len(sobjs), 1) 83 | self.assertEqual(len(sobjs[0].pseudos), 1) 84 | self.assertEqual(sobjs[0].pseudos[0].name, 'not') 85 | self.assertEqual(sobjs[0].pseudos[0].value, '1') 86 | 87 | def test_pseudo_empty(self): 88 | sobjs = Selector.parse(':not()') 89 | 90 | self.assertEqual(len(sobjs), 1) 91 | self.assertEqual(len(sobjs[0].pseudos), 1) 92 | self.assertEqual(sobjs[0].pseudos[0].name, 'not') 93 | self.assertEqual(sobjs[0].pseudos[0].value, '') 94 | 95 | def test_nested_pseudos(self): 96 | sobjs = Selector.parse(':not(:not(1))') 97 | 98 | self.assertEqual(len(sobjs), 1) 99 | self.assertEqual(len(sobjs[0].pseudos), 1) 100 | self.assertEqual(sobjs[0].pseudos[0].name, 'not') 101 | self.assertEqual(sobjs[0].pseudos[0].value, ':not(1)') 102 | 103 | def test_compound_pseudos(self): 104 | sobjs = Selector.parse(':not(1):not(2)') 105 | 106 | self.assertEqual(len(sobjs), 1) 107 | self.assertEqual(len(sobjs[0].pseudos), 2) 108 | self.assertEqual(sobjs[0].pseudos[0].name, 'not') 109 | self.assertEqual(sobjs[0].pseudos[0].value, '1') 110 | self.assertEqual(sobjs[0].pseudos[1].name, 'not') 111 | self.assertEqual(sobjs[0].pseudos[1].value, '2') 112 | 113 | def test_attrs(self): 114 | sobjs = Selector.parse('[name=1]') 115 | 116 | self.assertEqual(len(sobjs), 1) 117 | self.assertEqual(len(sobjs[0].attrs), 1) 118 | self.assertEqual(sobjs[0].attrs[0].lft, 'name') 119 | self.assertEqual(sobjs[0].attrs[0].op, '=') 120 | self.assertEqual(sobjs[0].attrs[0].rgt, '1') 121 | 122 | def test_attrs_empty(self): 123 | sobjs = Selector.parse('[name!=]') 124 | 125 | self.assertEqual(len(sobjs), 1) 126 | self.assertEqual(len(sobjs[0].attrs), 1) 127 | self.assertEqual(sobjs[0].attrs[0].lft, 'name') 128 | self.assertEqual(sobjs[0].attrs[0].op, '!=') 129 | self.assertEqual(sobjs[0].attrs[0].rgt, '') 130 | 131 | def test_attrs_whitespace(self): 132 | sobjs = Selector.parse('[ name = 1 ]') 133 | 134 | self.assertEqual(len(sobjs), 1) 135 | self.assertEqual(len(sobjs[0].attrs), 1) 136 | self.assertEqual(sobjs[0].attrs[0].lft, 'name') 137 | self.assertEqual(sobjs[0].attrs[0].op, '=') 138 | self.assertEqual(sobjs[0].attrs[0].rgt, '1') 139 | 140 | def test_deep_attrs(self): 141 | sobjs = Selector.parse('[name=1] > [name=2]') 142 | 143 | self.assertEqual(len(sobjs), 1) 144 | self.assertEqual(len(sobjs[0].attrs), 1) 145 | self.assertEqual(sobjs[0].attrs[0].lft, 'name') 146 | self.assertEqual(sobjs[0].attrs[0].op, '=') 147 | self.assertEqual(sobjs[0].attrs[0].rgt, '1') 148 | self.assertEqual(sobjs[0].next_selector.attrs[0].lft, 'name') 149 | self.assertEqual(sobjs[0].next_selector.attrs[0].op, '=') 150 | self.assertEqual(sobjs[0].next_selector.attrs[0].rgt, '2') 151 | 152 | def test_attr_in_pseudo(self): 153 | sobjs = Selector.parse(':not([name=1])') 154 | 155 | self.assertEqual(len(sobjs), 1) 156 | self.assertEqual(len(sobjs[0].attrs), 0) 157 | self.assertEqual(len(sobjs[0].pseudos), 1) 158 | self.assertEqual(sobjs[0].pseudos[0].name, 'not') 159 | self.assertEqual(sobjs[0].pseudos[0].value, '[name=1]') 160 | 161 | 162 | class CustomMatchEngine(MatchEngine): 163 | def __init__(self): 164 | super(CustomMatchEngine, self).__init__() 165 | self.register_pseudo('extends', self.pseudo_extends) 166 | 167 | @staticmethod 168 | def pseudo_extends(matcher, node, value): 169 | if not value: 170 | return hasattr(node, 'extends') and not node.extends 171 | return value in getattr(node, 'extends', []) 172 | 173 | def match_type(self, typ, node): 174 | if typ == 'class': 175 | cls = self.CLS 176 | elif typ == 'def': 177 | cls = self.DEF 178 | 179 | return isinstance(node, cls) 180 | 181 | def match_id(self, id_, node): 182 | return node.name == id_ 183 | 184 | def iter_data(self, data): 185 | for node in data: 186 | yield node, getattr(node, 'body', None) 187 | 188 | 189 | class TestCustomMatcher(unittest.TestCase): 190 | from collections import namedtuple 191 | 192 | CLS = namedtuple('ClassDef', ('name', 'extends', 'body')) 193 | DEF = namedtuple('FunctionDef', ('name',)) 194 | 195 | def setUp(self): 196 | self.data = [ 197 | self.CLS('Test', ['object'], [ 198 | self.DEF('foo'), 199 | self.DEF('bar'), 200 | self.CLS('Test2', [], [ 201 | self.DEF('baz'), 202 | ]), 203 | self.CLS('Test3', [], [ 204 | self.CLS('Test5', [], [ 205 | self.DEF('bang'), 206 | ]), 207 | ]), 208 | ]), 209 | self.DEF('baz'), 210 | self.CLS('Test4', [], []), 211 | ] 212 | 213 | self.matcher = CustomMatchEngine() 214 | self.matcher.CLS = self.CLS 215 | self.matcher.DEF = self.DEF 216 | 217 | self.match = lambda s: list(self.matcher.match(s, self.data)) 218 | 219 | def test_typ(self): 220 | self.assertEqual(len(self.match('class')), 5) 221 | self.assertEqual(len(self.match('class, def')), 10) 222 | 223 | def test_child(self): 224 | self.assertEqual(len(self.match('class > def')), 4) 225 | 226 | def test_ids(self): 227 | self.assertEqual(len(self.match('def#bla')), 0) 228 | self.assertEqual(len(self.match('def#foo')), 1) 229 | 230 | def test_pseudos(self): 231 | self.assertEqual(len(self.match(':extends(object)')), 1) 232 | 233 | def test_pseudos_noargs(self): 234 | self.assertEqual(len(self.match(':extends()')), 4) 235 | 236 | def test_nested_pseudos(self): 237 | self.assertEqual( 238 | len(self.match(':not(:extends(object))')), 9) 239 | self.assertEqual( 240 | len(self.match(':extends(object):not(def)')), 1) 241 | self.assertEqual( 242 | len(self.match(':not(def)')), 5) 243 | 244 | def test_pseudo_has(self): 245 | self.assertEqual(len(self.match(':has(def)')), 4) 246 | self.assertEqual(len(self.match(':has(> def)')), 3) 247 | 248 | 249 | unittest.main(failfast=True) 250 | -------------------------------------------------------------------------------- /testfiles/assign.py: -------------------------------------------------------------------------------- 1 | foo = 1 2 | bar, zzz = foo 3 | [x, y] = 2 4 | (a, (b,)) = 3 5 | abc['def'] = None 6 | bla()['x'] = 3 7 | -------------------------------------------------------------------------------- /testfiles/attrs.py: -------------------------------------------------------------------------------- 1 | foo.bar.bang 2 | 3 | x = x.y.z 4 | -------------------------------------------------------------------------------- /testfiles/calls.py: -------------------------------------------------------------------------------- 1 | print('Hello world') 2 | 3 | foo(bar, a=None, x=1) 4 | foo()(bar, bang, x=2) 5 | 6 | x = Foo(bang) 7 | -------------------------------------------------------------------------------- /testfiles/classes.py: -------------------------------------------------------------------------------- 1 | class Foo(object): 2 | def bar(self): 3 | pass 4 | 5 | def baz(self): 6 | pass 7 | 8 | 9 | class Bar(object, X, Y, foo(bar), A.B): 10 | test = None 11 | 12 | 13 | class Bang: 14 | class Baz(object): 15 | def bar(self): 16 | x = [0] 17 | x[0] = 1 18 | 19 | 20 | import dummy_import 21 | -------------------------------------------------------------------------------- /testfiles/cmd/.test_hidden_dir/foo.py: -------------------------------------------------------------------------------- 1 | def bar(): 2 | pass 3 | -------------------------------------------------------------------------------- /testfiles/cmd/cmd.py: -------------------------------------------------------------------------------- 1 | class Foo(object): 2 | pass 3 | 4 | 5 | @decorator 6 | class Bar(object): 7 | def foo(self): 8 | pass 9 | 10 | 11 | def baz(arg1, arg2): 12 | pass 13 | 14 | 15 | foo() | bar() 16 | -------------------------------------------------------------------------------- /testfiles/cmd/file2.py: -------------------------------------------------------------------------------- 1 | def hello(): 2 | print('Hello world') 3 | -------------------------------------------------------------------------------- /testfiles/cmd/ignoredir/ignoredir2/test.py: -------------------------------------------------------------------------------- 1 | class Foo(object): 2 | pass 3 | -------------------------------------------------------------------------------- /testfiles/cmd/ignoredir/test.py: -------------------------------------------------------------------------------- 1 | class Foo(object): 2 | pass 3 | -------------------------------------------------------------------------------- /testfiles/cmd/notpyfile.txt: -------------------------------------------------------------------------------- 1 | ():#! 2 | -------------------------------------------------------------------------------- /testfiles/ids.py: -------------------------------------------------------------------------------- 1 | class foo: 2 | pass 3 | 4 | foo = 1 5 | bar = foo 6 | 7 | def bar(baz, bang=1, foo=2): 8 | pass 9 | -------------------------------------------------------------------------------- /testfiles/imports.py: -------------------------------------------------------------------------------- 1 | from foo import bar # noqa 2 | from foo import bar as bar2, xyz # noqa 3 | from foo.baz import bang # noqa 4 | from . import x 5 | 6 | import example as example2 # noqa 7 | import foo.baz # noqa 8 | --------------------------------------------------------------------------------