Skip to content

Commit c034ce5

Browse files
gaborbernatambv
authored andcommitted
ignore files ignored by .gitignore, allow source tree merge (not just 1 level) (#15)
1 parent 76500ee commit c034ce5

8 files changed

Lines changed: 168 additions & 18 deletions

File tree

.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ lib64/
2020
parts/
2121
sdist/
2222
var/
23-
*.egg-info/
23+
*.egg-info
2424
.installed.cfg
2525
*.egg
2626

@@ -36,7 +36,7 @@ pip-delete-this-directory.txt
3636

3737
# Unit test / coverage reports
3838
htmlcov/
39-
.tox/
39+
.tox
4040
.coverage
4141
.coverage.*
4242
.cache
@@ -64,4 +64,4 @@ target/
6464
typed-src/
6565

6666
pip-wheel-metadata
67-
.mypy_cache
67+
.mypy_cache

retype.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
"""Re-apply type annotations from .pyi stubs to your codebase."""
33

4+
import os
45
import re
56
import sys
67
import threading
@@ -15,6 +16,7 @@
1516
from pathlib import Path
1617

1718
import click
19+
from pathspec import PathSpec
1820
from typed_ast import ast3
1921

2022
__version__ = "19.5.0"
@@ -99,11 +101,18 @@ def retype_path(
99101
):
100102
"""Recursively retype files or directories given. Generate errors."""
101103
if src.is_dir():
102-
for child in src.iterdir():
103-
if child == pyi_dir or child == targets:
104-
continue
104+
extra_ignore = []
105+
for folder in [pyi_dir, targets]:
106+
try:
107+
extra_ignore.append("/{}".format(folder.relative_to(src)))
108+
except ValueError:
109+
pass
110+
for file in walk_not_git_ignored(
111+
src, lambda p: p.suffix == ".py", extra_ignore
112+
):
113+
nested = file.relative_to(src).parent
105114
yield from retype_path(
106-
child, pyi_dir / src.name, targets / src.name, quiet=quiet, hg=hg
115+
file, pyi_dir / nested, targets / nested, quiet=quiet, hg=hg
107116
)
108117
elif src.suffix == ".py" or src_explicitly_given:
109118
try:
@@ -1409,6 +1418,58 @@ def new(n, prefix=None):
14091418
return n
14101419

14111420

1421+
def _load_ignore(at_path, parent_spec, ignores):
1422+
ignore_file = at_path / ".gitignore"
1423+
if not ignore_file.exists():
1424+
return parent_spec
1425+
lines = ignore_file.read_text().split(os.linesep)
1426+
spec = PathSpec.from_lines("gitwildmatch", lines)
1427+
spec = PathSpec(parent_spec.patterns + spec.patterns)
1428+
ignores[at_path] = spec
1429+
return spec
1430+
1431+
1432+
def walk_not_git_ignored(path, keep, extra_ignore):
1433+
path = path.absolute()
1434+
spec = PathSpec.from_lines("gitwildmatch", [".git"] + extra_ignore)
1435+
ignores = {}
1436+
# detect git folder, collect ignores up to root
1437+
at = path
1438+
while True:
1439+
git_exist = (at / ".git").exists()
1440+
if git_exist:
1441+
ignores[at.parent] = spec
1442+
# go down back and load all ignores
1443+
for part in (".",) + path.relative_to(at).parts:
1444+
at = at / part
1445+
spec = _load_ignore(at, spec, ignores)
1446+
break
1447+
if at == at.parent:
1448+
ignores[path] = spec
1449+
break
1450+
at = at.parent
1451+
1452+
# now walk from root, collect new ignores and evaluate
1453+
for root, dirs, files in os.walk(str(path)):
1454+
root_path = Path(root).relative_to(path) # current path
1455+
current_path = path / root
1456+
parent_spec = ignores.get(current_path) or next(
1457+
ignores[p] for p in current_path.parents if p in ignores
1458+
)
1459+
spec = _load_ignore(Path(root), parent_spec, ignores)
1460+
for file_name in files:
1461+
result = root_path / file_name
1462+
if (
1463+
file_name != ".gitignore"
1464+
and keep(result)
1465+
and not spec.match_file(str(result))
1466+
):
1467+
yield path / result
1468+
for cur_dir in list(dirs):
1469+
if spec.match_file(str(root_path / cur_dir)):
1470+
dirs.remove(cur_dir)
1471+
1472+
14121473
_as = Leaf(token.NAME, "as", prefix=" ")
14131474
_colon = Leaf(token.COLON, ":")
14141475
_comma = Leaf(token.COMMA, ",")

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
)
6464
],
6565
zip_safe=False,
66-
install_requires=["click", "typed-ast"],
66+
install_requires=["click", "typed-ast", "pathspec >= 0.5.9, <1"],
6767
test_suite="tests.test_retype",
6868
classifiers=[
6969
"Development Status :: 3 - Alpha",

tests/test_discovery.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from collections import namedtuple
2+
3+
import pytest
4+
5+
from retype import walk_not_git_ignored
6+
7+
8+
@pytest.fixture()
9+
def build(tmp_path):
10+
def _build(files):
11+
for file, content in files.items():
12+
dest = tmp_path / file
13+
dest.parent.mkdir(parents=True, exist_ok=True)
14+
dest.write_text(content, encoding="utf-8")
15+
return tmp_path
16+
17+
return _build
18+
19+
20+
Case = namedtuple("Case", ["files", "found", "cwd"])
21+
WALK_TESTS = {
22+
"no_ignore_root": Case({"a.py": ""}, ["a.py"], "."),
23+
"no_ignore_root_within": Case({"b/a.py": ""}, ["b/a.py"], "."),
24+
"no_ignore_keep_py_only": Case(
25+
{"a.py": "", "a.pyi": "", "b/a.pyi": "", "b/a.py": ""}, ["a.py", "b/a.py"], "."
26+
),
27+
"ignore_py_at_root": Case(
28+
{".gitignore": "*.py", "a.py": "", "b/a.py": ""}, [], "."
29+
),
30+
"ignore_py_nested": Case(
31+
{"a.py": "", "b/a.py": "", "b/.gitignore": "*.py"}, ["a.py"], "."
32+
),
33+
"git_nested_no_res": Case(
34+
{".git/demo": "", ".gitignore": "*.py", "b/c/d/a.py": ""}, [], "b/c/d"
35+
),
36+
"git_nested_has_res": Case(
37+
{".git/demo": "", "b/c/a.py": "", "b/c/d/.gitignore": "*.py", "b/c/d/a.py": ""},
38+
["a.py"],
39+
"b/c",
40+
),
41+
}
42+
43+
44+
@pytest.mark.parametrize("case", WALK_TESTS.values(), ids=list(WALK_TESTS.keys()))
45+
def test_walk(case: Case, build, monkeypatch):
46+
path = build(case.files)
47+
dest = path / case.cwd
48+
monkeypatch.chdir(dest)
49+
result = [
50+
str(f.relative_to(dest))
51+
for f in walk_not_git_ignored(
52+
dest, lambda p: p.suffix == ".py", extra_ignore=[]
53+
)
54+
]
55+
expected = [str(f) for f in case.found]
56+
assert result == expected

tox.ini

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ force_grid_wrap = 0
5050
use_parentheses = True
5151
line_length = 88
5252
known_first_party = retype
53-
known_third_party = click,setuptools,typed_ast
53+
known_third_party = click,pathspec,pytest,setuptools,typed_ast
5454

5555
[testenv:lint]
5656
description = run the flake8 checker
@@ -72,11 +72,8 @@ description = try to merge our types against our types
7272
deps = pip >= 19.1.1
7373
mypy == 0.701
7474
changedir = {envtmpdir}
75-
commands = python -c 'import glob; import shutil; from pathlib import Path; \
76-
[print(f, (Path()/"out"/shutil.copy(f, ".")).with_suffix(".py").absolute()) \
77-
for f in glob.glob(r"{toxinidir}/*.py")]'
78-
python '{envsitepackagesdir}/retype.py' -p "{toxinidir}/types" -t out .
79-
mypy out --strict --ignore-missing-imports
75+
commands = python '{envsitepackagesdir}/retype.py' -p '{toxinidir}/types' -t {envtmpdir} {toxinidir}
76+
mypy {envtmpdir} --strict --ignore-missing-imports {posargs}
8077

8178
[testenv:package_readme]
8279
description = check that the long description is valid (need for PyPi)
@@ -89,7 +86,6 @@ commands = python -m pep517.build --out-dir {envtmpdir}/build --binary .
8986

9087
[testenv:dev]
9188
description = generate a DEV environment
92-
deps = pip >= 19.1.1
9389
usedevelop = True
9490
commands = python -m pip list --format=columns
9591
python -c 'import sys; print(sys.executable)'

types/retype.pyi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ from lib2to3.pytree import Leaf, Node
22
from pathlib import Path
33
from typing import (
44
Callable,
5+
Dict,
6+
Generator,
57
Iterable,
68
Iterator,
79
List,
@@ -12,6 +14,7 @@ from typing import (
1214
Union,
1315
)
1416

17+
from pathspec import PathSpec
1518
from typed_ast import ast3
1619

1720
_LN = Union[Node, Leaf]
@@ -149,3 +152,9 @@ def _dn_call(call: ast3.Call) -> List[str]: ...
149152
def _dn_attribute(attr: ast3.Attribute) -> List[str]: ...
150153
def _nuin_node(node: Node, name: Leaf) -> bool: ...
151154
def _nuin_leaf(leaf: Leaf, name: Leaf) -> bool: ...
155+
def walk_not_git_ignored(
156+
path: Path, keep: Callable[[Path], bool], extra_ignore: List[str]
157+
) -> Generator[Path, None, None]: ...
158+
def _load_ignore(
159+
at_path: Path, parent_spec: PathSpec, ignores: Dict[Path, PathSpec]
160+
) -> PathSpec: ...

types/tests/test_discovery.pyi

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from pathlib import Path
2+
from typing import Callable, Dict, NamedTuple
3+
4+
from _pytest.monkeypatch import MonkeyPatch
5+
6+
Case: NamedTuple = ...
7+
8+
WALK_TESTS: Dict[str, Case]
9+
10+
def build(tmp_path: Path) -> Callable[[Dict[str, str]], Path]:
11+
def _build(files: Dict[str, str]) -> Path: ...
12+
13+
def test_walk(
14+
case: Case, build: Callable[[Dict[str, str]], Path], monkeypatch: MonkeyPatch
15+
) -> None: ...

types/tests/test_retype.pyi

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@ _E = TypeVar("_E", bound=Exception)
55

66
class RetypeTestCase(TestCase):
77
def assertReapply(
8-
self, pyi_txt: str, src_txt: str, expected_txt: str, *, incremental: bool
8+
self,
9+
pyi_txt: str,
10+
src_txt: str,
11+
expected_txt: str,
12+
*,
13+
incremental: bool = ...,
14+
replace_any: bool = ...,
915
) -> None: ...
1016
def assertReapplyVisible(
11-
self, pyi_txt: str, src_txt: str, expected_txt: str, *, incremental: bool
17+
self,
18+
pyi_txt: str,
19+
src_txt: str,
20+
expected_txt: str,
21+
*,
22+
incremental: bool = ...,
23+
replace_any: bool = ...,
1224
) -> None: ...
1325
def assertReapplyRaises(
1426
self,
1527
pyi_txt: str,
1628
src_txt: str,
1729
expected_exception: Type[_E],
1830
*,
19-
incremental: bool,
31+
incremental: bool = ...,
32+
replace_any: bool = ...,
2033
) -> _E: ...

0 commit comments

Comments
 (0)