-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtemptree.py
470 lines (334 loc) · 12 KB
/
temptree.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright © 2019 Damien Flament
# This file is part of temptree.
"""Generates temporary files and directories from a tree.
[![Build Status][ci-badge]][ci]
[![PyPI][pypi-badge]][package]
[![Python Version][py-versions-badge]][package]
[![License][license-badge]][GNU GPL 3 or later]
The provided `TemporaryTree` class allows to create complete files hierarchies
under a root `tempfile.TemporaryDirectory`.
It is well suited for usage within *doctests* :
>>> from temptree import TemporaryTree
>>> with TemporaryTree(["foo.py", "bar.py"]) as root:
... (root / "foo.py").is_file()
... (root / "bar.py").is_file()
...
True
True
A complete file hierarchy can be easily created, including text files content
and files mode:
>>> with TemporaryTree({
... "foo.py": ('''
... import os
... import sys
...
... FOO = "foo"
... ''', 0o700),
... "bar": {
... "bar.py": '''
... import foo
... import pathlib
...
... def bar():
... return foo.FOO
... ''',
... "baz.py": None,
... }
... }) as root:
... (root / "foo.py").exists()
... (root / "bar").is_dir()
... (root / "bar" / "bar.py").is_file()
... (root / "bar" / "baz.py").is_file()
...
True
True
True
True
Installation
------------
Add `temptree` to your project dependencies:
poetry add temptree
If you just need it within your *doctests*, add it as a development dependency:
poetry add --dev temptree
Documentation
-------------
[The complete documentation] is available from Github Pages.
Development
-----------
The development tasks are managed using [Invoke]. Use it to list the available
tasks:
poetry run invoke --list
Install the [pre-commit] hook within your repository:
poetry run pre-commit install
Run the tests using [Pytest]:
poetry run pytest
Contributing
------------
This project is hosted on a [Github repository].
If you're facing an issue using `temptree`, please look at
[the existing tickets]. Then you may open a new one.
You may also [make a push request] to help improve it.
Changelog
---------
See [the changelog] to see what changes have been made and what you can expect
in the next release.
License
-------
`temptree` is licensed under the [GNU GPL 3 or later].
[ci-badge]: https://img.shields.io/travis/neimad/temptree?style=flat-square
[pypi-badge]: https://img.shields.io/pypi/v/temptree?style=flat-square
[py-versions-badge]: https://img.shields.io/pypi/pyversions/temptree?style=flat-square
[license-badge]: https://img.shields.io/github/license/neimad/temptree?style=flat-square
[GNU GPL 3 or later]: https://github.com/neimad/temptree/blob/master/LICENSE.md
[the changelog]: https://github.com/neimad/temptree/blob/master/CHANGELOG.md
[The complete documentation]: https://neimad.github.io/temptree/
[Github repository]: https://github.com/neimad/temptree
[the existing tickets]: https://github.com/neimad/temptree/issues
[make a push request]: https://github.com/neimad/temptree/pulls
[package]: https://pypi.org/project/temptree/
[ci]: https://travis-ci.org/neimad/temptree
[Invoke]: https://pyinvoke.org
[pre-commit]: https://pre-commit.com
[Pytest]: https://pytest.org
"""
import os
from pathlib import Path
from tempfile import TemporaryDirectory
class TemporaryTree(object):
"""A tree of files and directories located in a `pathlib.TemporaryDirectory`.
To build a list of files, just specify their names in a list:
>>> with TemporaryTree(["foo.py", "bar.py"]) as root:
... (root / "foo.py").is_file()
... (root / "bar.py").is_file()
...
True
True
To build a hierarchy of files and directories, use nested dictionnaries:
>>> tree = TemporaryTree({
... "foo.py": None,
... "bar.py": None,
... "baz": {
... "foo.cfg": None,
... "bar.ini": None
... }
... })
...
A temporary directory is created and is available through the `TemporaryTree.root`
attribute as a `pathlib.Path`:
>>> from pathlib import Path
>>> isinstance(tree.root, Path)
True
The specified files hierarchy is created under the root:
>>> for f in sorted(tree.root.rglob("*")):
... print(f.relative_to(tree.root))
...
bar.py
baz
baz/bar.ini
baz/foo.cfg
foo.py
The tree is cleaned up when the `TemporaryTree` instance is destructed:
>>> root = tree.root
>>> del tree
>>> root.exists()
False
It can also be explicitly cleaned up using the `TemporaryTree.cleanup` method.
Context manager
---------------
A tree can be used as a context manager returning the root `pathlib.Path`:
>>> with TemporaryTree({"foo.py": None}) as root:
... foo = root / "foo.py"
... foo.exists()
...
True
On context completion, the tree is cleaned up.
Files content
-------------
The files text content can be given as a value within the files hierarchy
specification dictionnary:
>>> tree = TemporaryTree({"foo.py": '''
... FOO = "foo"
... '''})
...
>>> foo = tree / "foo.py"
>>> print(foo.read_text())
<BLANKLINE>
FOO = "foo"
<BLANKLINE>
Files mode and access flags
---------------------------
The files mode can be specified as a value within the files hierarchy specification
dictionnary:
>>> tree = TemporaryTree({"foo.py": 0o700})
>>> foo = tree / "foo.py"
>>> format(foo.stat().st_mode, "o")
'100700'
Slash operator
--------------
The slash operator can be used in the same way as with `pathlib.Path` objects:
>>> str(tree / "baz.py") == f"{str(tree.root)}/baz.py"
True
"""
def __init__(self, tree):
self._root = TemporaryDirectory()
_build_tree(self.root, tree)
@property
def root(self):
"""The root of the tree as a `pathlib.Path` object."""
return Path(self._root.name)
def __enter__(self):
"""Gives the root directory.
Returns
-------
The tree root.
"""
return self.root
def __exit__(self, e_type, e_value, e_traceback):
"""Exits the context."""
pass
def __truediv__(self, other):
"""Uses the slash operator to create childs paths from the root."""
return self.root.__truediv__(other)
def cleanup(self):
"""Cleans up the tree.
The root temporary directory is cleaned up:
>>> tree = TemporaryTree({"foo.py": None})
>>> tree.root.exists()
True
>>> tree.cleanup()
>>> tree.root.exists()
False
"""
self._root.cleanup()
def __repr__(self):
"""Represents the tree by the location of its root.
The `TemporaryTree` is represented by the location of its root in the
filesystem :
>>> tree = TemporaryTree({})
>>> repr(tree) == f"<TemporaryTree at {tree.root}>"
True
"""
class_name = self.__class__.__name__
return f"<{class_name} at {str(self.root)}>"
class FilenameError(ValueError):
"""Inappropriate file name."""
def _check_filename(filename):
r"""Checks if a filename is appropriate.
Empty filenames are forbiden:
>>> _check_filename("")
Traceback (most recent call last):
...
temptree.FilenameError: Can not create a file with an empty name
Current directory as filename is forbiden:
>>> from os import curdir
>>> _check_filename(curdir)
Traceback (most recent call last):
...
temptree.FilenameError: Can not create a file whose name is `...`
Parent directory as filename is forbiden:
>>> from os import pardir
>>> _check_filename(pardir)
Traceback (most recent call last):
...
temptree.FilenameError: Can not create a file whose name is `...`
Filenames containing path components separator are forbiden:
>>> from os import sep, altsep
>>> _check_filename(sep)
Traceback (most recent call last):
...
temptree.FilenameError: Can not create a file whose name contains `...`
>>> _check_filename(altsep) # doctest: +SKIP
Traceback (most recent call last):
...
temptree.FilenameError: Can not create a file whose name contains `...`
Filenames containing a null byte are forbiden:
>>> _check_filename("\0")
Traceback (most recent call last):
...
temptree.FilenameError: Can not create a file whose name contains a null byte
"""
NULL_BYTE = "\0"
if len(filename) == 0:
raise FilenameError("Can not create a file with an empty name")
if filename in (os.curdir, os.pardir):
raise FilenameError(f"Can not create a file whose name is `{filename}`")
if os.sep in filename:
raise FilenameError(f"Can not create a file whose name contains `{os.sep}`")
if os.altsep and os.altsep in filename:
raise FilenameError(f"Can not create a file whose name contains `{os.altsep}`")
if NULL_BYTE in filename:
raise FilenameError(f"Can not create a file whose name contains a null byte")
def _build_tree(directory, tree):
"""Creates the files hierarchy specified by the tree.
Files and directories specified by the `tree` dictionnary are created within the
given directory.
If the tree is a list, its element are taken as the keys in the tree dictionnary.
If a value is a list or a dictionnary, it is taken as a subtree under a subdirectory
whose the name is the related key.
"""
if isinstance(tree, list):
tree = {name: None for name in tree}
for name, value in tree.items():
_check_filename(name)
if isinstance(value, (list, dict)):
subdirectory = directory / name
subdirectory.mkdir()
assert subdirectory.exists()
_build_tree(subdirectory, value)
else:
file = directory / name
if value is None:
value = (None, None)
elif isinstance(value, (int, str)):
value = (value, None)
_create_file(file, value)
assert file.exists()
def _create_file(file, specification):
"""Creates the file following the given specification.
The `file` is created while following the `specification`.
>>> from tempfile import TemporaryDirectory
>>> from pathlib import Path
>>> tempdir = TemporaryDirectory()
>>> dir = Path(tempdir.name)
>>> file = dir / "file"
>>> file.exists()
False
>>> _create_file(file, (0o711, "file content"))
>>> file.is_file()
True
>>> file.read_text()
'file content'
>>> format(file.stat().st_mode, "o")
'100711'
The specification must be an iterable containing:
- the file mode as an integer,
- the file content as a string.
If the file mode is not specified, the default from `pathlib.Path.touch` is used:
>>> file.unlink()
>>> from os import umask
>>> _create_file(file, (None, "file content"))
>>> file.read_text()
'file content'
>>> file.stat().st_mode == 0o100666 - umask(0)
True
If the file content is not specified, it is left empty:
>>> file.unlink()
>>> _create_file(file, (0o711, None))
>>> file.read_text()
''
>>> format(file.stat().st_mode, "o")
'100711'
"""
content = None
mode = None
for value in specification:
if isinstance(value, str):
content = value
elif isinstance(value, int):
mode = value
file.touch()
if content:
file.write_text(content)
if mode:
file.chmod(mode)