diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 0000000..2b8d2df --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,54 @@ +name: release + +on: + push: + tags: + - "cli-v*" + +jobs: + build: + name: Publish CLI release + runs-on: "ubuntu-latest" + defaults: + run: + working-directory: ./cli + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install hatch + run: | + pip install hatch + + - name: Check that versions match + id: version + run: | + echo "Release tag: [${{ github.event.release.tag_name }}]" + PACKAGE_VERSION=$(hatch run rpzip --version) + echo "Package version: [$PACKAGE_VERSION]" + [ ${{ github.event.release.tag_name }} == "v$PACKAGE_VERSION" ] || { exit 1; } + echo "::set-output name=major_minor_version::v${PACKAGE_VERSION%.*}" + + - name: Build package + run: | + hatch build + + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@v1.3.0 + with: + user: ${{ secrets.PYPI_TEST_USERNAME }} + password: ${{ secrets.PYPI_TEST_PASSWORD }} + repository_url: https://test.pypi.org/legacy/ + skip_existing: true + + - name: Publish to Production PyPI + uses: pypa/gh-action-pypi-publish@v1.3.0 + with: + user: ${{ secrets.PYPI_PROD_USERNAME }} + password: ${{ secrets.PYPI_PROD_PASSWORD }} + skip_existing: false diff --git a/.github/workflows/release.yml b/.github/workflows/release-lib.yml similarity index 93% rename from .github/workflows/release.yml rename to .github/workflows/release-lib.yml index 1793cee..3670566 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release-lib.yml @@ -1,13 +1,13 @@ -name: release +name: release-lib on: - release: - types: - - published + push: + tags: + - "v*" jobs: build: - name: Build and publish new release + name: Publish library release runs-on: "ubuntu-latest" steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4010a95..21add71 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,12 @@ jobs: run: | pip install hatch + - name: Install zip on Windows + if: matrix.os == 'windows-latest' + run: | + choco install zip + + - name: Run tests run: | hatch run tests.py${{ matrix.python-version }}:test @@ -89,36 +95,12 @@ jobs: .venv-sdist/$PYTHON_BIN -m pip install dist/repro_zipfile-*.tar.gz --force-reinstall .venv-sdist/$PYTHON_BIN -c "from repro_zipfile import ReproducibleZipFile" - # - name: Test building documentation - # run: | - # hatch run docs:build - # if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10' - - # - name: Deploy site preview to Netlify - # if: | - # matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10' - # && github.event.pull_request != null - # uses: nwtgck/actions-netlify@v1.1 - # with: - # publish-dir: "./site" - # production-deploy: false - # github-token: ${{ secrets.GITHUB_TOKEN }} - # deploy-message: "Deploy from GitHub Actions" - # enable-pull-request-comment: true - # enable-commit-comment: false - # overwrites-pull-request-comment: true - # alias: deploy-preview-${{ github.event.number }} - # env: - # NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - # NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - # timeout-minutes: 1 - - # notify: - # name: Notify failed build - # needs: [code-quality, tests] - # if: failure() && github.event.pull_request == null - # runs-on: ubuntu-latest - # steps: - # - uses: jayqi/failed-build-issue-action@v1 - # with: - # github-token: ${{ secrets.GITHUB_TOKEN }} + notify: + name: Notify failed build + needs: [code-quality, tests] + if: failure() && github.event.pull_request == null + runs-on: ubuntu-latest + steps: + - uses: jayqi/failed-build-issue-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abb130..f2da952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -# Changelog +# Changelog — repro-zipfile + +## v0.3.0 (2024-01-27) + +- Added a `cli` installation extra for installing the rpzip package, which includes a command-line program ## v0.2.0 (2024-01-08) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e19e69..7d6d0e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,12 @@ Please file an issue in the [issue tracker](https://github.com/drivendataorg/rep This project uses [Hatch](https://github.com/pypa/hatch) as its project management tool. +### Directory structure + +This is a monorepo containing both the repro-zipfile library package and the rpzip CLI package. The root of the repository contains files relevant to the library package, and the CLI package is in the subdirectory `cli/`. + +Tests for both packages are combined in `tests/`. + ### Tests To run tests in your current environment, you should install from source with the `tests` extra to additionally install test dependencies (pytest). Then, use pytest to run the tests. @@ -58,3 +64,17 @@ hatch run typecheck ### Configuring IDEs with the Virtual Environment The default hatch environment is configured to be located in `./venv/`. To configure your IDE to use it, point it at that environment's Python interpreter located at `./venv/bin/python`. + +### Releases and publishing to PyPI + +The release process of building and publishing the packages is done using GitHub Actions CI. There are two workflows: + +- `release-lib` — for the repro-zipfile library package +- `release-cli` — for the rpzip CLI package + +Each package should be released independently. + +To trigger a release, publish a release through the GitHub web UI. Use a different tag naming scheme to determine which release workflow you trigger: + +- `v*` (e.g., `v0.1.0`) to publish repro-zipfile +- `cli-v*` (e.g., `cli-v0.1.0`) to publish rpzip diff --git a/README.md b/README.md index 7c57bdf..fd03869 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ [![tests](https://github.com/drivendataorg/repro-zipfile/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/drivendataorg/repro-zipfile/actions/workflows/tests.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/drivendataorg/repro-zipfile/branch/main/graph/badge.svg)](https://codecov.io/gh/drivendataorg/repro-zipfile) -**A tiny, zero-dependency replacement for Python's `zipfile.ZipFile` for creating reproducible/deterministic ZIP archives.** +**A tiny, zero-dependency replacement for Python's `zipfile.ZipFile` library for creating reproducible/deterministic ZIP archives.** "Reproducible" or "deterministic" in this context means that the binary content of the ZIP archive is identical if you add files with identical binary content in the same order. It means you can reliably check equality of the contents of two ZIP archives by simply comparing checksums of the archive using a hash function like MD5 or SHA-256. -This Python package provides a `ReproducibleZipFile` class that works exactly like [`zipfile.ZipFile`](https://docs.python.org/3/library/zipfile.html#zipfile-objects) from the Python standard library, except that all files written to the archive have their last-modified timestamps set to a fixed value. +This Python package provides a `ReproducibleZipFile` class that works exactly like [`zipfile.ZipFile`](https://docs.python.org/3/library/zipfile.html#zipfile-objects) from the Python standard library, except that certain file metadata are set to fixed values. See the ["How does repro-zipfile work?" section](#how-does-repro-zipfile-work) below for details. + +You can also optionally install a command-line program, **rpzip**. See the ["rpzip command line program"](#rpzip-command-line-program) section further below. ## Installation @@ -45,7 +47,34 @@ Note that files must be written to the archive in the same order to reproduce an See [`examples/usage.py`](./examples/usage.py) for an example script that you can run, and [`examples/demo_vs_zipfile.py`](./examples/demo_vs_zipfile.py) for a demonstration in contrast with the standard library's zipfile module. -For more advanced usage, such as customizing the fixed metadata values, see the following section. +For more advanced usage, such as customizing the fixed metadata values, see the subsections under ["How does repro-zipfile work?"](#how-does-repro-zipfile-work). + +## rpzip command-line program + +[![PyPI](https://img.shields.io/pypi/v/rpzip.svg)](https://pypi.org/project/rpzip/) + +You can optionally install a lightweight command-line program, **rpzip**. This includes an additional dependency on the [typer](https://typer.tiangolo.com/) CLI framework. You can install it either directly or using the `cli` extra with repro-zipfile: + +```bash +pip install rpzip +# or +pip install repro-zipfile[cli] +``` + +rpzip is designed to a partial drop-in replacement ubiquitous [zip](https://linux.die.net/man/1/zip) program. Use `rpzip --help` to see the documentation. Here are some usage examples: + +```bash +# Archive a single file +rpzip archive.zip examples/data.txt +# Archive multiple files +rpzip archive.zip examples/data.txt README.md +# Archive multiple files with a shell glob +rpzip archive.zip examples/*.py +# Archive a directory recursively +rpzip -r archive.zip examples +``` + +In addition to the fixed file metadata done by repro-zipfile, rpzip will also always sort all paths being written. ## How does repro-zipfile work? diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md new file mode 100644 index 0000000..f1919b1 --- /dev/null +++ b/cli/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog — rpzip + +## v0.1.0 (2024-01-27) + +Initial release! 🎉 diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..59ec55e --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 DrivenData Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..c51f651 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,14 @@ +# rpzip — a CLI backed by repro-zipfile + +[![PyPI](https://img.shields.io/pypi/v/rpzip.svg)](https://pypi.org/project/rpzip/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/rpzip)](https://pypi.org/project/rpzip/) +[![tests](https://github.com/drivendataorg/repro-zipfile/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/drivendataorg/repro-zipfile/actions/workflows/tests.yml?query=branch%3Amain) +[![codecov](https://codecov.io/gh/drivendataorg/repro-zipfile/branch/main/graph/badge.svg)](https://codecov.io/gh/drivendataorg/repro-zipfile) + +**A lightweight command-line program for creating reproducible/deterministic ZIP archives.** + +"Reproducible" or "deterministic" in this context means that the binary content of the ZIP archive is identical if you add files with identical binary content in the same order. It means you can reliably check equality of the contents of two ZIP archives by simply comparing checksums of the archive using a hash function like MD5 or SHA-256. + +This package provides a command-line program named **rpzip**. It is designed as a partial drop-in replacement for the ubiquitous [zip](https://linux.die.net/man/1/zip) program and implements a commonly used subset of zip's inferface. + +For further documentation, see the ["rpzip command line program"](https://github.com/drivendataorg/repro-zipfile#rpzip-command-line-program) section of the repro-zipfile README. diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..9fbaefb --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rpzip" +dynamic = ["version"] +description = "A lightweight command-line program for creating reproducible/deterministic ZIP archives." +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = ["zipfile", "zip", "reproducible", "deterministic", "cli"] +authors = [{ name = "DrivenData", email = "info@drivendata.org" }] +classifiers = [ + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: System :: Archiving", + "Topic :: System :: Archiving :: Compression", + "Topic :: System :: Archiving :: Packaging", +] +dependencies = ["repro-zipfile", "typer>=0.9.0", "typing_extensions>=3.9 ; python_version < '3.9'"] + +[project.scripts] +rpzip = "rpzip:app" + +[project.urls] +Documentation = "https://github.com/drivendataorg/repro-zipfile#readme" +Issues = "https://github.com/drivendataorg/repro-zipfile/issues" +Source = "https://github.com/drivendataorg/repro-zipfile/tree/main/cli" + +[tool.hatch.version] +path = "rpzip.py" + +## TOOLS ## + +[tool.black] +line-length = 99 diff --git a/cli/rpzip.py b/cli/rpzip.py new file mode 100644 index 0000000..1bd3948 --- /dev/null +++ b/cli/rpzip.py @@ -0,0 +1,116 @@ +import logging +from pathlib import Path +import sys +from typing import List, Optional + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import typer + +from repro_zipfile import ReproducibleZipFile +from repro_zipfile import __version__ as repro_zipfile_version + +__version__ = "0.1.0" + +app = typer.Typer() + + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +def version_callback(value: bool): + if value: + print(f"repro-zipfile v{repro_zipfile_version}") + print(f"rpzip (rpzip) v{__version__}") + raise typer.Exit() + + +@app.command(context_settings={"obj": {}}) +def rpzip( + out_file: Annotated[ + str, + typer.Argument(help="Path of output archive. '.zip' suffix will be added if not present."), + ], + in_list: Annotated[List[str], typer.Argument(help="Files to add to the archive.")], + recurse_paths: Annotated[ + bool, typer.Option("--recurse-paths", "-r", help="Recurse into directories.") + ] = False, + quiet: Annotated[ + int, + typer.Option( + "--quiet", + "-q", + count=True, + show_default=False, + help="Use to decrease log verbosity.", + ), + ] = 0, + verbose: Annotated[ + int, + typer.Option( + "--verbose", + "-v", + count=True, + show_default=False, + help="Use to increase log verbosity.", + ), + ] = 0, + version: Annotated[ + Optional[bool], + typer.Option( + "--version", + help="Print version number and exit.", + callback=version_callback, + ), + ] = None, +): + """A lightweight replacement for zip for most simple cases. Use it to compress and package + files in ZIP archives, but reproducibly/deterministicly. + + Example commands: + + \b + rpzip archive.zip some_file.txt # Archive one file + rpzip archive.zip file1.txt file2.txt # Archive two files + rpzip archive.zip some_dir/*.txt # Archive with glob + rpzip -r archive.zip some_dir/ # Archive directory recursively + """ + # Set up logger + log_level = logging.INFO + 10 * quiet - 10 * verbose + logger.setLevel(log_level) + log_handler = logging.StreamHandler() + logger.addHandler(log_handler) + prog_name = Path(sys.argv[0]).stem + log_formatter = logging.Formatter(f"%(asctime)s | {prog_name} | %(levelname)s | %(message)s") + log_handler.setFormatter(log_formatter) + + logger.debug("out_file: %s", out_file) + logger.debug("in_list: %s", in_list) + logger.debug("recurse_paths: %s", recurse_paths) + + # Set output archive path + if not out_file.endswith(".zip"): + out_path = Path(out_file).with_suffix(".zip").resolve() + else: + out_path = Path(out_file).resolve() + logger.debug("writing to: %s", out_path) + + # Process inputs + in_paths = set(Path(p) for p in in_list) + if recurse_paths: + for path in frozenset(in_paths): + if path.is_dir(): + in_paths.update(path.glob("**/*")) + + with ReproducibleZipFile(out_path, "w") as zp: + for path in sorted(in_paths): + logger.info("adding: %s", path) + zp.write(path) + + +if __name__ == "__main__": + app(prog_name="python -m rpzip") diff --git a/pyproject.toml b/pyproject.toml index 46f337d..b49ec65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ classifiers = [ dependencies = [] [project.optional-dependencies] -tests = ["pytest>=6", "pytest-cases"] +cli = ["rpzip"] +tests = ["pytest>=6,<8", "pytest-cases"] [project.urls] Documentation = "https://github.com/drivendataorg/repro-zipfile#readme" @@ -42,18 +43,25 @@ path = "repro_zipfile.py" ## DEFAULT ENVIRONMENT ## [tool.hatch.envs.default] -dependencies = ["black>=23.1.0", "mypy>=1.0.0", "ruff>=0.0.243"] +pre-install-commands = [ + "pip install -e cli", +] +features = ["cli", "tests"] +dependencies = ["mypy>=1.0.0", "ruff>=0.1.14"] python = "3.10" path = ".venv" [tool.hatch.envs.default.scripts] -lint = ["black --check {args:.}", "ruff check {args:.}"] +lint = ["ruff format --check {args:.}", "ruff check {args:.}"] typecheck = ["mypy {args:repro_zipfile.py} --install-types --non-interactive"] ## TESTS ENVIRONMENT ## [tool.hatch.envs.tests] -features = ["tests"] +pre-install-commands = [ + "pip install -e cli", +] +features = ["tests", "cli"] dependencies = ["coverage>=6.5", "pytest-cov"] template = "tests" @@ -61,13 +69,10 @@ template = "tests" python = ["3.8", "3.9", "3.10", "3.11", "3.12"] [tool.hatch.envs.tests.scripts] -test = "pytest {args:tests} -v --cov=. --cov-report=term --cov-report=html --cov-report=xml" +test = "pytest {args:tests} -v --cov=. --cov=./cli --cov-report=term --cov-report=html --cov-report=xml" ## TOOLS ## -[tool.black] -line-length = 99 - [tool.ruff] line-length = 99 select = [ @@ -75,11 +80,11 @@ select = [ "F", # Pycodestyle "I", # isort ] -src = ["*.py", "tests/*.py"] +src = ["*.py", "cli/*.py", "tests/*.py"] unfixable = ["F"] [tool.ruff.isort] -known-first-party = ["repro_zipfile"] +known-first-party = ["repro_zipfile", "rpzip"] force-sort-within-sections = true [tool.coverage.run] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2918f3e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,143 @@ +from glob import glob +import subprocess +import sys + +from typer.testing import CliRunner + +from repro_zipfile import __version__ as repro_zipfile_version +from rpzip import __version__ as rpzip_version +from rpzip import app +from tests.utils import assert_archive_contents_equals, dir_tree_factory, file_factory + +runner = CliRunner() + + +def test_zip_single_file(base_path): + data_file = file_factory(base_path) + + rpzip_out = base_path / "rpzip.zip" + rpzip_args = [str(rpzip_out), str(data_file)] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + + zip_out = base_path / "zip.zip" + zip_cmd = ["zip", str(zip_out), str(data_file)] + zip_result = subprocess.run(zip_cmd) + assert zip_result.returncode == 0, zip_cmd + + assert_archive_contents_equals(rpzip_out, zip_out) + + +def test_zip_directory(base_path): + """Single directory, not recursive.""" + dir_tree = dir_tree_factory(base_path) + + rpzip_out = base_path / "rpzip.zip" + rpzip_args = [str(rpzip_out), str(dir_tree)] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + + zip_out = base_path / "zip.zip" + zip_cmd = ["zip", str(zip_out), str(dir_tree)] + zip_result = subprocess.run(zip_cmd) + assert zip_result.returncode == 0, zip_cmd + + assert_archive_contents_equals(rpzip_out, zip_out) + + +def test_zip_directory_recursive(base_path): + """Single input directory with recursive -r flag.""" + dir_tree = dir_tree_factory(base_path) + + rpzip_out = base_path / "rpzip.zip" + rpzip_args = ["-r", str(rpzip_out), str(dir_tree)] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + + zip_out = base_path / "zip.zip" + zip_cmd = ["zip", "-r", str(zip_out), str(dir_tree)] + zip_result = subprocess.run(zip_cmd) + assert zip_result.returncode == 0, zip_cmd + + assert_archive_contents_equals(rpzip_out, zip_out) + + +def test_zip_multiple_recursive(base_path): + """Mulitiple input files with recursive -r flag.""" + dir_tree = dir_tree_factory(base_path) + + rpzip_out = base_path / "rpzip.zip" + rpzip_args = ["-r", str(rpzip_out)] + glob(str(dir_tree)) + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + + zip_out = base_path / "zip.zip" + zip_cmd = ["zip", "-r", str(zip_out)] + glob(str(dir_tree)) + zip_result = subprocess.run(zip_cmd) + assert zip_result.returncode == 0, zip_cmd + + assert_archive_contents_equals(rpzip_out, zip_out) + + +def test_zip_no_suffix(base_path): + data_file = file_factory(base_path) + + rpzip_out = base_path / "rpzip.zip" + rpzip_args = [str(rpzip_out.with_suffix("")), str(data_file)] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + + zip_out = base_path / "zip.zip" + zip_cmd = ["zip", str(zip_out.with_suffix("")), str(data_file)] + zip_result = subprocess.run(zip_cmd) + assert zip_result.returncode == 0, zip_cmd + + assert_archive_contents_equals(rpzip_out, zip_out) + + +def test_verbosity(rel_path): + """Adjustment of verbosity with -v and -q.""" + data_file = file_factory(rel_path) + rpzip_out = rel_path / "rpzip.zip" + + # Base case, should be INFO level + rpzip_args = [str(rpzip_out), str(data_file)] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + assert "INFO" in rpzip_result.output + assert "DEBUG" not in rpzip_result.output + + # With -v, should be DEBUG level + rpzip_args = [str(rpzip_out), str(data_file), "-v"] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + assert "INFO" in rpzip_result.output + assert "DEBUG" in rpzip_result.output + + # With -q, should be no output + rpzip_args = [str(rpzip_out), str(data_file), "-q"] + rpzip_result = runner.invoke(app, rpzip_args) + assert rpzip_result.exit_code == 0, rpzip_args + assert rpzip_result.output.strip() == "" + + +def test_version(): + """With --version flag.""" + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + output_lines = result.output.split("\n") + assert output_lines[0].startswith("repro-zipfile ") + assert output_lines[0].endswith(f"v{repro_zipfile_version}") + assert output_lines[1].startswith("rpzip ") + assert output_lines[1].endswith(f"v{rpzip_version}") + + +def test_python_dash_m_invocation(): + result = subprocess.run( + [sys.executable, "-m", "rpzip", "--help"], + capture_output=True, + text=True, + universal_newlines=True, + ) + assert result.returncode == 0 + assert "Usage: python -m rpzip" in result.stdout