diff --git a/.travis.yml b/.travis.yml index 9ba8c7f..ac89877 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,11 +13,6 @@ jobs: if: tag IS present python: *latest_py3 before_script: skip - env: - - TWINE_USERNAME=jaraco - # TWINE_PASSWORD - - secure: K4MiKMo4GKJIfvEdZYEplypr0F/7qwpOO7nQLJGtQ1qt6SL10e772MzFtiLBPYPZNnBz+aHoSmwwLTDmMOV2S4SgCDwBDT6pz0DY3Ajfs2suLdL0DXM4SeMR8f+apDwcs3SWz1xf1KmzVp2pbSXsTty2QSRBO1/S7ZQo11HsZW8ziGOasxkfogLwbsgH9qdM6UIwPBCpLhmY8HE08dM1sl2YYltC/zv31mTkSi9eV/t15fDXUL6YQTFcrTCKJt/H2X0JYyDUmXEAE6uPuLLHePvqSu2+8FrOsk9w6/lWyB4+L8334d2JV3diygdmSAdMVmQeJqnJas+En30VNVBfXDmNucUZIDN7uqu30f9/b1Yr0dTOPDWLxxF5YojzUvwDAAvT4EUaYW9f/oSDC4Qm8emGhSj2VKJu+bsu0zF+MSHzTtRnsr9MTdbBAS2UA2AVnmW3V6iHlGeWxtVRTT1fvc9Pcjx4P/VzsKokEhh/hONVd62XXor9xzYWgk1wh8I4ylyie4Do2JM3YHc17pKrX9DRzDCef+qlj3qynbg1fMFU4IUefZQodhpnVdPaGSFJ1gEMdeF8DM73DiV/B+GwJ4+gd/IB2bBHA0bm6pe4MYY5APjuWmeGYVYmpjfo3B2HPHaV9D0RF8C5NY45aKQplvMCsRLifYM5kUmX+y6lC+E= - - TOX_TESTENV_PASSENV="TWINE_USERNAME TWINE_PASSWORD" script: tox -e release cache: pip diff --git a/CHANGES.rst b/CHANGES.rst index 4a64ccf..cad2514 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,12 @@ +v0.6.0 +====== + +#12: When adding implicit dirs, ensure that ancestral directories +are added and that duplicates are excluded. + +The library now relies on +`more_itertools `_. + v0.5.2 ====== diff --git a/setup.cfg b/setup.cfg index f5d4be3..faa060b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,6 +22,7 @@ packages = find: include_package_data = true python_requires = >=2.7 install_requires = + more_itertools setup_requires = setuptools_scm >= 1.15.0 [options.extras_require] diff --git a/skeleton.md b/skeleton.md index 7249407..52b97f0 100644 --- a/skeleton.md +++ b/skeleton.md @@ -103,13 +103,7 @@ Relies a .flake8 file to correct some default behaviors: ## Continuous Integration -The project is pre-configured to run tests in [Travis-CI](https://travis-ci.org) (.travis.yml). Any new project must be enabled either through their web site or with the `travis enable` command. In addition to running tests, an additional deploy stage is configured to automatically release tagged commits. The username and password for PyPI must be configured for each project using the `travis` command and only after the travis project is created. As releases are cut with [twine](https://pypi.org/project/twine), the two values are supplied through the `TWINE_USERNAME` and `TWINE_PASSWORD`. To configure the latter as a secret, run the following command: - -``` -echo "TWINE_PASSWORD={password}" | travis encrypt -``` - -Or disable it in the CI definition and configure it through the web UI. +The project is pre-configured to run tests in [Travis-CI](https://travis-ci.org) (.travis.yml). Any new project must be enabled either through their web site or with the `travis enable` command. Features include: - test against Python 2 and 3 @@ -118,6 +112,14 @@ Features include: Also provided is a minimal template for running under Appveyor (Windows). +### Continuous Deployments + +In addition to running tests, an additional deploy stage is configured to automatically release tagged commits to PyPI using [API tokens](https://pypi.org/help/#apitoken). The release process expects an authorized token to be configured with Travis as the TWINE_PASSWORD environment variable. After the Travis project is created, configure the token through the web UI or with a command like the following (bash syntax): + +``` +TWINE_PASSWORD={token} travis env copy TWINE_PASSWORD +``` + ## Building Documentation Documentation is automatically built by [Read the Docs](https://readthedocs.org) when the project is registered with it, by way of the .readthedocs.yml file. To test the docs build manually, a tox env may be invoked as `tox -e build-docs`. Both techniques rely on the dependencies declared in `setup.cfg/options.extras_require.docs`. @@ -127,3 +129,9 @@ In addition to building the sphinx docs scaffolded in `docs/`, the docs build a ## Cutting releases By default, tagged commits are released through the continuous integration deploy stage. + +Releases may also be cut manually by invoking the tox environment `release` with the PyPI token set as the TWINE_PASSWORD: + +``` +TWINE_PASSWORD={token} tox -e release +``` diff --git a/test_zipp.py b/test_zipp.py index e084374..e4c77a2 100644 --- a/test_zipp.py +++ b/test_zipp.py @@ -14,10 +14,9 @@ except ImportError: import pathlib2 as pathlib -try: - from contextlib import ExitStack -except ImportError: - from contextlib2 import ExitStack +if not hasattr(contextlib, 'ExitStack'): + import contextlib2 + contextlib.ExitStack = contextlib2.ExitStack try: import unittest @@ -32,42 +31,54 @@ consume = tuple -def add_dirs(zipfile): +def add_dirs(zf): """ - Given a writable zipfile, inject directory entries for + Given a writable zip file zf, inject directory entries for any directories implied by the presence of children. """ - names = zipfile.namelist() - consume( - zipfile.writestr(name + "/", b"") - for name in map(posixpath.dirname, names) - if name and name + "/" not in names - ) - return zipfile + for name in zipp.Path._implied_dirs(zf.namelist()): + zf.writestr(name, b"") + return zf -def build_abcde_files(): +def build_alpharep_fixture(): """ Create a zip file with this structure: . ├── a.txt - └── b - ├── c.txt - └── d - └── e.txt + ├── b + │ ├── c.txt + │ ├── d + │ │ └── e.txt + │ └── f.txt + └── g + └── h + └── i.txt + + This fixture has the following key characteristics: + + - a file at the root (a) + - a file two levels deep (b/d/e) + - multiple files in a directory (b/c, b/f) + - a directory containing only a directory (g/h) + + "alpha" because it uses alphabet + "rep" because it's a representative example """ data = io.BytesIO() zf = zipfile.ZipFile(data, "w") zf.writestr("a.txt", b"content of a") zf.writestr("b/c.txt", b"content of c") zf.writestr("b/d/e.txt", b"content of e") - zf.filename = "abcde.zip" + zf.writestr("b/f.txt", b"content of f") + zf.writestr("g/h/i.txt", b"content of i") + zf.filename = "alpharep.zip" return zf @contextlib.contextmanager -def tempdir(): +def temp_dir(): tmpdir = tempfile.mkdtemp() try: yield pathlib.Path(tmpdir) @@ -75,65 +86,69 @@ def tempdir(): shutil.rmtree(tmpdir) -class TestEverything(unittest.TestCase): +class TestPath(unittest.TestCase): def setUp(self): - self.fixtures = ExitStack() + self.fixtures = contextlib.ExitStack() self.addCleanup(self.fixtures.close) - def zipfile_abcde(self): + def zipfile_alpharep(self): with self.subTest(): - yield build_abcde_files() + yield build_alpharep_fixture() with self.subTest(): - yield add_dirs(build_abcde_files()) + yield add_dirs(build_alpharep_fixture()) def zipfile_ondisk(self): - tmpdir = self.fixtures.enter_context(tempdir()) - for zipfile_abcde in self.zipfile_abcde(): - buffer = zipfile_abcde.fp - zipfile_abcde.close() - path = tmpdir / zipfile_abcde.filename + tmpdir = pathlib.Path(self.fixtures.enter_context(temp_dir())) + for alpharep in self.zipfile_alpharep(): + buffer = alpharep.fp + alpharep.close() + path = tmpdir / alpharep.filename with path.open("wb") as strm: strm.write(buffer.getvalue()) yield path - def test_iterdir_istype(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + def test_iterdir_and_types(self): + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) assert root.is_dir() - a, b = root.iterdir() + a, b, g = root.iterdir() assert a.is_file() assert b.is_dir() - c, d = b.iterdir() - assert c.is_file() + assert g.is_dir() + c, f, d = b.iterdir() + assert c.is_file() and f.is_file() e, = d.iterdir() assert e.is_file() + h, = g.iterdir() + i, = h.iterdir() + assert i.is_file() def test_open(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) - a, b = root.iterdir() + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) + a, b, g = root.iterdir() with a.open() as strm: data = strm.read() assert data == b"content of a" def test_read(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) - a, b = root.iterdir() + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) + a, b, g = root.iterdir() assert a.read_text() == "content of a" assert a.read_bytes() == b"content of a" def test_joinpath(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) a = root.joinpath("a") assert a.is_file() e = root.joinpath("b").joinpath("d").joinpath("e.txt") assert e.read_text() == "content of e" def test_traverse_truediv(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) a = root / "a" assert a.is_file() e = root / "b" / "d" / "e.txt" @@ -143,9 +158,9 @@ def test_traverse_simplediv(self): """ Disable the __future__.division when testing traversal. """ - for zipfile_abcde in self.zipfile_abcde(): + for alpharep in self.zipfile_alpharep(): code = compile( - source="zipp.Path(zipfile_abcde) / 'a'", + source="zipp.Path(alpharep) / 'a'", filename="(test)", mode="eval", dont_inherit=True, @@ -161,23 +176,23 @@ def test_pathlike_construction(self): zipp.Path(pathlike) def test_traverse_pathlike(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) root / pathlib.Path("a") def test_parent(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) assert (root / 'a').parent.at == '' assert (root / 'a' / 'b').parent.at == 'a/' def test_dir_parent(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) assert (root / 'b').parent.at == '' assert (root / 'b/').parent.at == '' def test_missing_dir_parent(self): - for zipfile_abcde in self.zipfile_abcde(): - root = zipp.Path(zipfile_abcde) + for alpharep in self.zipfile_alpharep(): + root = zipp.Path(alpharep) assert (root / 'missing dir/').parent.at == '' diff --git a/tox.ini b/tox.ini index 175afb2..cb542c1 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,10 @@ deps = pep517>=0.5 twine>=1.13 path.py +passenv = + TWINE_PASSWORD +setenv = + TWINE_USERNAME = {env:TWINE_USERNAME:__token__} commands = python -c "import path; path.Path('dist').rmtree_p()" python -m pep517.build . diff --git a/zipp.py b/zipp.py index 8cdfdc0..8ab7d09 100644 --- a/zipp.py +++ b/zipp.py @@ -7,10 +7,54 @@ import posixpath import zipfile import functools +import itertools + +import more_itertools __metaclass__ = type +def _parents(path): + """ + Given a path with elements separated by + posixpath.sep, generate all parents of that path. + + >>> list(_parents('b/d')) + ['b'] + >>> list(_parents('/b/d/')) + ['/b'] + >>> list(_parents('b/d/f/')) + ['b/d', 'b'] + >>> list(_parents('b')) + [] + >>> list(_parents('')) + [] + """ + return itertools.islice(_ancestry(path), 1, None) + + +def _ancestry(path): + """ + Given a path with elements separated by + posixpath.sep, generate all elements of that path + + >>> list(_ancestry('b/d')) + ['b/d', 'b'] + >>> list(_ancestry('/b/d/')) + ['/b/d', '/b'] + >>> list(_ancestry('b/d/f/')) + ['b/d/f', 'b/d', 'b'] + >>> list(_ancestry('b')) + ['b'] + >>> list(_ancestry('')) + [] + """ + path = path.rstrip(posixpath.sep) + while path and path != posixpath.sep: + yield path + path, tail = posixpath.split(path) + + class Path: """ A pathlib-compatible interface for zip files. @@ -70,7 +114,7 @@ class Path: >>> (b / 'missing.txt').exists() False - Coersion to string: + Coercion to string: >>> str(c) 'abcde.zip/b/c.txt' @@ -150,12 +194,17 @@ def joinpath(self, add): __truediv__ = joinpath @staticmethod - def _add_implied_dirs(names): - return names + [ - name + "/" - for name in map(posixpath.dirname, names) - if name and name + "/" not in names - ] + def _implied_dirs(names): + return more_itertools.unique_everseen( + parent + "/" + for name in names + for parent in _parents(name) + if parent + "/" not in names + ) + + @classmethod + def _add_implied_dirs(cls, names): + return names + list(cls._implied_dirs(names)) @property def parent(self):