Skip to content

Commit 1e8d176

Browse files
moved from bitbucket
0 parents  commit 1e8d176

11 files changed

Lines changed: 803 additions & 0 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
venv3
2+
.mypy_cache/
3+
.idea
4+
*.pyc
5+
.precommit_hashes
6+
*.egg-info
7+
.tox
8+
dist/

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
1.0.2
2+
=====
3+
* Moved from bitbucket to github
4+
* Moved the temppathlib.py to temppathlib/__init__.py to facilitate usage with site packages and mypy
5+
6+
1.0.1
7+
=====
8+
* Fixed NamedTemporaryFile to accept None as ``dir`` argument.
9+
10+
1.0.0
11+
=====
12+
* Initial version

LICENSE.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2018 Parquery AG
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.

README.rst

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
temppathlib
2+
===========
3+
4+
temppathlib provides wrappers around ``tempfile`` so that you can directly use them together with ``pathlib`` module.
5+
We found it cumbersome to convert ``tempfile`` objects manually to ``pathlib.Path`` whenever we needed a temporary
6+
file.
7+
8+
Additionally, we also provide:
9+
10+
* a context manager ``removing_tree`` that checks if a path exists and recursively deletes it
11+
by wrapping ``shutil.rmtree``.
12+
13+
* a context manager ``TmpDirIfNecessary`` that creates a temporary directory if no directory is given and otherwise
14+
uses a supplied directory. This is useful when you want to keep some of the temporary files for examination
15+
after the program finished. We usually specify an optional ``--operation_dir`` command-line argument to our programs
16+
and pass its value to the ``TmpDirIfNecessary``.
17+
18+
If you need a more complex library to transition from string paths to ``pathlib.Path``, have a look at
19+
ruamel.std.pathlib_.
20+
21+
.. _ruamel.std.pathlib: https://pypi.org/project/ruamel.std.pathlib/
22+
23+
Usage
24+
=====
25+
.. code-block:: python
26+
27+
import pathlib
28+
29+
import temppathlib
30+
31+
# create a temporary directory
32+
with temppathlib.TemporaryDirectory() as tmp_dir:
33+
tmp_pth = tmp_dir.path / "some-filename.txt"
34+
# do something else with tmp_dir ...
35+
36+
# create a temporary file
37+
with temppathlib.NamedTemporaryFile() as tmp:
38+
# write to it
39+
tmp.file.write('hello'.encode())
40+
tmp.file.flush()
41+
42+
# you can use its path.
43+
target_pth = pathlib.Path('/some/permanent/directory') / tmp.path.name
44+
45+
# create a temporary directory only if necessary
46+
operation_dir = pathlib.Path("/some/operation/directory)
47+
with temppathlib.TmpDirIfNecessary(path=operation_dir) as op_dir:
48+
# do something with the operation directory
49+
pth = op_dir.path / "some-file.txt"
50+
51+
# operation_dir is not deleted since 'path' was specified.
52+
53+
54+
with temppathlib.TmpDirIfNecessary() as op_dir:
55+
# do something with the operation directory
56+
pth = op_dir.path / "some-file.txt"
57+
58+
# op_dir is deleted since 'path' argument was not specified.
59+
60+
# context manager to remove the path recursively
61+
pth = pathlib.Path('/some/directory')
62+
with temppathlib.removing_tree(pth):
63+
# do something in the directory ...
64+
pass
65+
66+
Installation
67+
============
68+
69+
* Create a virtual environment:
70+
71+
.. code-block:: bash
72+
73+
python3 -m venv venv3
74+
75+
* Activate it:
76+
77+
.. code-block:: bash
78+
79+
source venv3/bin/activate
80+
81+
* Install temppathlib with pip:
82+
83+
.. code-block:: bash
84+
85+
pip3 install temppathlib
86+
87+
Development
88+
===========
89+
90+
* Check out the repository.
91+
92+
* In the repository root, create the virtual environment:
93+
94+
.. code-block:: bash
95+
96+
python3 -m venv venv3
97+
98+
* Activate the virtual environment:
99+
100+
.. code-block:: bash
101+
102+
source venv3/bin/activate
103+
104+
* Install the development dependencies:
105+
106+
.. code-block:: bash
107+
108+
pip3 install -e .[dev]
109+
110+
* We use tox for testing and packaging the distribution. Assuming that the virtual environment has been activated and
111+
the development dependencies have been installed, run:
112+
113+
.. code-block:: bash
114+
115+
tox
116+
117+
* We also provide a set of pre-commit checks that lint and check code for formatting. Run them locally from an activated
118+
virtual environment with development dependencies:
119+
120+
.. code-block:: bash
121+
122+
./precommit.py
123+
124+
* The pre-commit script can also automatically format the code:
125+
126+
.. code-block:: bash
127+
128+
./precommit.py --overwrite
129+
130+
Versioning
131+
==========
132+
We follow `Semantic Versioning <http://semver.org/spec/v1.0.0.html>`_. The version X.Y.Z indicates:
133+
134+
* X is the major version (backward-incompatible),
135+
* Y is the minor version (backward-compatible), and
136+
* Z is the patch version (backward-compatible bug fix).

precommit.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Runs precommit checks on the repository.
4+
"""
5+
import argparse
6+
import concurrent.futures
7+
import hashlib
8+
import os
9+
import pathlib
10+
import subprocess
11+
import sys
12+
from typing import List, Union, Tuple # pylint: disable=unused-import
13+
14+
import yapf.yapflib.yapf_api
15+
16+
17+
def compute_hash(text: str) -> str:
18+
"""
19+
:param text: to hash
20+
:return: hash digest
21+
"""
22+
md5 = hashlib.md5()
23+
md5.update(text.encode())
24+
return md5.hexdigest()
25+
26+
27+
class Hasher:
28+
"""
29+
Hashes the source code files and reports if they differed to one of the previous hashings.
30+
"""
31+
32+
def __init__(self, source_dir: pathlib.Path, hash_dir: pathlib.Path) -> None:
33+
self.source_dir = source_dir
34+
self.hash_dir = hash_dir
35+
36+
def __hash_dir(self, path: pathlib.Path) -> pathlib.Path:
37+
"""
38+
:param path: to a source file
39+
:return: path to the file holding the hash of the source text
40+
"""
41+
if self.source_dir not in path.parents:
42+
raise ValueError("Expected the path to be beneath the source directory {!r}, got: {!r}".format(
43+
str(self.source_dir), str(path)))
44+
45+
return self.hash_dir / path.relative_to(self.source_dir).parent / path.name
46+
47+
def hash_differs(self, path: pathlib.Path) -> bool:
48+
"""
49+
:param path: to the source file
50+
:return: True if the hash of the content differs to one of the previous hashings.
51+
"""
52+
hash_dir = self.__hash_dir(path=path)
53+
54+
if not hash_dir.exists():
55+
return True
56+
57+
prev_hashes = set([pth.name for pth in hash_dir.iterdir()])
58+
59+
new_hsh = compute_hash(text=path.read_text())
60+
61+
return not new_hsh in prev_hashes
62+
63+
def update_hash(self, path: pathlib.Path) -> None:
64+
"""
65+
Hashes the file content and stores it on disk.
66+
67+
:param path: to the source file
68+
:return:
69+
"""
70+
hash_dir = self.__hash_dir(path=path)
71+
hash_dir.mkdir(exist_ok=True, parents=True)
72+
73+
new_hsh = compute_hash(text=path.read_text())
74+
75+
pth = hash_dir / new_hsh
76+
pth.write_text('passed')
77+
78+
79+
def check(path: pathlib.Path, py_dir: pathlib.Path, overwrite: bool) -> Union[None, str]:
80+
"""
81+
Runs all the checks on the given file.
82+
83+
:param path: to the source file
84+
:param py_dir: path to the source files
85+
:param overwrite: if True, overwrites the source file in place instead of reporting that it was not well-formatted.
86+
:return: None if all checks passed. Otherwise, an error message.
87+
"""
88+
style_config = py_dir / 'style.yapf'
89+
90+
report = []
91+
92+
# yapf
93+
if not overwrite:
94+
formatted, _, changed = yapf.yapflib.yapf_api.FormatFile(
95+
filename=str(path), style_config=str(style_config), print_diff=True)
96+
97+
if changed:
98+
report.append("Failed to yapf {}:\n{}".format(path, formatted))
99+
else:
100+
yapf.yapflib.yapf_api.FormatFile(filename=str(path), style_config=str(style_config), in_place=True)
101+
102+
# mypy
103+
env = os.environ.copy()
104+
env['PYTHONPATH'] = ":".join([py_dir.as_posix(), env.get("PYTHONPATH", "")])
105+
106+
proc = subprocess.Popen(
107+
['mypy', str(path), '--ignore-missing-imports'],
108+
stdout=subprocess.PIPE,
109+
stderr=subprocess.PIPE,
110+
env=env,
111+
universal_newlines=True)
112+
stdout, stderr = proc.communicate()
113+
if proc.returncode != 0:
114+
report.append("Failed to mypy {}:\nOutput:\n{}\n\nError:\n{}".format(path, stdout, stderr))
115+
116+
# pylint
117+
proc = subprocess.Popen(
118+
['pylint', str(path), '--rcfile={}'.format(py_dir / 'pylint.rc')],
119+
stdout=subprocess.PIPE,
120+
stderr=subprocess.PIPE,
121+
universal_newlines=True)
122+
123+
stdout, stderr = proc.communicate()
124+
if proc.returncode != 0:
125+
report.append("Failed to pylint {}:\nOutput:\n{}\n\nError:\n{}".format(path, stdout, stderr))
126+
127+
if len(report) > 0:
128+
return "\n".join(report)
129+
130+
return None
131+
132+
133+
def main() -> int:
134+
""""
135+
Main routine
136+
"""
137+
# pylint: disable=too-many-locals
138+
parser = argparse.ArgumentParser()
139+
parser.add_argument(
140+
"--overwrite",
141+
help="Overwrites the unformatted source files with the well-formatted code in place. "
142+
"If not set, an exception is raised if any of the files do not conform to the style guide.",
143+
action='store_true')
144+
145+
parser.add_argument("--all", help="checks all the files even if they didn't change", action='store_true')
146+
147+
args = parser.parse_args()
148+
149+
overwrite = bool(args.overwrite)
150+
check_all = bool(args.all)
151+
152+
py_dir = pathlib.Path(__file__).parent
153+
154+
hash_dir = py_dir / '.precommit_hashes'
155+
hash_dir.mkdir(exist_ok=True)
156+
157+
hasher = Hasher(source_dir=py_dir, hash_dir=hash_dir)
158+
159+
# yapf: disable
160+
pths = sorted(
161+
list(py_dir.glob("*.py")) +
162+
list((py_dir / 'tests').glob("*.py"))
163+
)
164+
# yapf: enable
165+
166+
# see which files changed:
167+
pending_pths = [] # type: List[pathlib.Path]
168+
169+
if check_all:
170+
pending_pths = pths
171+
else:
172+
for pth in pths:
173+
if hasher.hash_differs(path=pth):
174+
pending_pths.append(pth)
175+
176+
print("There are {} file(s) that need to be individually checked...".format(len(pending_pths)))
177+
178+
success = True
179+
180+
futures_paths = [] # type: List[Tuple[concurrent.futures.Future, pathlib.Path]]
181+
with concurrent.futures.ThreadPoolExecutor() as executor:
182+
for pth in pending_pths:
183+
future = executor.submit(fn=check, path=pth, py_dir=py_dir, overwrite=overwrite)
184+
futures_paths.append((future, pth))
185+
186+
for future, pth in futures_paths:
187+
report = future.result()
188+
if report is None:
189+
print("Passed all checks: {}".format(pth))
190+
hasher.update_hash(path=pth)
191+
else:
192+
print("One or more checks failed for {}:\n{}".format(pth, report))
193+
success = False
194+
195+
success = subprocess.call(['python3', '-m', 'unittest', 'discover', (py_dir / 'tests').as_posix()]) == 0 and success
196+
197+
if not success:
198+
print("One or more checks failed.")
199+
return 1
200+
201+
return 0
202+
203+
204+
if __name__ == "__main__":
205+
sys.exit(main())

0 commit comments

Comments
 (0)