From 44adc5959790e74b8d47747a7e13c75b0df4a3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 6 Apr 2026 19:38:16 +0300 Subject: [PATCH 01/59] Add project infrastructure: issue templates, CI/CD workflows, license, and dev dependencies --- .github/ISSUE_TEMPLATE/bug_report.md | 32 ++++++ .github/ISSUE_TEMPLATE/documentation.md | 26 +++++ .github/ISSUE_TEMPLATE/feature_request.md | 17 +++ .github/ISSUE_TEMPLATE/question.md | 12 ++ .github/workflows/lint.yml | 61 ++++++++++ .github/workflows/release.yml | 39 +++++++ .github/workflows/tests_and_coverage.yml | 68 +++++++++++ .gitignore | 20 ++++ LICENSE | 131 ++++++++++++++++++++++ bubbies/__init__.py | 0 bubbies/py.typed | 0 pyproject.toml | 74 ++++++++++++ requirements_dev.txt | 13 +++ tests/__init__.py | 0 14 files changed, 493 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/documentation.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/tests_and_coverage.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 bubbies/__init__.py create mode 100644 bubbies/py.typed create mode 100644 pyproject.toml create mode 100644 requirements_dev.txt create mode 100644 tests/__init__.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..95e7494 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: pomponchik + +--- + +## Short description + +Replace this text with a short description of the error and the behavior that you expected to see instead. + + +## Describe the bug in detail + +Please add a test that reproduces the bug (i.e., currently fails): + +```python +def test_your_bug(): + ... +``` + +When writing the test, please ensure compatibility with the [`pytest`](https://docs.pytest.org/) framework. + +If for some reason you cannot describe the error in the test format, describe the steps to reproduce it here. + + +## Environment + - OS: ... + - Python version (the output of the `python --version` command): ... + - Version of this package: ... diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..5f5fdc0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,26 @@ +--- +name: Documentation fix +about: Add something to the documentation, delete it, or change it +title: '' +labels: documentation +assignees: pomponchik +--- + +## It's cool that you're here! + +Documentation is an important part of the project; we strive to make it high-quality and keep it up to date. Please adjust this template by outlining your proposal. + + +## Type of action + +What do you want to do: remove something, add something, or change something? + + +## Where? + +Specify which part of the documentation you want to change. For example, the name of an existing documentation section or a line number in `README.md`. + + +## The essence + +Please describe the essence of the proposed change. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..117d79f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: pomponchik + +--- + +## Short description + +What do you propose and why do you consider it important? + + +## Some details + +If you can, provide code examples that will show how your proposal will work. Also, if you can, indicate which alternative approaches you have considered. And finally, describe how you propose to verify that your idea is implemented correctly, if at all possible. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..6f86494 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,12 @@ +--- +name: Question or consultation +about: Ask anything about this project +title: '' +labels: question +assignees: pomponchik + +--- + +## Your question + +Here you can freely describe your question about the project. Please read the documentation provided before doing this, and ask the question only if it is not answered there. In addition, please keep in mind that this is a free non-commercial project and user support is optional for its author. Response times are not guaranteed. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3fa535c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,61 @@ +name: Lint + +on: + push + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-alpha.1'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + shell: bash + run: uv pip install --system -r requirements_dev.txt + + - name: Install the library + shell: bash + run: uv pip install --system . + + - name: Run ruff + shell: bash + run: ruff check bubbies + + - name: Run ruff for tests + shell: bash + run: ruff check tests + + - name: Run mypy + shell: bash + run: >- + mypy + --python-version 3.8 + --show-error-codes + --strict + --disallow-any-decorated + --disallow-any-explicit + --disallow-any-expr + --disallow-any-generics + --disallow-any-unimported + --disallow-subclassing-any + --warn-return-any + bubbies + + - name: Run mypy for tests + shell: bash + run: mypy --exclude '^tests/typing/' tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..641ef68 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + shell: bash + run: uv pip install --system -r requirements_dev.txt + + - name: Build the project + shell: bash + run: python -m build . + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml new file mode 100644 index 0000000..6058c5d --- /dev/null +++ b/.github/workflows/tests_and_coverage.yml @@ -0,0 +1,68 @@ +name: Tests + +on: + push + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-alpha.1'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + shell: bash + run: uv pip install --system -r requirements_dev.txt + + - name: Install the library + shell: bash + run: uv pip install --system . + + - name: Print all libs + shell: bash + run: uv pip list --system + + - name: Run tests and show coverage on the command line + shell: bash + run: | + pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/bubbies_coverage_process_startup.pth" + printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file" + coverage erase + COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain + coverage combine + coverage report -m --fail-under=100 + coverage xml + + - name: Upload coverage to Coveralls + if: runner.os == 'Linux' + env: + COVERALLS_REPO_TOKEN: ${{secrets.COVERALLS_REPO_TOKEN}} + uses: coverallsapp/github-action@v2 + with: + format: cobertura + file: coverage.xml + continue-on-error: true + + - name: Run tests and show the branch coverage on the command line + shell: bash + run: | + pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/bubbies_coverage_process_startup.pth" + printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file" + coverage erase + BUBBIES_COVERAGE_BRANCH=true COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain + coverage combine + coverage report -m --fail-under=100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dddf337 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.DS_Store +__pycache__ +venv +.pytest_cache +build +dist +*.egg-info +test.py +.coverage +.coverage.* +.idea +.ruff_cache +.mutmut-cache +.mypy_cache +html +CLAUDE.md +.claude +mutants +planning_features.md +coverage.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a71cb6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,131 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/bubbies/__init__.py b/bubbies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bubbies/py.typed b/bubbies/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cc78d94 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["setuptools==68.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "bubbies" +version = "0.0.1" +authors = [ + { name="Evgeniy Blinov", email="zheni-b@yandex.ru" }, +] +description = 'Running commands in isolated environments' +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + 'suby>=0.0.4', + 'cantok>=0.0.36', +] +classifiers = [ + "Operating System :: OS Independent", + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + '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', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', + 'Programming Language :: Python :: Free Threading', + 'Programming Language :: Python :: Free Threading :: 3 - Stable', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', + 'Typing :: Typed', +] +keywords = [ + 'subprocesses', + 'subprocesses wrapper', + 'execute commands', + 'isolated environments', + 'sandboxes', +] + +[tool.setuptools.package-data] +"bubbies" = ["py.typed"] + +[tool.setuptools.packages.find] +include = ["bubbies"] + +[tool.mutmut] +paths_to_mutate=["bubbies"] + +[tool.coverage.run] +branch = "${BUBBIES_COVERAGE_BRANCH-false}" +omit = ["*tests*"] +parallel = true +plugins = ["coverage_pyver_pragma"] +source = ["bubbies"] + +[tool.pytest.ini_options] +norecursedirs = ["build", "mutants"] + +[tool.ruff] +lint.ignore = ['E501', 'E712', 'PTH123', 'PTH118', 'PLR2004', 'PTH107', 'SIM105', 'SIM102', 'RET503', 'PLR0912', 'C901', 'E731', 'F821'] +lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", "T20", "PT", "RSE", "RET", "SIM", "SLOT", "TID252", "ARG", "PTH", "I", "C90", "N", "E", "W", "D201", "D202", "D419", "F", "PL", "PLE", "PLR", "PLW", "RUF", "TRY201", "TRY400", "TRY401"] +format.quote-style = "single" + +[project.urls] +'Source' = 'https://github.com/mutating/bubbies' +'Tracker' = 'https://github.com/mutating/bubbies/issues' diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..47af4f7 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,13 @@ +pytest==8.3.5 +pytest-xdist==3.6.1; python_version < '3.9' +pytest-xdist==3.8.0; python_version >= '3.9' +coverage==7.6.1 +coverage-pyver-pragma==0.4.0 +build==1.2.2.post1 +mypy==1.14.1 +pytest-mypy-testing==0.1.3 +ruff==0.14.6 +mutmut==3.2.3 +cosmic-ray==8.3.15; python_version < '3.9' +cosmic-ray==8.4.6; python_version >= '3.9' +full_match==0.0.3 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 431fb68f33d21e69096fc08ff2b23894a6176b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 6 Apr 2026 20:18:41 +0300 Subject: [PATCH 02/59] Rename project from "bubbies" to "pupupu" across all files --- .github/workflows/lint.yml | 4 ++-- .github/workflows/tests_and_coverage.yml | 6 +++--- README.md | 2 +- {bubbies => pupupu}/__init__.py | 0 {bubbies => pupupu}/py.typed | 0 pyproject.toml | 16 ++++++++-------- 6 files changed, 14 insertions(+), 14 deletions(-) rename {bubbies => pupupu}/__init__.py (100%) rename {bubbies => pupupu}/py.typed (100%) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3fa535c..37ee494 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,7 +34,7 @@ jobs: - name: Run ruff shell: bash - run: ruff check bubbies + run: ruff check pupupu - name: Run ruff for tests shell: bash @@ -54,7 +54,7 @@ jobs: --disallow-any-unimported --disallow-subclassing-any --warn-return-any - bubbies + pupupu - name: Run mypy for tests shell: bash diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml index 6058c5d..0e9c544 100644 --- a/.github/workflows/tests_and_coverage.yml +++ b/.github/workflows/tests_and_coverage.yml @@ -39,7 +39,7 @@ jobs: - name: Run tests and show coverage on the command line shell: bash run: | - pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/bubbies_coverage_process_startup.pth" + pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/pupupu_coverage_process_startup.pth" printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file" coverage erase COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain @@ -60,9 +60,9 @@ jobs: - name: Run tests and show the branch coverage on the command line shell: bash run: | - pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/bubbies_coverage_process_startup.pth" + pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/pupupu_coverage_process_startup.pth" printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file" coverage erase - BUBBIES_COVERAGE_BRANCH=true COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain + PUPUPU_COVERAGE_BRANCH=true COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain coverage combine coverage report -m --fail-under=100 diff --git a/README.md b/README.md index 63b3964..0c44dce 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# bubbies \ No newline at end of file +# pupupu diff --git a/bubbies/__init__.py b/pupupu/__init__.py similarity index 100% rename from bubbies/__init__.py rename to pupupu/__init__.py diff --git a/bubbies/py.typed b/pupupu/py.typed similarity index 100% rename from bubbies/py.typed rename to pupupu/py.typed diff --git a/pyproject.toml b/pyproject.toml index cc78d94..44ceb47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools==68.0.0"] build-backend = "setuptools.build_meta" [project] -name = "bubbies" +name = "pupupu" version = "0.0.1" authors = [ { name="Evgeniy Blinov", email="zheni-b@yandex.ru" }, @@ -46,20 +46,20 @@ keywords = [ ] [tool.setuptools.package-data] -"bubbies" = ["py.typed"] +"pupupu" = ["py.typed"] [tool.setuptools.packages.find] -include = ["bubbies"] +include = ["pupupu"] [tool.mutmut] -paths_to_mutate=["bubbies"] +paths_to_mutate=["pupupu"] [tool.coverage.run] -branch = "${BUBBIES_COVERAGE_BRANCH-false}" +branch = "${PUPUPU_COVERAGE_BRANCH-false}" omit = ["*tests*"] parallel = true plugins = ["coverage_pyver_pragma"] -source = ["bubbies"] +source = ["pupupu"] [tool.pytest.ini_options] norecursedirs = ["build", "mutants"] @@ -70,5 +70,5 @@ lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", " format.quote-style = "single" [project.urls] -'Source' = 'https://github.com/mutating/bubbies' -'Tracker' = 'https://github.com/mutating/bubbies/issues' +'Source' = 'https://github.com/mutating/pupupu' +'Tracker' = 'https://github.com/mutating/pupupu/issues' From 0209dc180962e0f7a83216042d5e2873e8bea732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 6 Apr 2026 20:19:08 +0300 Subject: [PATCH 03/59] Add abstract base class file for pupupu components --- pupupu/abstract.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pupupu/abstract.py diff --git a/pupupu/abstract.py b/pupupu/abstract.py new file mode 100644 index 0000000..e69de29 From 4ea68b03452f4f72d47c7d1c7883f8a336c10d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Apr 2026 18:23:56 +0300 Subject: [PATCH 04/59] Add new dependencies and update README with system design overview --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 0c44dce..41f0349 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ # pupupu + + +Вкратце, дизайн системы состоит из: + +- Абстрактного класса изолята +- Слота +- Одной или нескольких базовых реализаций + +Какие базовые реализации нам нужны? + +1. "Глупая" реализация без создания новых директорий: каждое действие происходит в той же директории +2. + + + + +Дополнительные возможности: + +- Поддержка токенов отмены +- Каждый плагин с уникальным именем +- Макс число плагинов: 1 From 471f07802e62caafc7edd98ac76161f17bf8b392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Apr 2026 18:24:28 +0300 Subject: [PATCH 05/59] Add new dependencies to pyproject.toml --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 44ceb47..f5772fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ requires-python = ">=3.8" dependencies = [ 'suby>=0.0.4', 'cantok>=0.0.36', + 'pristan>=0.0.12', + 'skelet>=0.0.19', + 'emptylog>=0.0.12', ] classifiers = [ "Operating System :: OS Independent", From 8443af8cddcdd64f1690a3b8899baa7a9b76068a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Apr 2026 18:25:20 +0300 Subject: [PATCH 06/59] Refactor isolate abstraction and add directory isolate implementation --- pupupu/__init__.py | 2 ++ pupupu/{abstract.py => isolates/__init__.py} | 0 pupupu/isolates/abstract.py | 24 +++++++++++++++++ pupupu/isolates/directory.py | 27 ++++++++++++++++++++ pupupu/slot.py | 8 ++++++ 5 files changed, 61 insertions(+) rename pupupu/{abstract.py => isolates/__init__.py} (100%) create mode 100644 pupupu/isolates/abstract.py create mode 100644 pupupu/isolates/directory.py create mode 100644 pupupu/slot.py diff --git a/pupupu/__init__.py b/pupupu/__init__.py index e69de29..7973dd3 100644 --- a/pupupu/__init__.py +++ b/pupupu/__init__.py @@ -0,0 +1,2 @@ +from pupupu.isolates.abstract import AbstractIsolate as AbstractIsolate +from pupupu.isolates.directory import AbstractIsolate as AbstractIsolate diff --git a/pupupu/abstract.py b/pupupu/isolates/__init__.py similarity index 100% rename from pupupu/abstract.py rename to pupupu/isolates/__init__.py diff --git a/pupupu/isolates/abstract.py b/pupupu/isolates/abstract.py new file mode 100644 index 0000000..3cf9e60 --- /dev/null +++ b/pupupu/isolates/abstract.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, List, Optional, Union + +from cantok import AbstractToken, DefaultToken +from emptylog import EmptyLogger, LoggerProtocol + + +class AbstractIsolate(ABC): + @abstractmethod + def run(self, *arguments: Union[str, Path], logger: LoggerProtocol = EmptyLogger(), split: bool = True, token: AbstractToken = DefaultToken(), timeout: Optional[Union[int, float]] = None) -> Any: # noqa: B008 + ... + + @abstractmethod + def load(self, dump: bytes) -> None: + ... + + @abstractmethod + def dump(self) -> bytes: + ... + + @abstractmethod + def install(self, what: Union[str, List[str]]): + ... diff --git a/pupupu/isolates/directory.py b/pupupu/isolates/directory.py new file mode 100644 index 0000000..1d555ef --- /dev/null +++ b/pupupu/isolates/directory.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import Any, List, Optional, Union + +from cantok import AbstractToken, DefaultToken +from emptylog import EmptyLogger, LoggerProtocol +from skelet import Field, Storage, for_tool + +from pupupu import AbstractIsolate + + +class DirectoryIsolationConfig(Storage, sources=for_tool('pupupu')): + venv_folder_name: str = Field('.venv') + change_directories: bool = Field(False) + + +class DirectoryIsolate(AbstractIsolate): + def run(self, *arguments: Union[str, Path], logger: LoggerProtocol = EmptyLogger(), split: bool = True, token: AbstractToken = DefaultToken(), timeout: Optional[Union[int, float]] = None) -> Any: # noqa: B008 + ... + + def load(self, dump: bytes) -> None: + ... + + def dump(self) -> bytes: + ... + + def install(self, what: Union[str, List[str]]): + ... diff --git a/pupupu/slot.py b/pupupu/slot.py new file mode 100644 index 0000000..4ada518 --- /dev/null +++ b/pupupu/slot.py @@ -0,0 +1,8 @@ +from typing import List + +from pristan import slot + + +@slot(max=1, entrypoint_group='pupupu') +def get_runner() -> List[AbstractIsolate]: + return [DirectoryIsolate()] From a10b11fa6a2a00377cbdbc54e5c9e690a74fd3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 18 May 2026 15:09:09 +0300 Subject: [PATCH 07/59] Rename pupupu package to throng --- {pupupu => throng}/__init__.py | 0 {pupupu => throng}/isolates/__init__.py | 0 {pupupu => throng}/isolates/abstract.py | 0 {pupupu => throng}/isolates/directory.py | 0 {pupupu => throng}/py.typed | 0 {pupupu => throng}/slot.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename {pupupu => throng}/__init__.py (100%) rename {pupupu => throng}/isolates/__init__.py (100%) rename {pupupu => throng}/isolates/abstract.py (100%) rename {pupupu => throng}/isolates/directory.py (100%) rename {pupupu => throng}/py.typed (100%) rename {pupupu => throng}/slot.py (100%) diff --git a/pupupu/__init__.py b/throng/__init__.py similarity index 100% rename from pupupu/__init__.py rename to throng/__init__.py diff --git a/pupupu/isolates/__init__.py b/throng/isolates/__init__.py similarity index 100% rename from pupupu/isolates/__init__.py rename to throng/isolates/__init__.py diff --git a/pupupu/isolates/abstract.py b/throng/isolates/abstract.py similarity index 100% rename from pupupu/isolates/abstract.py rename to throng/isolates/abstract.py diff --git a/pupupu/isolates/directory.py b/throng/isolates/directory.py similarity index 100% rename from pupupu/isolates/directory.py rename to throng/isolates/directory.py diff --git a/pupupu/py.typed b/throng/py.typed similarity index 100% rename from pupupu/py.typed rename to throng/py.typed diff --git a/pupupu/slot.py b/throng/slot.py similarity index 100% rename from pupupu/slot.py rename to throng/slot.py From b22bff7a8a29dd4e8ae87916e9d9a9182f43e876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 18 May 2026 15:09:34 +0300 Subject: [PATCH 08/59] Rename project from "pupupu" to "throng" --- pyproject.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5772fe..756bc02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools==68.0.0"] build-backend = "setuptools.build_meta" [project] -name = "pupupu" +name = "throng" version = "0.0.1" authors = [ { name="Evgeniy Blinov", email="zheni-b@yandex.ru" }, @@ -49,20 +49,20 @@ keywords = [ ] [tool.setuptools.package-data] -"pupupu" = ["py.typed"] +"throng" = ["py.typed"] [tool.setuptools.packages.find] -include = ["pupupu"] +include = ["throng"] [tool.mutmut] -paths_to_mutate=["pupupu"] +paths_to_mutate=["throng"] [tool.coverage.run] -branch = "${PUPUPU_COVERAGE_BRANCH-false}" +branch = "${THRONG_COVERAGE_BRANCH-false}" omit = ["*tests*"] parallel = true plugins = ["coverage_pyver_pragma"] -source = ["pupupu"] +source = ["throng"] [tool.pytest.ini_options] norecursedirs = ["build", "mutants"] @@ -73,5 +73,5 @@ lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", " format.quote-style = "single" [project.urls] -'Source' = 'https://github.com/mutating/pupupu' -'Tracker' = 'https://github.com/mutating/pupupu/issues' +'Source' = 'https://github.com/mutating/throng' +'Tracker' = 'https://github.com/mutating/throng/issues' From f3647c35cac0e04f724dd9df505bbe5278d9bf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 18 May 2026 15:10:05 +0300 Subject: [PATCH 09/59] Rename pupupu to throng in isolate directory module --- throng/isolates/directory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/throng/isolates/directory.py b/throng/isolates/directory.py index 1d555ef..550c770 100644 --- a/throng/isolates/directory.py +++ b/throng/isolates/directory.py @@ -5,10 +5,10 @@ from emptylog import EmptyLogger, LoggerProtocol from skelet import Field, Storage, for_tool -from pupupu import AbstractIsolate +from throng import AbstractIsolate -class DirectoryIsolationConfig(Storage, sources=for_tool('pupupu')): +class DirectoryIsolationConfig(Storage, sources=for_tool('throng')): venv_folder_name: str = Field('.venv') change_directories: bool = Field(False) From 4c6c3b482eed21c6b164b51142ff6654e5a52a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 18 May 2026 15:10:50 +0300 Subject: [PATCH 10/59] Update import paths to use throng namespace --- throng/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/throng/__init__.py b/throng/__init__.py index 7973dd3..30f24f4 100644 --- a/throng/__init__.py +++ b/throng/__init__.py @@ -1,2 +1,2 @@ -from pupupu.isolates.abstract import AbstractIsolate as AbstractIsolate -from pupupu.isolates.directory import AbstractIsolate as AbstractIsolate +from throng.isolates.abstract import AbstractIsolate as AbstractIsolate +from throng.isolates.directory import AbstractIsolate as AbstractIsolate From b7b2406e53870ed6335edb447ab74f47729d4889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 18 May 2026 15:29:30 +0300 Subject: [PATCH 11/59] Rename pupupu to throng in CI workflows --- .github/workflows/lint.yml | 4 ++-- .github/workflows/tests_and_coverage.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 37ee494..ae04bd2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,7 +34,7 @@ jobs: - name: Run ruff shell: bash - run: ruff check pupupu + run: ruff check throng - name: Run ruff for tests shell: bash @@ -54,7 +54,7 @@ jobs: --disallow-any-unimported --disallow-subclassing-any --warn-return-any - pupupu + throng - name: Run mypy for tests shell: bash diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml index 0e9c544..f4a7f8b 100644 --- a/.github/workflows/tests_and_coverage.yml +++ b/.github/workflows/tests_and_coverage.yml @@ -39,7 +39,7 @@ jobs: - name: Run tests and show coverage on the command line shell: bash run: | - pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/pupupu_coverage_process_startup.pth" + pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/throng_coverage_process_startup.pth" printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file" coverage erase COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain @@ -60,9 +60,9 @@ jobs: - name: Run tests and show the branch coverage on the command line shell: bash run: | - pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/pupupu_coverage_process_startup.pth" + pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/throng_coverage_process_startup.pth" printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file" coverage erase - PUPUPU_COVERAGE_BRANCH=true COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain + THRONG_COVERAGE_BRANCH=true COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain coverage combine coverage report -m --fail-under=100 From 8f038a559c7ff78b23a056af3f232a3c9ad5351b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Mon, 18 May 2026 20:49:13 +0300 Subject: [PATCH 12/59] Update package versions in pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 756bc02..e6ce289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ requires-python = ">=3.8" dependencies = [ 'suby>=0.0.4', 'cantok>=0.0.36', - 'pristan>=0.0.12', - 'skelet>=0.0.19', + 'pristan>=0.0.15', + 'skelet>=0.0.21', 'emptylog>=0.0.12', ] classifiers = [ From a131021d64b5d332705c57af54ea9006dc2f3567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 21 May 2026 14:18:54 +0300 Subject: [PATCH 13/59] Update README with system design and plugin features --- README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/README.md b/README.md index 41f0349..0c44dce 100644 --- a/README.md +++ b/README.md @@ -1,22 +1 @@ # pupupu - - -Вкратце, дизайн системы состоит из: - -- Абстрактного класса изолята -- Слота -- Одной или нескольких базовых реализаций - -Какие базовые реализации нам нужны? - -1. "Глупая" реализация без создания новых директорий: каждое действие происходит в той же директории -2. - - - - -Дополнительные возможности: - -- Поддержка токенов отмены -- Каждый плагин с уникальным именем -- Макс число плагинов: 1 From 1ab71ce2bc3a47ebd4bedd2a4c272bfa0c286478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 21 May 2026 15:18:16 +0300 Subject: [PATCH 14/59] Add dirstree>=0.0.6 to dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e6ce289..a38af16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ 'pristan>=0.0.15', 'skelet>=0.0.21', 'emptylog>=0.0.12', + 'dirstree>=0.0.6', ] classifiers = [ "Operating System :: OS Independent", From c35441e338fb46aedeb4323659d49e96ba84caa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 21 May 2026 15:20:07 +0300 Subject: [PATCH 15/59] Add error module --- throng/errors.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 throng/errors.py diff --git a/throng/errors.py b/throng/errors.py new file mode 100644 index 0000000..e69de29 From 481b9c0abdccf677c675969544df1a44786a85c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Thu, 21 May 2026 15:20:42 +0300 Subject: [PATCH 16/59] Rename slot.py to slots.py --- throng/{slot.py => slots.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename throng/{slot.py => slots.py} (100%) diff --git a/throng/slot.py b/throng/slots.py similarity index 100% rename from throng/slot.py rename to throng/slots.py From 38bdb8e4395a3b7888c40e95af410fcf14bdc7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 15:58:15 +0300 Subject: [PATCH 17/59] Update README with throng features, limitations, and improvements --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c44dce..362f63b 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ -# pupupu +# throng + +## Known limitations + +`load()` preserves excluded paths by temporarily moving existing isolate +contents through a backup directory created in the platform's default +temporary location. If an isolate is placed on a different filesystem from +that temporary location, excluded special filesystem entries such as POSIX +named pipes are not guaranteed to be restored safely. Keep such isolates on +the same filesystem as the platform temporary directory when excluded special +entries must be preserved. + +## Issues + + + +- Cancellation during `dump()` and `load()` is currently observed between files, +not while the contents of one large regular file are being copied. A future +implementation should copy large file payloads in chunks and call the +operation cancellation token between chunks. Benchmarks with a never-cancelled +`SimpleToken` indicate that **256 KiB** is the recommended default chunk size: +it keeps cancellation response latency low while adding no measurable +regression for the supported tar compression paths. + +- Add reprs to all basic classes. + +- Add size limits and some reactions to overflow. + +- Add more optional backends. + +- Log information about file sizes. + +- Add to pristan possibility to declare signature as a list (not only str as now). + +- Add to dirstree apply method, and sorting, and non-pass non-file mode, and fix the tree before using. And use it all here after. And add watchdog support (https://github.com/gorakhargosh/watchdog/). From 680ca6f1809aef83ebc69d18d0ab0dacdcabdf1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 15:58:26 +0300 Subject: [PATCH 18/59] Add pathspec and locklib dependencies; update setuptools include pattern to match throng* --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a38af16..02d6f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ 'skelet>=0.0.21', 'emptylog>=0.0.12', 'dirstree>=0.0.6', + 'pathspec>=0.12.0', + 'locklib>=0.0.21', ] classifiers = [ "Operating System :: OS Independent", @@ -53,7 +55,7 @@ keywords = [ "throng" = ["py.typed"] [tool.setuptools.packages.find] -include = ["throng"] +include = ["throng*"] [tool.mutmut] paths_to_mutate=["throng"] From 9430ff85391185c0d98d29f6b0384e89b490d95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 15:58:42 +0300 Subject: [PATCH 19/59] Add entry points for local and temporary directory plugins --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 02d6f1b..882532f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,3 +78,7 @@ format.quote-style = "single" [project.urls] 'Source' = 'https://github.com/mutating/throng' 'Tracker' = 'https://github.com/mutating/throng/issues' + +[project.entry-points.throng] +local = "throng.plugins.plugins:create_local_throng" +temporary_directory = "throng.plugins.plugins:create_temporary_directory_throng" From e9558bdeaa8b13ec5e4cdc82fa2a7e9a2956e64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 15:59:13 +0300 Subject: [PATCH 20/59] Add locklib to development requirements --- requirements_dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements_dev.txt b/requirements_dev.txt index 47af4f7..4c89aee 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -11,3 +11,4 @@ mutmut==3.2.3 cosmic-ray==8.3.15; python_version < '3.9' cosmic-ray==8.4.6; python_version >= '3.9' full_match==0.0.3 +locklib==0.0.21 From 4c89a766c410944c463bd802136a56e61dbdc633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 16:00:16 +0300 Subject: [PATCH 21/59] Remove isolates module and its abstract base class --- throng/isolates/__init__.py | 0 throng/isolates/abstract.py | 24 ------------------------ throng/isolates/directory.py | 27 --------------------------- 3 files changed, 51 deletions(-) delete mode 100644 throng/isolates/__init__.py delete mode 100644 throng/isolates/abstract.py delete mode 100644 throng/isolates/directory.py diff --git a/throng/isolates/__init__.py b/throng/isolates/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/throng/isolates/abstract.py b/throng/isolates/abstract.py deleted file mode 100644 index 3cf9e60..0000000 --- a/throng/isolates/abstract.py +++ /dev/null @@ -1,24 +0,0 @@ -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Any, List, Optional, Union - -from cantok import AbstractToken, DefaultToken -from emptylog import EmptyLogger, LoggerProtocol - - -class AbstractIsolate(ABC): - @abstractmethod - def run(self, *arguments: Union[str, Path], logger: LoggerProtocol = EmptyLogger(), split: bool = True, token: AbstractToken = DefaultToken(), timeout: Optional[Union[int, float]] = None) -> Any: # noqa: B008 - ... - - @abstractmethod - def load(self, dump: bytes) -> None: - ... - - @abstractmethod - def dump(self) -> bytes: - ... - - @abstractmethod - def install(self, what: Union[str, List[str]]): - ... diff --git a/throng/isolates/directory.py b/throng/isolates/directory.py deleted file mode 100644 index 550c770..0000000 --- a/throng/isolates/directory.py +++ /dev/null @@ -1,27 +0,0 @@ -from pathlib import Path -from typing import Any, List, Optional, Union - -from cantok import AbstractToken, DefaultToken -from emptylog import EmptyLogger, LoggerProtocol -from skelet import Field, Storage, for_tool - -from throng import AbstractIsolate - - -class DirectoryIsolationConfig(Storage, sources=for_tool('throng')): - venv_folder_name: str = Field('.venv') - change_directories: bool = Field(False) - - -class DirectoryIsolate(AbstractIsolate): - def run(self, *arguments: Union[str, Path], logger: LoggerProtocol = EmptyLogger(), split: bool = True, token: AbstractToken = DefaultToken(), timeout: Optional[Union[int, float]] = None) -> Any: # noqa: B008 - ... - - def load(self, dump: bytes) -> None: - ... - - def dump(self) -> bytes: - ... - - def install(self, what: Union[str, List[str]]): - ... From 9e3cd19baf5299a6206809176da5d3c66d0b0436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 16:01:24 +0300 Subject: [PATCH 22/59] Refactor imports and add error types and throng slots --- throng/__init__.py | 13 +++++++++++-- throng/errors.py | 41 +++++++++++++++++++++++++++++++++++++++++ throng/slots.py | 21 +++++++++++++++++---- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/throng/__init__.py b/throng/__init__.py index 30f24f4..c6d13b1 100644 --- a/throng/__init__.py +++ b/throng/__init__.py @@ -1,2 +1,11 @@ -from throng.isolates.abstract import AbstractIsolate as AbstractIsolate -from throng.isolates.directory import AbstractIsolate as AbstractIsolate +from throng.abstracts.isolate import AbstractIsolate as AbstractIsolate +from throng.abstracts.throng import AbstractThrong as AbstractThrong +from throng.errors import ArchiveUnpackError as ArchiveUnpackError +from throng.errors import CommandExecutionError as CommandExecutionError +from throng.errors import InstallError as InstallError +from throng.errors import InvalidBaseDirectoryError as InvalidBaseDirectoryError +from throng.errors import InvalidVirtualEnvPathError as InvalidVirtualEnvPathError +from throng.errors import IsolateDeletedError as IsolateDeletedError +from throng.errors import OperationCancelledError as OperationCancelledError +from throng.result import RunResult as RunResult +from throng.slots import throngs as throngs diff --git a/throng/errors.py b/throng/errors.py index e69de29..d5b00f7 100644 --- a/throng/errors.py +++ b/throng/errors.py @@ -0,0 +1,41 @@ +from typing import Optional + +from throng.result import RunResult + + +class CommandExecutionError(Exception): + """Report a command that did not complete successfully during ``run``.""" + + def __init__(self, message: str, result: Optional[RunResult] = None) -> None: + """Create an error, optionally retaining the failed command's RunResult.""" + self.result = result + super().__init__(message) + + +class InstallError(Exception): + """Report dependency installation or virtual-environment creation failure.""" + + +class InvalidBaseDirectoryError(Exception): + """Report an unusable configured root for temporary isolate directories.""" + + +class InvalidVirtualEnvPathError(Exception): + """Report a virtual-environment location or executable that cannot be used.""" + + +class ArchiveUnpackError(Exception): + """ + Report a ``load`` failure caused by input or filesystem application errors. + + The archive may be malformed or unsafe, or staging/commit may have failed + while applying otherwise valid bytes to the isolate filesystem. + """ + + +class OperationCancelledError(Exception): + """Report cancellation of a cancellable isolate operation.""" + + +class IsolateDeletedError(Exception): + """Report an attempted operation on an isolate already disposed of.""" diff --git a/throng/slots.py b/throng/slots.py index 4ada518..0c9c3ee 100644 --- a/throng/slots.py +++ b/throng/slots.py @@ -1,8 +1,21 @@ -from typing import List +from typing import Dict +from emptylog import EmptyLogger, LoggerProtocol from pristan import slot +from throng.abstracts.throng import AbstractThrong -@slot(max=1, entrypoint_group='pupupu') -def get_runner() -> List[AbstractIsolate]: - return [DirectoryIsolate()] + +@slot(entrypoint_group='throng') +def throngs(logger: LoggerProtocol = EmptyLogger()) -> Dict[str, AbstractThrong]: # type: ignore[empty-body] # noqa: B008 + """ + Resolve registered throng plugins, passing them the inherited logger. + + Pristan supplies this function body at runtime and discovers third-party + providers from the ``throng`` entry-point group. + """ + + +__all__ = [ + 'throngs', +] From 54a993f2c85abbff97dce88336006cff373a73a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 16:03:18 +0300 Subject: [PATCH 23/59] Add abstract base classes for isolates and throngs with result handling --- throng/abstracts/__init__.py | 7 ++++ throng/abstracts/isolate.py | 64 ++++++++++++++++++++++++++++++++++++ throng/abstracts/throng.py | 11 +++++++ throng/result.py | 17 ++++++++++ 4 files changed, 99 insertions(+) create mode 100644 throng/abstracts/__init__.py create mode 100644 throng/abstracts/isolate.py create mode 100644 throng/abstracts/throng.py create mode 100644 throng/result.py diff --git a/throng/abstracts/__init__.py b/throng/abstracts/__init__.py new file mode 100644 index 0000000..1969dee --- /dev/null +++ b/throng/abstracts/__init__.py @@ -0,0 +1,7 @@ +from throng.abstracts.isolate import AbstractIsolate +from throng.abstracts.throng import AbstractThrong + +__all__ = [ + 'AbstractIsolate', + 'AbstractThrong', +] diff --git a/throng/abstracts/isolate.py b/throng/abstracts/isolate.py new file mode 100644 index 0000000..e1be0a4 --- /dev/null +++ b/throng/abstracts/isolate.py @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import List, Mapping, Optional, Sequence, Tuple, Union + +from cantok import AbstractToken, DefaultToken +from emptylog import EmptyLogger, LoggerProtocol + +from throng.result import RunResult + + +class AbstractIsolate(ABC): + """ + Define operations available through one execution-environment handle. + + A throng chooses how isolates are created or reused. Users operate on an + isolate for concrete actions without depending on backend lifetime or + reuse policy, and may supply an additional logger or a cancellation token + to each cancellable action. Once ``delete`` has succeeded, + implementations must reject all further actions. + """ + + @abstractmethod + def run( # noqa: PLR0913 + self, + *arguments: Union[str, Path], + logger: LoggerProtocol = EmptyLogger(), # noqa: B008 + split: bool = True, + token: AbstractToken = DefaultToken(), # noqa: B008 + timeout: Optional[Union[int, float]] = None, + catch_output: bool = False, + catch_exceptions: bool = False, + double_backslash: bool = False, + env: Optional[Mapping[str, str]] = None, + add_env: Optional[Mapping[str, str]] = None, + delete_env: Optional[Union[List[str], Tuple[str, ...]]] = None, + ) -> RunResult: + """ + Execute a command inside the isolate and return a library result. + + Implementations choose the isolate working directory and must not let + callers override it. A cancelled execution raises the library + cancellation exception instead of exposing backend-specific state. + """ + + @abstractmethod + def install(self, what: Union[str, Sequence[str]], logger: LoggerProtocol = EmptyLogger(), token: AbstractToken = DefaultToken()) -> None: # noqa: B008 + """Install one or more Python dependency specifications for later runs.""" + + @abstractmethod + def dump(self, logger: LoggerProtocol = EmptyLogger(), token: AbstractToken = DefaultToken()) -> bytes: # noqa: B008 + """Serialize the isolate filesystem into portable archive bytes.""" + + @abstractmethod + def load(self, dump: bytes, logger: LoggerProtocol = EmptyLogger(), token: AbstractToken = DefaultToken()) -> None: # noqa: B008 + """Replace non-excluded filesystem contents from archive bytes.""" + + @abstractmethod + def delete(self, logger: LoggerProtocol = EmptyLogger()) -> None: # noqa: B008 + """ + Dispose of the isolate and prevent later operations on this object. + + Deletion deliberately has no cancellation token: cleanup is either + completed or raises a cleanup error that the caller can handle. + """ diff --git a/throng/abstracts/throng.py b/throng/abstracts/throng.py new file mode 100644 index 0000000..2cd3e0d --- /dev/null +++ b/throng/abstracts/throng.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from throng.abstracts.isolate import AbstractIsolate + + +class AbstractThrong(ABC): + """Define a provider of isolates without exposing its allocation policy.""" + + @abstractmethod + def get_isolate(self) -> AbstractIsolate: + """Return an isolate suitable for one or more immediate operations.""" diff --git a/throng/result.py b/throng/result.py new file mode 100644 index 0000000..e2eb1d9 --- /dev/null +++ b/throng/result.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class RunResult: + """Store backend-independent command output and completion status.""" + + id: str + stdout: Optional[str] + stderr: Optional[str] + returncode: Optional[int] + + @property + def success(self) -> bool: + """Return ``True`` exactly when ``returncode`` is zero.""" + return self.returncode == 0 From 53cb31a443cb8d468731b696b1e7f53b20e6f4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 16:04:36 +0300 Subject: [PATCH 24/59] Add directory isolation plugin with local and temporary implementations --- throng/plugins/__init__.py | 14 + throng/plugins/directory_isolate.py | 874 +++++++++++++++++++ throng/plugins/empty_lock.py | 25 + throng/plugins/local_throng.py | 32 + throng/plugins/plugins.py | 18 + throng/plugins/temporary_directory_throng.py | 77 ++ 6 files changed, 1040 insertions(+) create mode 100644 throng/plugins/__init__.py create mode 100644 throng/plugins/directory_isolate.py create mode 100644 throng/plugins/empty_lock.py create mode 100644 throng/plugins/local_throng.py create mode 100644 throng/plugins/plugins.py create mode 100644 throng/plugins/temporary_directory_throng.py diff --git a/throng/plugins/__init__.py b/throng/plugins/__init__.py new file mode 100644 index 0000000..2ff221c --- /dev/null +++ b/throng/plugins/__init__.py @@ -0,0 +1,14 @@ +from throng.plugins.directory_isolate import DirectoryIsolate, DirectoryIsolationConfig +from throng.plugins.local_throng import LocalDirectoryThrong +from throng.plugins.temporary_directory_throng import ( + TemporaryDirectoryIsolationConfig, + TemporaryDirectoryThrong, +) + +__all__ = [ + 'DirectoryIsolate', + 'DirectoryIsolationConfig', + 'LocalDirectoryThrong', + 'TemporaryDirectoryIsolationConfig', + 'TemporaryDirectoryThrong', +] diff --git a/throng/plugins/directory_isolate.py b/throng/plugins/directory_isolate.py new file mode 100644 index 0000000..240bd02 --- /dev/null +++ b/throng/plugins/directory_isolate.py @@ -0,0 +1,874 @@ +# mypy: disable-error-code=misc +# skelet's Storage metaclass exposes Any through class-definition metadata. +from functools import partial +from io import BytesIO +from os import X_OK, access, environ, pathsep +from os import name as os_name +from pathlib import Path, PurePosixPath +from shutil import Error as ShutilError +from shutil import copyfileobj, move, rmtree +from sys import executable +from tarfile import TarError, TarInfo +from tarfile import open as open_tar +from tempfile import TemporaryDirectory, mkdtemp +from threading import Lock as ThreadLock +from typing import ( + BinaryIO, + ClassVar, + Dict, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, + Union, + cast, +) +from weakref import finalize + +from cantok import AbstractToken, CancellationError, DefaultToken +from dirstree import Crawler +from emptylog import EmptyLogger, LoggerProtocol, LoggersGroup +from locklib import ContextLockProtocol +from pathspec import PathSpec +from pathspec.patterns.gitwildmatch import GitWildMatchPatternError +from skelet import Field, Storage, for_tool +from suby import ( + EnvironmentVariablesConflict, + RunningCommandError, + WrongCommandError, + WrongDirectoryError, +) +from suby import run as run_suby +from suby.subprocess_result import SubprocessResult + +from throng.abstracts.isolate import AbstractIsolate +from throng.errors import ( + ArchiveUnpackError, + CommandExecutionError, + InstallError, + InvalidVirtualEnvPathError, + IsolateDeletedError, + OperationCancelledError, +) +from throng.result import RunResult + + +def is_supported_compression_mode(value: str) -> bool: + """Return whether ``value`` selects a supported archive compression value.""" + return value in ('gzip', 'bz2', 'lzma', 'none') + + +def are_valid_exclusion_patterns(patterns: List[str]) -> bool: + """Return whether gitwildmatch exclusion patterns can be compiled.""" + try: + PathSpec.from_lines('gitwildmatch', patterns) + except GitWildMatchPatternError: + return False + + return True + + +class DirectoryIsolationConfig(Storage, sources=for_tool('local')): + """ + Configure filesystem-backed isolate behavior shared by built-in plugins. + + This base configuration reads the local plugin's skelet source. The + temporary plugin subclasses it to obtain the same fields from its own + independent source. + """ + + compression: str = Field( + 'gzip', + doc='compression mode used to write and read isolate snapshot archives', + validation={'Unsupported compression mode.': is_supported_compression_mode}, + ) + use_venv: bool = Field(True, doc='whether dependency installation uses an isolate-local virtual environment') + venv_path: str = Field('.venv', doc='relative path of the isolate-local virtual environment') + dump_exclude: List[str] = Field( + default_factory=list, + doc='gitwildmatch patterns omitted from snapshots and preserved during load', + validation={'Invalid dump exclude pattern.': are_valid_exclusion_patterns}, + ) + exclude_venv: bool = Field(True, doc='whether the effective virtual environment path is excluded from snapshots') + log_run: bool = Field(False, doc='whether suby emits detailed command execution logs, which can reveal arguments') + + +class DirectoryIsolate(AbstractIsolate): + """ + Run isolate operations against a designated filesystem directory. + + A concrete throng supplies the directory, inherited logger, and locking + policy. Public operations acquire that lock; a local throng provides a + reentrant serialization lock while a temporary throng intentionally + provides :class:`EmptyLock`. Successful deletion makes this object + unusable even when the underlying local directory remains present. + """ + + COMPRESSION_TO_TAR_MODE: ClassVar[Dict[str, str]] = { + 'none': 'r:', + 'gzip': 'r:gz', + 'bz2': 'r:bz2', + 'lzma': 'r:xz', + } + COMPRESSION_TO_TAR_WRITE_MODE: ClassVar[Dict[str, str]] = { + 'none': 'w', + 'gzip': 'w:gz', + 'bz2': 'w:bz2', + 'lzma': 'w:xz', + } + + def __init__( # noqa: PLR0913 + self, + directory: Path, + config: DirectoryIsolationConfig, + logger: LoggerProtocol = EmptyLogger(), # noqa: B008 + *, + lock: ContextLockProtocol, + temporary_directory_manager: Optional['TemporaryDirectory[str]'] = None, + owns_directory: bool = False, + ) -> None: + """ + Bind filesystem state, lifecycle ownership, logging, and locking. + + ``lock`` must support nested entry if its caller will use + ``install``: installation invokes public ``run`` while still inside + the operation lock. ``temporary_directory_manager`` and + ``owns_directory`` select the cleanup strategy used by ``delete`` and + best-effort finalization. + """ + self.directory = directory + self.config = config + self.logger = logger + self.lock = lock + self.temporary_directory_manager = temporary_directory_manager + self.owns_directory = owns_directory + self.directory_finalizer = finalize(self, rmtree, self.directory, ignore_errors=True) if owns_directory else None + self.lock_only_for_delete = ThreadLock() + self.deleted = False + + def run( # noqa: PLR0913 + self, + *arguments: Union[str, Path], + logger: LoggerProtocol = EmptyLogger(), # noqa: B008 + split: bool = True, + token: AbstractToken = DefaultToken(), # noqa: B008 + timeout: Optional[Union[int, float]] = None, + catch_output: bool = False, + catch_exceptions: bool = False, + double_backslash: bool = False, + env: Optional[Mapping[str, str]] = None, + add_env: Optional[Mapping[str, str]] = None, + delete_env: Optional[Union[List[str], Tuple[str, ...]]] = None, + ) -> RunResult: + """ + Execute a command with this isolate as its forced working directory. + + The passed logger is added to the inherited logger. Environment + controls are forwarded to suby after optional venv activation, and + backend cancellation or execution errors are normalized to throng + exceptions. + """ + operation_logger = self._combine_loggers(logger) + + with self.lock: + self._raise_if_deleted(operation_logger, 'run') + + return self._run_unlocked( + arguments, + operation_logger, + split, + token, + timeout, + catch_output, + catch_exceptions, + double_backslash, + env, + add_env, + delete_env, + ) + + def install(self, what: Union[str, Sequence[str]], logger: LoggerProtocol = EmptyLogger(), token: AbstractToken = DefaultToken()) -> None: # noqa: B008 + """ + Install dependency specifications through an isolate ``run`` call. + + Depending on configuration, the command uses a validated local venv + or the current Python interpreter. Dependency strings are not placed + in lifecycle logs and are redacted from failure diagnostics. + """ + combined_logger = self._combine_loggers(logger) + + with self.lock: + self._raise_if_deleted(combined_logger, 'install') + + return self._install_unlocked(what, combined_logger, logger, token) + + def dump(self, logger: LoggerProtocol = EmptyLogger(), token: AbstractToken = DefaultToken()) -> bytes: # noqa: B008 + """ + Create archive bytes for non-excluded regular isolate files. + + Symbolic links are never serialized. The configured compression and + exclusion policy are used, and the effective venv is excluded by + default. Cancellation is checked between traversed files rather than + during one file's tar copy. + """ + operation_logger = self._combine_loggers(logger) + + with self.lock: + self._raise_if_deleted(operation_logger, 'dump') + + return self._dump_unlocked(operation_logger, token) + + def load(self, dump: bytes, logger: LoggerProtocol = EmptyLogger(), token: AbstractToken = DefaultToken()) -> None: # noqa: B008 + """ + Replace non-excluded contents with validated archive bytes. + + Bytes are first extracted into staging and validated for safe archive + members. After staging succeeds, commit deliberately runs without + cancellation checks so token cancellation cannot intentionally leave a + half-applied tree; a commit failure instead triggers best-effort + rollback. + """ + operation_logger = self._combine_loggers(logger) + + with self.lock: + self._raise_if_deleted(operation_logger, 'load') + + return self._load_unlocked(dump, operation_logger, token) + + def delete(self, logger: LoggerProtocol = EmptyLogger()) -> None: # noqa: B008 + """ + Dispose of this isolate under its supplied operation-lock policy. + + Temporary isolates remove their owned directory; local isolates only + transition to the deleted state. A cleanup failure leaves deletion + retryable. + """ + operation_logger = self._combine_loggers(logger) + + with self.lock: + return self._delete_unlocked(operation_logger) + + def _run_unlocked( # noqa: PLR0913 + self, + arguments: Tuple[Union[str, Path], ...], + logger: LoggerProtocol, + split: bool, + token: AbstractToken, + timeout: Optional[Union[int, float]], + catch_output: bool, + catch_exceptions: bool, + double_backslash: bool, + env: Optional[Mapping[str, str]], + add_env: Optional[Mapping[str, str]], + delete_env: Optional[Union[List[str], Tuple[str, ...]]], + ) -> RunResult: + """ + Implement ``run`` after the public method has acquired its lock. + + By default the subprocess backend receives no logger argument and + therefore uses its empty default logger: ``DirectoryIsolate`` emits + concise public lifecycle records itself. Enabling ``log_run`` + forwards this operation's logger to suby, deliberately exposing its + detailed records, which may contain command arguments. + """ + try: + token.check() + + if not arguments: + logger.error('Run failed because the command is empty.') + raise CommandExecutionError('Cannot run an empty command.') + + effective_add_env = self._build_run_add_env(add_env, env) + + logger.info(f'Starting run in "{self.directory}".') + logger.debug(f'Run environment additions: {sorted((effective_add_env or {}).keys())}.') + + backend_run = partial(run_suby, logger=logger) if self.config.log_run else run_suby + + subprocess_result = backend_run( + *arguments, + catch_output=catch_output, + catch_exceptions=catch_exceptions, + timeout=timeout, + directory=self.directory, + split=split, + double_backslash=double_backslash, + env=env, + add_env=effective_add_env, + delete_env=delete_env, + token=token, + ) + + if subprocess_result.killed_by_token: + logger.error('Run operation was cancelled.') + raise OperationCancelledError('The operation was cancelled.') + + run_result = self._map_subprocess_result(subprocess_result) + if run_result.returncode != 0: + logger.error(f'Run completed with non-zero exit code {run_result.returncode}.') + if not catch_exceptions: + raise CommandExecutionError('Command failed with a non-zero exit code.', run_result) + return run_result + + logger.info('Run completed successfully.') + return run_result + + except CancellationError as error: + logger.exception('Run operation was cancelled.') + raise OperationCancelledError('The operation was cancelled.') from error + except InvalidVirtualEnvPathError: + logger.exception('Run failed because the virtual environment is invalid.') + raise + except RunningCommandError as error: + if error.result.killed_by_token: + logger.exception('Run operation was cancelled.') + raise OperationCancelledError('The operation was cancelled.') from error + + run_result = self._map_subprocess_result(error.result) + logger.error(f'Run failed with non-zero exit code {run_result.returncode}.') # noqa: TRY400 - suby exception may expose command arguments. + raise CommandExecutionError(f'Command failed or output decoding failed: {error}', run_result) from error + except (EnvironmentVariablesConflict, UnicodeDecodeError, WrongCommandError, WrongDirectoryError) as error: + logger.exception('Run failed.') + raise CommandExecutionError(str(error)) from error + + def _install_unlocked( + self, + what: Union[str, Sequence[str]], + operation_logger: LoggerProtocol, + nested_run_logger: LoggerProtocol, + token: AbstractToken, + ) -> None: + """ + Implement installation while avoiding duplicate nested run loggers. + + ``nested_run_logger`` is the operation-specific logger received from + the caller, not the already combined logger; public ``run`` combines + it with the inherited logger exactly once when installation invokes + nested commands. + """ + try: + self._check_token(token) + packages = (what,) if isinstance(what, str) else tuple(what) + + if not packages: + operation_logger.info('No dependencies requested; install completed without changes.') + return + + if any(package == '' for package in packages): + raise InstallError('Dependency name cannot be empty.') + + dependency_word = 'dependency' if len(packages) == 1 else 'dependencies' + operation_logger.info(f'Installing {len(packages)} {dependency_word}.') + + if self.config.use_venv: + venv_path = self._resolve_venv_path() + python_path = self._get_venv_python_path(venv_path) + + if not python_path.exists(): + operation_logger.debug(f'Creating virtual environment at "{venv_path}".') + venv_creation_result = self.run(executable, '-m', 'venv', str(venv_path), logger=nested_run_logger, token=token, catch_output=True, catch_exceptions=True, split=False) + + if not venv_creation_result.success: + raise InstallError(venv_creation_result.stderr or venv_creation_result.stdout or 'Virtual environment creation failed.') + + self._validate_venv_python_path(python_path) + + command = (str(python_path), '-m', 'pip', 'install', *packages) + operation_logger.debug(f'Installing dependencies with virtual environment at "{venv_path}".') + else: + command = (executable, '-m', 'pip', 'install', *packages) + operation_logger.debug('Installing dependencies with the current interpreter.') + + installation_result = self.run(*command, logger=nested_run_logger, token=token, catch_output=True, catch_exceptions=True, split=False) + + if not installation_result.success: + raise InstallError(installation_result.stderr or installation_result.stdout or 'Dependency installation failed.') + + operation_logger.info('Install completed successfully.') + except OperationCancelledError: + operation_logger.exception('Install operation was cancelled.') + raise + except (CommandExecutionError, InstallError, InvalidVirtualEnvPathError) as error: + diagnostic = str(error) + non_empty_packages = (package for package in packages if package) + for package in sorted(non_empty_packages, key=len, reverse=True): + diagnostic = diagnostic.replace(package, '') + + operation_logger.exception(f'Install failed: {diagnostic}.') + + if isinstance(error, CommandExecutionError): + raise InstallError(str(error)) from error + + raise + + def _dump_unlocked(self, logger: LoggerProtocol, token: AbstractToken) -> bytes: + """Build a tar archive from non-excluded regular files after lock checks.""" + try: + self._check_token(token) + + archive_mode = self.COMPRESSION_TO_TAR_WRITE_MODE[self.config.compression] + try: + exclusion_spec = self._create_exclusion_spec() + except ValueError as error: + logger.exception(f'Dump failed because the exclude configuration is invalid: {error}.') # noqa: TRY401 - plain loggers need the diagnostic text. + raise + + excluded_venv_path = self._resolve_excluded_venv_path() + + logger.info(f'Dumping isolate "{self.directory}".') + logger.debug( + f'Dump options: compression={self.config.compression}, ' + f'excludes={self.config.dump_exclude}, excluded_venv_path={excluded_venv_path}.', + ) + + archive_buffer = BytesIO() + with open_tar(fileobj=archive_buffer, mode=archive_mode) as archive: + archive.dereference = True + crawler = Crawler( + self.directory, + filter=lambda source_path: ( + not source_path.is_symlink() + and not self._is_path_excluded( + source_path.relative_to(self.directory).as_posix(), + exclusion_spec, + excluded_venv_path, + ) + ), + ) + + for source_path in crawler.go(token=token): + self._check_token(token) + relative_path = source_path.relative_to(self.directory).as_posix() + archive.add(source_path, arcname=relative_path, recursive=False) + + self._check_token(token) + + logger.info('Dump completed successfully.') + return archive_buffer.getvalue() + except OperationCancelledError: + logger.exception('Dump operation was cancelled.') + raise + except InvalidVirtualEnvPathError as error: + logger.exception(f'Dump failed because the virtual environment is invalid: {error}.') # noqa: TRY401 - plain loggers need the diagnostic text. + raise + except (OSError, TarError) as error: + logger.exception(f'Dump failed: {error}.') # noqa: TRY401 - plain loggers need the diagnostic text. + raise + + def _load_unlocked(self, dump: bytes, logger: LoggerProtocol, token: AbstractToken) -> None: + """Stage, validate, and commit loaded contents after lock acquisition.""" + staging_directory: Optional[Path] = None + backup_directory: Optional[Path] = None + + try: + self._check_token(token) + + try: + exclusion_spec = self._create_exclusion_spec() + except ValueError as error: + logger.exception(f'Load failed because the exclude configuration is invalid: {error}.') # noqa: TRY401 - plain loggers need the diagnostic text. + raise + + excluded_venv_path = self._resolve_excluded_venv_path() + staging_directory = Path(mkdtemp(prefix='throng-load-')) + backup_directory = Path(mkdtemp(prefix='throng-backup-')) + + logger.info(f'Loading isolate "{self.directory}".') + logger.debug(f'Load compression mode: {self.config.compression}.') + + self._extract_to_staging(dump, staging_directory, token, exclusion_spec, excluded_venv_path) + self._check_token(token) + self._commit_staged_tree(staging_directory, backup_directory, exclusion_spec, excluded_venv_path) + + logger.info('Load completed successfully.') + except InvalidVirtualEnvPathError as error: + logger.exception(f'Load failed because the virtual environment is invalid: {error}.') # noqa: TRY401 - plain loggers need the diagnostic text. + raise + except (OperationCancelledError, ArchiveUnpackError) as error: + if isinstance(error, OperationCancelledError): + message = 'Load operation was cancelled.' + else: + archive_reason = str(error) + archive_prefix = 'archive unpack failed: ' + + if archive_reason.startswith(archive_prefix): + archive_reason = archive_reason[len(archive_prefix):] + + message = f'Archive unpack failed: {archive_reason}.' + logger.exception(message) + raise + except OSError as error: + reason = error.strerror or str(error) + unpack_error = ArchiveUnpackError(f'archive unpack failed: cannot create temporary load directories: {reason}') + logger.exception(f'Archive unpack failed: cannot create temporary load directories: {reason}.') + raise unpack_error from error + finally: + for temporary_directory in (path for path in (staging_directory, backup_directory) if path is not None): + rmtree(temporary_directory, ignore_errors=True) + + def _delete_unlocked(self, logger: LoggerProtocol) -> None: + """Mark deletion and apply this isolate's configured cleanup strategy.""" + self._mark_deleted(logger) + + logger.info(f'Deleting isolate "{self.directory}".') + + try: + if self.temporary_directory_manager is not None: + self.temporary_directory_manager.cleanup() + elif self.owns_directory: + rmtree(self.directory) + + if self.directory_finalizer is not None: + self.directory_finalizer.detach() + except OSError as error: + with self.lock_only_for_delete: + self.deleted = False + + logger.exception(f'Delete failed: {error}.') # noqa: TRY401 - plain loggers need the diagnostic text. + raise + + logger.info('Delete completed successfully.') + + def _extract_to_staging( + self, + dump: bytes, + staging_directory: Path, + token: AbstractToken, + exclusion_spec: PathSpec, + excluded_venv_path: Optional[PurePosixPath], + ) -> None: + """ + Validate archive members and copy accepted contents into staging. + + Excluded paths are ignored in incoming bytes so existing excluded + paths can later be restored. Cancellation is checked per archive + member; copying the bytes of one regular file is intentionally a + bounded but currently non-interruptible unit. + """ + member_names: Set[str] = set() + member_kinds: Dict[str, str] = {} + + try: + archive_mode = self.COMPRESSION_TO_TAR_MODE[self.config.compression] + + with open_tar(fileobj=BytesIO(dump), mode=archive_mode) as archive: + for member in archive: + self._check_token(token) + normalized_name = self._validate_member(member, member_names, member_kinds) + + if not member.isdir() and not member.isfile(): + raise ArchiveUnpackError(f'Archive contains unsafe entry: {member.name}') + + if normalized_name is None: + continue + + member_names.add(normalized_name) + member_kinds[normalized_name] = 'dir' if member.isdir() else 'file' + + if self._is_path_excluded(normalized_name, exclusion_spec, excluded_venv_path): + continue + + target_path = staging_directory / normalized_name + if member.isdir(): + target_path.mkdir(parents=True, exist_ok=True) + else: + target_path.parent.mkdir(parents=True, exist_ok=True) + source_file = cast(BinaryIO, archive.extractfile(member)) + + with source_file, target_path.open('wb') as target_file: + copyfileobj(source_file, target_file) + + target_path.chmod(member.mode & 0o777) + except (OSError, TarError) as error: + raise ArchiveUnpackError(f'archive unpack failed: {error}') from error + + def _validate_member(self, member: TarInfo, member_names: Set[str], member_kinds: Dict[str, str]) -> Optional[str]: + """ + Return a safe normalized member name or reject an unsafe archive. + + Only empty/current-directory entries yield ``None``. The method + rejects dangerous names, duplicate entries, and file/directory + conflicts before extraction; entry types are checked by the caller. + """ + raw_name = member.name + + if any(ord(character) < 32 or ord(character) == 127 for character in raw_name): + raise ArchiveUnpackError(f'Archive contains an unsafe control character in path: {raw_name!r}') + if '\\' in raw_name: + raise ArchiveUnpackError(f'Archive contains an unsafe backslash path: {raw_name}') + + member_path = PurePosixPath(raw_name) + + if member_path.is_absolute(): + raise ArchiveUnpackError(f'Archive contains an absolute path: {raw_name}') + if member_path.parts and len(member_path.parts[0]) == 2 and member_path.parts[0][1] == ':': + raise ArchiveUnpackError(f'Archive contains an absolute Windows drive path: {raw_name}') + if '..' in member_path.parts: + raise ArchiveUnpackError(f'Archive contains path traversal outside the isolate: {raw_name}') + + normalized_name = member_path.as_posix() + + if normalized_name == '.': + return None + if normalized_name in member_names: + raise ArchiveUnpackError(f'Archive contains a duplicate entry: {normalized_name}') + + for known_name, member_kind in member_kinds.items(): + if (member_kind == 'file' and normalized_name.startswith(f'{known_name}/')) or (member.isfile() and known_name.startswith(f'{normalized_name}/')): + raise ArchiveUnpackError(f'Archive contains a file/directory conflict: {normalized_name}') + + return normalized_name + + def _commit_staged_tree( + self, + staging_directory: Path, + backup_directory: Path, + exclusion_spec: PathSpec, + excluded_venv_path: Optional[PurePosixPath], + ) -> None: + """ + Replace live non-excluded contents and roll back commit failures. + + Existing contents are first moved to backup. Staged contents become + the new source of truth, after which excluded old paths are restored. + Any failure attempts to reconstruct the prior tree and reports paths + that could not be restored. + """ + backup_complete = False + backed_up_paths: Set[str] = set() + restored_excluded_paths: List[Path] = [] + + try: + for child_path in self.directory.iterdir(): + move(str(child_path), str(backup_directory / child_path.name)) + backed_up_paths.add(child_path.name) + + backup_complete = True + + for child_path in staging_directory.iterdir(): + move(str(child_path), str(self.directory / child_path.name)) + + self._restore_excluded_paths(backup_directory, exclusion_spec, excluded_venv_path, restored_excluded_paths) + except (ArchiveUnpackError, OSError, ShutilError) as error: + unrestored_paths: List[str] = [] + + if backup_complete: + for restored_path in reversed(restored_excluded_paths): + try: + source_path = self.directory / restored_path + backup_path = backup_directory / restored_path + backup_path.parent.mkdir(parents=True, exist_ok=True) + move(str(source_path), str(backup_path)) + except (OSError, ShutilError): + unrestored_paths.append(restored_path.as_posix()) + + for child_path in self.directory.iterdir(): + try: + self._remove_path(child_path) + except OSError: + if child_path.name not in backed_up_paths: + unrestored_paths.append(child_path.name) + + for child_path in backup_directory.iterdir(): + if child_path.name not in backed_up_paths: + continue + + try: + destination_path = self.directory / child_path.name + + if destination_path.exists() or destination_path.is_symlink(): + self._remove_path(destination_path) + + move(str(child_path), str(destination_path)) + except (OSError, ShutilError): + unrestored_paths.append(child_path.name) + + unrestored_suffix = f' Unrestored paths: {", ".join(unrestored_paths)}.' if unrestored_paths else '' + reason = error.strerror if isinstance(error, OSError) and error.strerror else str(error) + + raise ArchiveUnpackError(f'Archive commit failed; rollback attempted. Cause: {reason}.{unrestored_suffix}') from error + + def _restore_excluded_paths( + self, + backup_directory: Path, + exclusion_spec: PathSpec, + excluded_venv_path: Optional[PurePosixPath], + restored_paths: List[Path], + ) -> None: + """Move excluded backup paths back over newly staged contents.""" + def get_restore_order(source_path: Path) -> Tuple[int, str]: + """Sort parent paths before descendants during excluded restoration.""" + relative_path = source_path.relative_to(backup_directory) + return len(relative_path.parts), relative_path.as_posix() + + restored_directory_names: List[str] = [] + source_paths: List[Path] = list(backup_directory.rglob('*')) + source_paths.sort(key=get_restore_order) + + for source_path in source_paths: + relative_path = source_path.relative_to(backup_directory) + relative_name = relative_path.as_posix() + + if any(relative_name == restored_name or relative_name.startswith(f'{restored_name}/') for restored_name in restored_directory_names): + continue + if not self._is_path_excluded(relative_name, exclusion_spec, excluded_venv_path): + continue + + destination_path = self.directory / relative_path + if destination_path.parent.exists() and not destination_path.parent.is_dir(): + raise ArchiveUnpackError(f'Archive commit failed; excluded path parent is a file: {relative_path.parent.as_posix()}') + + destination_path.parent.mkdir(parents=True, exist_ok=True) + + if destination_path.exists() or destination_path.is_symlink(): + self._remove_path(destination_path) + + move(str(source_path), str(destination_path)) + restored_paths.append(relative_path) + + if destination_path.is_dir() and not destination_path.is_symlink(): + restored_directory_names.append(relative_name) + + def _remove_path(self, path: Path) -> None: + """Remove one filesystem node without following symbolic links.""" + if path.is_dir() and not path.is_symlink(): + rmtree(path) + else: + path.unlink(missing_ok=True) + + def _raise_if_deleted(self, logger: LoggerProtocol, operation_name: str) -> None: + """Reject an operation after this object has entered its deleted state.""" + with self.lock_only_for_delete: + deleted = self.deleted + + if deleted: + logger.error(f'{operation_name.capitalize()} rejected because the isolate has been deleted.') + raise IsolateDeletedError('Isolate has been deleted.') + + def _mark_deleted(self, logger: LoggerProtocol) -> None: + """Atomically begin deletion or reject an already deleted isolate.""" + with self.lock_only_for_delete: + if not self.deleted: + self.deleted = True + return + + logger.error('Delete rejected because the isolate has been deleted.') + raise IsolateDeletedError('Isolate has been deleted.') + + def _map_subprocess_result(self, subprocess_result: SubprocessResult) -> RunResult: + """Detach public command results from the suby result type.""" + return RunResult( + id=str(subprocess_result.id), + stdout=subprocess_result.stdout, + stderr=subprocess_result.stderr, + returncode=subprocess_result.returncode, + ) + + def _combine_loggers(self, operation_logger: LoggerProtocol) -> LoggerProtocol: + """Add an operation logger to inherited logging without duplication.""" + if self.logger is operation_logger or isinstance(operation_logger, EmptyLogger): + return self.logger + if isinstance(self.logger, EmptyLogger): + return operation_logger + return LoggersGroup(self.logger, operation_logger) + + def _check_token(self, token: AbstractToken) -> None: + """Raise the library cancellation exception for a cancelled token.""" + try: + token.check() + except CancellationError as error: + raise OperationCancelledError('The operation was cancelled.') from error + + def _resolve_venv_path(self) -> Path: + """Resolve a configured venv path while keeping it inside the isolate.""" + configured_path = Path(self.config.venv_path) + + if configured_path.is_absolute(): + raise InvalidVirtualEnvPathError(f'Virtual environment path must be relative to the isolate directory, got absolute path: {configured_path}') + + resolved_path = (self.directory / configured_path).resolve() + isolate_path = self.directory.resolve() + + if resolved_path != isolate_path and isolate_path not in resolved_path.parents: + raise InvalidVirtualEnvPathError(f'Virtual environment path escapes outside the isolate directory: {configured_path}') + + return resolved_path + + def _get_venv_python_path(self, venv_path: Path) -> Path: + """Return the platform-specific Python executable path in a venv.""" + if os_name == 'nt': + return venv_path / 'Scripts' / 'python.exe' + + return venv_path / 'bin' / 'python' + + def _validate_venv_python_path(self, python_path: Path) -> None: + """Require a runnable Python executable at the configured venv path.""" + if not python_path.exists(): + raise InvalidVirtualEnvPathError(f'Virtual environment python executable is missing: {python_path}') + if not python_path.is_file(): + raise InvalidVirtualEnvPathError(f'Virtual environment python executable is not a regular file: {python_path}') + if os_name != 'nt' and not access(python_path, X_OK): + raise InvalidVirtualEnvPathError(f'Virtual environment python executable is not executable: {python_path}') + + def _build_run_add_env(self, add_env: Optional[Mapping[str, str]], env: Optional[Mapping[str, str]]) -> Optional[Mapping[str, str]]: + """ + Merge additions with venv activation only when that venv exists. + + Setting ``use_venv`` does not create a virtual environment during + ``run``; activation variables are injected only after installation has + created the configured venv directory. + """ + environment_additions: Dict[str, str] = dict(add_env or {}) + + if self.config.use_venv: + venv_path = self._resolve_venv_path() + + if venv_path.exists(): + python_path = self._get_venv_python_path(venv_path) + self._validate_venv_python_path(python_path) + + bin_path = python_path.parent + if 'PATH' in environment_additions: + current_path = environment_additions['PATH'] + elif env is not None: + current_path = env.get('PATH', '') + else: + current_path = environ.get('PATH', '') + + environment_additions['VIRTUAL_ENV'] = str(venv_path) + environment_additions['PATH'] = f'{bin_path}{pathsep}{current_path}' if current_path else str(bin_path) + + return environment_additions or None + + def _create_exclusion_spec(self) -> PathSpec: + """Compile current mutable exclusion settings before each archive action.""" + try: + return PathSpec.from_lines('gitwildmatch', self.config.dump_exclude) + except GitWildMatchPatternError as error: + raise ValueError('Invalid dump exclude pattern.') from error + + def _resolve_excluded_venv_path(self) -> Optional[PurePosixPath]: + """Return the literal effective venv subtree excluded by default.""" + if not self.config.use_venv or not self.config.exclude_venv: + return None + + venv_path = self._resolve_venv_path() + relative_venv_path = venv_path.relative_to(self.directory.resolve()).as_posix() + + return PurePosixPath(relative_venv_path) + + def _is_path_excluded( + self, + relative_name: str, + exclusion_spec: PathSpec, + excluded_venv_path: Optional[PurePosixPath], + ) -> bool: + """Return whether one relative archive path must remain untouched.""" + relative_path = PurePosixPath(relative_name) + + if excluded_venv_path is not None and (relative_path == excluded_venv_path or excluded_venv_path in relative_path.parents): + return True + + return exclusion_spec.match_file(relative_path.as_posix()) diff --git a/throng/plugins/empty_lock.py b/throng/plugins/empty_lock.py new file mode 100644 index 0000000..7fca218 --- /dev/null +++ b/throng/plugins/empty_lock.py @@ -0,0 +1,25 @@ +from types import TracebackType +from typing import Optional, Type + + +class EmptyLock: + """ + Provide the context-lock interface while deliberately doing no locking. + + Temporary isolates receive this lock so they can reuse ``DirectoryIsolate`` + without acquiring the serialization policy of the local plugin. + """ + + def __enter__(self) -> None: + """Enter a no-op critical section.""" + self.acquire() + + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]) -> None: + """Leave a no-op critical section without suppressing exceptions.""" + self.release() + + def acquire(self) -> None: + """Accept a lock acquisition request without blocking.""" + + def release(self) -> None: + """Accept a matching lock release request without side effects.""" diff --git a/throng/plugins/local_throng.py b/throng/plugins/local_throng.py new file mode 100644 index 0000000..65c4e67 --- /dev/null +++ b/throng/plugins/local_throng.py @@ -0,0 +1,32 @@ +from pathlib import Path +from threading import RLock +from typing import Optional + +from emptylog import EmptyLogger, LoggerProtocol + +from throng.abstracts.throng import AbstractThrong +from throng.plugins.directory_isolate import DirectoryIsolate, DirectoryIsolationConfig + + +class LocalDirectoryThrong(AbstractThrong): + """ + Create isolates that operate in the caller's current working directory. + + Every isolate returned by one throng shares its reentrant lock. The lock + is reentrant because dependency installation performs nested ``run`` calls + while the outer install operation already owns the lock. + """ + + def __init__(self, logger: LoggerProtocol = EmptyLogger(), config: Optional[DirectoryIsolationConfig] = None) -> None: # noqa: B008 + """Initialize local settings, inherited logging, and operation lock.""" + self.logger = logger + self.config = config if config is not None else DirectoryIsolationConfig() + self.lock = RLock() + + def get_isolate(self) -> DirectoryIsolate: + """Return an isolate bound to the current working directory at creation.""" + working_directory = Path.cwd() + + self.logger.info(f'Creating local isolate in current working directory "{working_directory}".') + + return DirectoryIsolate(working_directory, self.config, self.logger, lock=self.lock) diff --git a/throng/plugins/plugins.py b/throng/plugins/plugins.py new file mode 100644 index 0000000..d4d534c --- /dev/null +++ b/throng/plugins/plugins.py @@ -0,0 +1,18 @@ +from emptylog import EmptyLogger, LoggerProtocol + +from throng.abstracts.throng import AbstractThrong +from throng.plugins.local_throng import LocalDirectoryThrong +from throng.plugins.temporary_directory_throng import TemporaryDirectoryThrong +from throng.slots import throngs + + +@throngs.plugin('local', unique=True) +def create_local_throng(logger: LoggerProtocol = EmptyLogger()) -> AbstractThrong: # noqa: B008 + """Construct the unique built-in provider for current-directory execution.""" + return LocalDirectoryThrong(logger=logger) + + +@throngs.plugin('temporary_directory', unique=True) +def create_temporary_directory_throng(logger: LoggerProtocol = EmptyLogger()) -> AbstractThrong: # noqa: B008 + """Construct the unique built-in provider for temporary-directory execution.""" + return TemporaryDirectoryThrong(logger=logger) diff --git a/throng/plugins/temporary_directory_throng.py b/throng/plugins/temporary_directory_throng.py new file mode 100644 index 0000000..07a18dc --- /dev/null +++ b/throng/plugins/temporary_directory_throng.py @@ -0,0 +1,77 @@ +# mypy: disable-error-code=misc +# skelet's Storage metaclass exposes Any through class-definition metadata. +from os import W_OK, access +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional +from uuid import uuid4 + +from emptylog import EmptyLogger, LoggerProtocol +from skelet import Field, for_tool + +from throng.abstracts.throng import AbstractThrong +from throng.errors import InvalidBaseDirectoryError +from throng.plugins.directory_isolate import DirectoryIsolate, DirectoryIsolationConfig +from throng.plugins.empty_lock import EmptyLock + + +class TemporaryDirectoryIsolationConfig(DirectoryIsolationConfig, sources=for_tool('temporary_directory')): + """Configure temporary isolates from their independent skelet source.""" + + base_directory: Optional[str] = Field(None, doc='existing writable parent directory for created temporary isolate directories') + + +class TemporaryDirectoryThrong(AbstractThrong): + """ + Create isolated disposable working directories without serialization. + + With no configured base, directories are managed by + ``tempfile.TemporaryDirectory``. With a base, each isolate owns a new + UUID-hex child directory below that existing writable base. + """ + + def __init__(self, logger: LoggerProtocol = EmptyLogger(), config: Optional[TemporaryDirectoryIsolationConfig] = None) -> None: # noqa: B008 + """Initialize the inherited logger and temporary-plugin settings.""" + self.logger = logger + self.config = config if config is not None else TemporaryDirectoryIsolationConfig() + + def get_isolate(self) -> DirectoryIsolate: + """Create a fresh owned directory and return an isolate with a no-op lock.""" + if self.config.base_directory is None: + temporary_directory_manager = TemporaryDirectory() + isolate_directory = Path(temporary_directory_manager.name) + + self.logger.info(f'Creating temporary isolate in stdlib temporary directory "{isolate_directory}".') + + return DirectoryIsolate( + isolate_directory, + self.config, + self.logger, + lock=EmptyLock(), + temporary_directory_manager=temporary_directory_manager, + ) + + base_directory = Path(self.config.base_directory) + self._validate_base_directory(base_directory) + + isolate_directory = base_directory / uuid4().hex + isolate_directory.mkdir() + + self.logger.info(f'Creating temporary isolate "{isolate_directory}" inside base directory "{base_directory}".') + + return DirectoryIsolate(isolate_directory, self.config, self.logger, lock=EmptyLock(), owns_directory=True) + + def _validate_base_directory(self, base_directory: Path) -> None: + """Reject configured bases that cannot contain newly created isolates.""" + validation_error_message = None + + if not base_directory.exists(): + validation_error_message = f'Temporary base directory does not exist: {base_directory}' + elif not base_directory.is_dir(): + validation_error_message = f'Temporary base path is not a directory: {base_directory}' + elif not access(base_directory, W_OK): + validation_error_message = f'Temporary base directory is not writable: {base_directory}' + + if validation_error_message is not None: + self.logger.error(validation_error_message) + raise InvalidBaseDirectoryError(validation_error_message) From e9ad6f33f8b6e02a91ee9dd61b28986e7ed62ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 16:12:37 +0300 Subject: [PATCH 25/59] Add directory_isolate tests and helpers --- tests/helpers.py | 58 + tests/plugins/__init__.py | 0 tests/plugins/test_directory_isolate.py | 2351 +++++++++++++++++ tests/plugins/test_local_throng.py | 253 ++ tests/plugins/test_plugins.py | 77 + .../test_temporary_directory_throng.py | 493 ++++ tests/test_result.py | 122 + tests/test_slots.py | 46 + 8 files changed, 3400 insertions(+) create mode 100644 tests/helpers.py create mode 100644 tests/plugins/__init__.py create mode 100644 tests/plugins/test_directory_isolate.py create mode 100644 tests/plugins/test_local_throng.py create mode 100644 tests/plugins/test_plugins.py create mode 100644 tests/plugins/test_temporary_directory_throng.py create mode 100644 tests/test_result.py create mode 100644 tests/test_slots.py diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..7515761 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,58 @@ +from io import BytesIO +from pathlib import Path +from tarfile import TarInfo +from tarfile import open as open_tar +from typing import Dict, Iterable, Optional + +from dirstree import Crawler +from emptylog.call_data import LoggerCallData + + +def assert_any_message_contains(calls: Iterable[LoggerCallData], *chunks: str) -> None: + """Assert that at least one logged message contains all chunks, case-insensitively.""" + messages = [str(call.message) for call in calls] + lowered_chunks = [chunk.lower() for chunk in chunks] + + for message in messages: + lowered_message = message.lower() + + if all(chunk in lowered_message for chunk in lowered_chunks): + return + + raise AssertionError(f'No log message contains {chunks!r}. Messages: {messages!r}') + + +def make_tar_bytes( + entries: Dict[str, bytes], + *, + compression: str = 'none', + modes: Optional[Dict[str, int]] = None, +) -> bytes: + archive_buffer = BytesIO() + archive_mode = { + 'none': 'w', + 'gzip': 'w:gz', + 'bz2': 'w:bz2', + 'lzma': 'w:xz', + }[compression] + + with open_tar(fileobj=archive_buffer, mode=archive_mode) as archive: + for name, content in entries.items(): + member_info = TarInfo(name) + member_info.size = len(content) + + if modes is not None and name in modes: + member_info.mode = modes[name] + + archive.addfile(member_info, BytesIO(content)) + + return archive_buffer.getvalue() + + +def read_tree(root: Path) -> Dict[str, bytes]: + file_contents: Dict[str, bytes] = {} + + for file_path in Crawler(root).go(): + file_contents[file_path.relative_to(root).as_posix()] = file_path.read_bytes() + + return file_contents diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py new file mode 100644 index 0000000..d001d4b --- /dev/null +++ b/tests/plugins/test_directory_isolate.py @@ -0,0 +1,2351 @@ +import tempfile +from io import BytesIO +from os import environ, link, pathsep +from os import name as os_name +from pathlib import Path +from stat import S_IMODE, S_IREAD, S_IRWXU, S_ISGID, S_ISUID, S_ISVTX, S_IWRITE, S_IXUSR +from subprocess import run as run_process +from sys import executable +from tarfile import CHRTYPE, DIRTYPE, LNKTYPE, PAX_FORMAT, SYMTYPE, TarInfo +from tarfile import open as open_tar +from time import monotonic +from typing import Dict, List, Mapping, Optional, Protocol, Tuple, cast + +import pytest +from cantok import CounterToken, SimpleToken, TimeoutToken +from emptylog import EmptyLogger, MemoryLogger +from full_match import match +from suby import RunningCommandError +from suby.subprocess_result import SubprocessResult + +from tests.helpers import ( + assert_any_message_contains, + make_tar_bytes, + read_tree, +) +from throng import ( + ArchiveUnpackError, + CommandExecutionError, + InstallError, + InvalidVirtualEnvPathError, + OperationCancelledError, + throngs, +) +from throng.plugins.directory_isolate import DirectoryIsolate +from throng.plugins.directory_isolate import mkdtemp as directory_mkdtemp +from throng.plugins.directory_isolate import move as directory_move +from throng.plugins.directory_isolate import run_suby as directory_run_suby +from throng.plugins.temporary_directory_throng import ( + TemporaryDirectoryIsolationConfig, + TemporaryDirectoryThrong, +) + + +class TemporaryIsolateFactory(Protocol): + def __call__( + self, + *, + compression: str = 'none', + use_venv: bool = True, + exclude_venv: bool = True, + dump_exclude: Optional[List[str]] = None, + venv_path: str = '.venv', + ) -> DirectoryIsolate: + ... + + +@pytest.fixture +def temporary_isolate(tmp_path: Path) -> TemporaryIsolateFactory: + def create( + *, + compression: str = 'none', + use_venv: bool = True, + exclude_venv: bool = True, + dump_exclude: Optional[List[str]] = None, + venv_path: str = '.venv', + ) -> DirectoryIsolate: + config = TemporaryDirectoryIsolationConfig( + base_directory=str(tmp_path), + compression=compression, + use_venv=use_venv, + exclude_venv=exclude_venv, + dump_exclude=dump_exclude or [], + venv_path=venv_path, + ) + + return TemporaryDirectoryThrong(config=config).get_isolate() + + return create + + +def test_run_precancelled_token_stops_before_suby(tmp_path, monkeypatch): + """Verify that a pre-cancelled run token stops before the subprocess runner is called.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + token = SimpleToken().cancel() + marker = tmp_path / 'created-by-cancelled-command' + + def forbidden_run(*_args, **_kwargs): + raise AssertionError('suby.run must not be called') + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', forbidden_run) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['local'].get_isolate().run( + executable, + '-c', + f'from pathlib import Path; Path({str(marker)!r}).write_text("created")', + token=token, + logger=logger, + ) + + assert not marker.exists() + assert_any_message_contains(logger.data.exception, 'run', 'cancelled') + + +def test_run_killed_by_token_result_raises_operation_cancelled(tmp_path, monkeypatch): + """Verify that a subprocess result marked killed_by_token is normalized to OperationCancelledError.""" + monkeypatch.chdir(tmp_path) + + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='cancelled-run', killed_by_token=True) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['local'].get_isolate().run('anything') + + assert calls == [('anything',)] + + +def test_run_killed_by_token_suby_error_raises_operation_cancelled(tmp_path, monkeypatch): + """Verify that a suby error carrying a cancellation result is normalized to OperationCancelledError.""" + monkeypatch.chdir(tmp_path) + logger = MemoryLogger() + + def fake_run(*_args, **_kwargs): + raise RunningCommandError('cancelled', SubprocessResult(id='cancelled-error', killed_by_token=True)) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['local'].get_isolate().run('anything', logger=logger) + + assert_any_message_contains(logger.data.exception, 'run', 'cancelled') + + +def test_run_mid_execution_cancelled(tmp_path, monkeypatch): + """Verify that a timeout token firing during a long-running command raises OperationCancelledError.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['local'].get_isolate().run(executable, '-c', 'import time; time.sleep(5)', token=TimeoutToken(0.1), split=False) + + +def test_install_precancelled_token_stops_before_pip(tmp_path, monkeypatch): + """Verify that a pre-cancelled install token stops before any pip or venv subprocess is called.""" + monkeypatch.chdir(tmp_path) + + def fake_run(*_args, **_kwargs): + raise AssertionError('pip must not be called') + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['local'].get_isolate().install('example', token=SimpleToken().cancel()) + + +def test_install_killed_by_token_result_raises_operation_cancelled(tmp_path, monkeypatch): + """Verify that install normalizes and logs cancellation reported by its nested pip run.""" + calls: List[Tuple[object, ...]] = [] + logger = MemoryLogger() + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='cancelled-install', killed_by_token=True) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + isolate.install('example', logger=logger) + + assert calls == [(executable, '-m', 'pip', 'install', 'example')] + assert_any_message_contains(logger.data.error, 'run', 'cancelled') + assert_any_message_contains(logger.data.exception, 'install', 'cancelled') + + +def test_dump_precancelled_token_stops_before_walk(tmp_path, monkeypatch): + """Verify that a pre-cancelled dump token raises before walking the isolate tree.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['temporary_directory'].get_isolate().dump(token=SimpleToken().cancel()) + + +def test_dump_mid_walk_cancelled(tmp_path): + """Verify that cancellation during dump traversal raises OperationCancelledError instead of returning bytes.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + for index in range(25): + (isolate.directory / f'{index}.txt').write_text(str(index)) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + isolate.dump(token=CounterToken(5)) + + +def test_load_precancelled_token_stops_before_staging(tmp_path): + """Verify that pre-cancelled load logs cancellation and leaves the isolate tree untouched before staging.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + logger = MemoryLogger() + (isolate.directory / 'old.txt').write_text('old') + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + isolate.load(make_tar_bytes({'new.txt': b'new'}), logger=logger, token=SimpleToken().cancel()) + + assert (isolate.directory / 'old.txt').read_text() == 'old' + assert not (isolate.directory / 'new.txt').exists() + assert_any_message_contains(logger.data.exception, 'load', 'cancelled') + + +def test_load_cancellation_during_staging(tmp_path, monkeypatch): + """Verify that cancellation during load staging keeps the original tree and removes staging directories.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + (isolate.directory / 'old.txt').write_text('old') + archive = make_tar_bytes({f'{index}.txt': str(index).encode() for index in range(25)}) + created_temp_dirs: List[Path] = [] + + def tracking_mkdtemp(*args, **kwargs): + path = Path(directory_mkdtemp(*args, **kwargs)) + created_temp_dirs.append(path) + return str(path) + + monkeypatch.setattr('throng.plugins.directory_isolate.mkdtemp', tracking_mkdtemp) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + isolate.load(archive, token=CounterToken(5)) + + assert (isolate.directory / 'old.txt').read_text() == 'old' + assert created_temp_dirs + assert all(not path.exists() for path in created_temp_dirs) + + +def test_load_cancellation_does_not_wait_for_all_archive_headers(tmp_path): + """Verify that cancellation is observed while traversing a large tar member list, before all headers are read.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + archive = make_tar_bytes({f'{index}.txt': b'' for index in range(80_000)}) + + started_at = monotonic() + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + isolate.load(archive, token=TimeoutToken(0.005)) + elapsed = monotonic() - started_at + + assert elapsed < 0.5 + + +def test_load_ignores_cancellation_after_commit_has_started(tmp_path, monkeypatch): + """Verify the plan guarantee that token cancellation during load commit does not interrupt tree replacement.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + token = SimpleToken() + (isolate.directory / 'old.txt').write_text('old') + cancellation_triggered = False + + def move_then_cancel_during_commit(source, destination): + nonlocal cancellation_triggered + result = directory_move(source, destination) + destination_path = Path(destination) + + if not cancellation_triggered and destination_path.parent.name.startswith('throng-backup-'): + token.cancel() + cancellation_triggered = True + + return result + + monkeypatch.setattr('throng.plugins.directory_isolate.move', move_then_cancel_during_commit) + + isolate.load(make_tar_bytes({'new.txt': b'new'}), token=token) + + assert cancellation_triggered is True + assert read_tree(isolate.directory) == {'new.txt': b'new'} + + +def test_load_success_removes_staging_and_backup_directories(tmp_path, monkeypatch): + """Verify that successful load removes both temporary directories created for staging and rollback.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + created_temp_dirs: List[Path] = [] + + def tracking_mkdtemp(*args, **kwargs): + path = Path(directory_mkdtemp(*args, **kwargs)) + created_temp_dirs.append(path) + return str(path) + + monkeypatch.setattr('throng.plugins.directory_isolate.mkdtemp', tracking_mkdtemp) + + isolate.load(make_tar_bytes({'new.txt': b'new'})) + + assert (isolate.directory / 'new.txt').read_bytes() == b'new' + assert len(created_temp_dirs) == 2 + assert all(not path.exists() for path in created_temp_dirs) + + +def test_load_validation_failure_removes_staging_and_backup_directories(tmp_path, monkeypatch): + """Verify that validation failure leaves no staging or rollback directory behind.""" + config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + created_temp_dirs: List[Path] = [] + + def tracking_mkdtemp(*args, **kwargs): + path = Path(directory_mkdtemp(*args, **kwargs)) + created_temp_dirs.append(path) + return str(path) + + monkeypatch.setattr('throng.plugins.directory_isolate.mkdtemp', tracking_mkdtemp) + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains an absolute path: /outside.txt')): + isolate.load(make_tar_bytes({'/outside.txt': b'unsafe'})) + + assert len(created_temp_dirs) == 2 + assert all(not path.exists() for path in created_temp_dirs) + + +def test_run_empty_arguments(tmp_path, monkeypatch): + """Verify that calling run without command arguments raises CommandExecutionError instead of silently succeeding.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(CommandExecutionError, match=match('Cannot run an empty command.')): + throngs()['local'].get_isolate().run() + + +def test_run_split_true_false(tmp_path, monkeypatch): + """Verify that run forwards the requested split flag to the subprocess runner unchanged.""" + monkeypatch.chdir(tmp_path) + + observed: List[bool] = [] + + def fake_run(*_args, **kwargs): + observed.append(kwargs['split']) + return SubprocessResult(id='split', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + isolate = throngs()['local'].get_isolate() + + isolate.run('one two', split=True) + isolate.run('one two', split=False) + + assert observed == [True, False] + + +def test_run_default_split_parses_quoted_command_string(tmp_path, monkeypatch): + """Verify that default split=True parses a quoted command string without an explicit split argument.""" + monkeypatch.chdir(tmp_path) + + result = throngs()['local'].get_isolate().run('printf "one two"', catch_output=True) + + assert result.stdout == 'one two' + assert result.returncode == 0 + assert result.success is True + + +def test_run_double_backslash_forwarded_to_suby(tmp_path, monkeypatch): + """Verify that run forwards the double_backslash flag to the subprocess runner unchanged.""" + monkeypatch.chdir(tmp_path) + + observed: List[bool] = [] + + def fake_run(*_args, **kwargs): + observed.append(kwargs['double_backslash']) + return SubprocessResult(id='double-backslash', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + isolate = throngs()['local'].get_isolate() + + isolate.run('anything') + isolate.run('anything', double_backslash=True) + + assert observed == [False, True] + + +def test_run_directory_forwarded_to_suby(tmp_path, monkeypatch): + """Verify that run always forwards the isolate directory to the subprocess runner.""" + observed_directories: List[Path] = [] + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + def fake_run(*_args, **kwargs): + observed_directories.append(kwargs['directory']) + return SubprocessResult(id='directory', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + isolate.run('anything') + + assert observed_directories == [isolate.directory] + + +def test_run_env_override_visible_to_subprocess(tmp_path, monkeypatch): + """Verify that an explicit env mapping replaces the child process environment and is visible to the command.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('THRONG_PARENT_ONLY_ENV', 'parent') + + result = throngs()['local'].get_isolate().run( + executable, + '-c', + 'import os; print(os.environ.get("THRONG_TEST_ENV", "")); print(os.environ.get("THRONG_PARENT_ONLY_ENV", ""))', + catch_output=True, + env={'THRONG_TEST_ENV': 'override'}, + split=False, + ) + + assert result.stdout is not None + assert result.stdout.splitlines() == ['override', ''] + + +def test_run_add_env_visible_to_subprocess(tmp_path, monkeypatch): + """Verify that add_env adds variables to the child process environment.""" + monkeypatch.chdir(tmp_path) + + result = throngs()['local'].get_isolate().run( + executable, + '-c', + 'import os; print(os.environ.get("THRONG_TEST_ADD_ENV", ""))', + catch_output=True, + add_env={'THRONG_TEST_ADD_ENV': 'added'}, + split=False, + ) + + assert result.stdout is not None + assert result.stdout.strip() == 'added' + + +def test_run_delete_env_hidden_from_subprocess(tmp_path, monkeypatch): + """Verify that delete_env removes selected parent environment variables only from the child process.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('THRONG_TEST_DELETE_ENV', 'delete-me') + + result = throngs()['local'].get_isolate().run( + executable, + '-c', + 'import os; print(os.environ.get("THRONG_TEST_DELETE_ENV", ""))', + catch_output=True, + delete_env=['THRONG_TEST_DELETE_ENV'], + split=False, + ) + + assert result.stdout is not None + assert result.stdout.strip() == '' + assert environ['THRONG_TEST_DELETE_ENV'] == 'delete-me' + + +def test_run_combines_env_add_env_and_delete_env(tmp_path, monkeypatch): + """Verify that env, add_env, and delete_env combine into the child process environment.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('THRONG_ENV_REMOVED', 'remove-me') + + result = throngs()['local'].get_isolate().run( + executable, + '-c', + ( + 'import os; ' + 'print(os.environ.get("THRONG_ENV_BASE", "")); ' + 'print(os.environ.get("THRONG_ENV_ADDED", "")); ' + 'print(os.environ.get("THRONG_ENV_REMOVED", ""))' + ), + catch_output=True, + env={'THRONG_ENV_BASE': 'base'}, + add_env={'THRONG_ENV_ADDED': 'added'}, + delete_env=['THRONG_ENV_REMOVED'], + split=False, + ) + + assert result.stdout is not None + assert result.stdout.splitlines() == ['base', 'added', ''] + + +def test_run_env_conflict_raises_command_error_and_logs_failure(tmp_path, monkeypatch): + """Verify that suby env/add/delete conflicts are naturally wrapped as CommandExecutionError and logged.""" + monkeypatch.chdir(tmp_path) + logger = MemoryLogger() + + with pytest.raises(CommandExecutionError, match=match('Environment variables cannot be both set via env/add_env and deleted via delete_env: THRONG_ENV_CONFLICT.')): + throngs()['local'].get_isolate().run( + executable, + '-c', + 'print("never")', + env={'THRONG_ENV_CONFLICT': 'value'}, + delete_env=['THRONG_ENV_CONFLICT'], + logger=logger, + split=False, + ) + + assert_any_message_contains(logger.data.exception, 'run', 'failed') + + +def test_run_nonzero_raises_by_default(tmp_path, monkeypatch): + """Verify that a non-zero command raises throng CommandExecutionError by default and does not leak suby errors.""" + monkeypatch.chdir(tmp_path) + + expected_message = f'Command failed or output decoding failed: Error when executing the command "{executable} -c "import sys; sys.exit(9)"".' + with pytest.raises(CommandExecutionError, match=match(expected_message)) as error: + throngs()['local'].get_isolate().run(executable, '-c', 'import sys; sys.exit(9)', split=False) + + assert not isinstance(error.value, RunningCommandError) + + +def test_run_nonzero_catch_exceptions_returns_result(tmp_path, monkeypatch): + """Verify that catch_exceptions=True returns an unsuccessful RunResult for a non-zero command.""" + monkeypatch.chdir(tmp_path) + + result = throngs()['local'].get_isolate().run(executable, '-c', 'import sys; sys.exit(9)', catch_exceptions=True, split=False) + + assert result.returncode == 9 + assert result.success is False + + +def test_run_nonzero_backend_result_raises_by_default(tmp_path, monkeypatch): + """Verify that run raises the throng command error if a backend returns a non-zero result by default.""" + monkeypatch.chdir(tmp_path) + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='nonzero-result', stderr='failed', returncode=2) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + with pytest.raises(CommandExecutionError, match=match('Command failed with a non-zero exit code.')) as raised: + throngs()['local'].get_isolate().run('anything') + + assert raised.value.result is not None + assert raised.value.result.stderr == 'failed' + assert raised.value.result.returncode == 2 + assert raised.value.result.success is False + + +def test_run_startup_failure_maps_error(tmp_path, monkeypatch): + """Verify that a missing executable is wrapped in CommandExecutionError with an unsuccessful mapped result.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(CommandExecutionError, match=match('Command failed or output decoding failed: The executable for the command "not-a-real-throng-command" was not found.')) as error: + throngs()['local'].get_isolate().run('not-a-real-throng-command') + + assert error.value.result is not None + assert error.value.result.success is False + + +def test_run_timeout_cancelled(tmp_path, monkeypatch): + """Verify that a subprocess timeout is normalized to OperationCancelledError.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + throngs()['local'].get_isolate().run(executable, '-c', 'import time; time.sleep(5)', timeout=0.1, split=False) + + +def test_run_non_utf8_output_behavior(tmp_path, monkeypatch): + """Verify that non-UTF-8 captured output is surfaced as a command execution failure.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(CommandExecutionError, match=match("'utf-8' codec can't decode byte 0xff in position 0: invalid start byte")): + throngs()['local'].get_isolate().run( + executable, + '-c', + 'import sys; sys.stdout.buffer.write(b"\\xff")', + catch_output=True, + split=False, + ) + + +def test_install_use_venv_creates_missing_venv(tmp_path, monkeypatch): + """Verify that install creates the configured virtual environment when it is missing.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + if '-m' in args and 'venv' in args: + venv_path = Path(args[-1]) + (venv_path / 'bin').mkdir(parents=True) + (venv_path / 'bin' / 'python').write_text('') + (venv_path / 'bin' / 'python').chmod(S_IREAD | S_IWRITE | S_IXUSR) + return SubprocessResult(id='install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + isolate.install('example') + + assert (isolate.directory / '.venv').exists() + assert any('-m' in call and 'venv' in call for call in calls) + + +def test_install_use_venv_uses_existing_venv(tmp_path, monkeypatch): + """Verify that install reuses an existing virtual environment instead of recreating it.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python.parent.mkdir(parents=True) + venv_python.write_text('') + venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) + + isolate.install('example') + + assert not any('-m' in call and 'venv' in call for call in calls) + assert any(call[0] == str(venv_python) for call in calls) + + +def test_install_custom_valid_venv_path(tmp_path, monkeypatch): + """Verify that a custom relative venv_path is created and then activated by run.""" + calls: List[Tuple[Tuple[object, ...], Dict[str, object]]] = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + if '-m' in args and 'venv' in args: + venv_path = Path(args[-1]) + (venv_path / 'bin').mkdir(parents=True) + (venv_path / 'bin' / 'python').write_text('') + (venv_path / 'bin' / 'python').chmod(S_IREAD | S_IWRITE | S_IXUSR) + return SubprocessResult(id='install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True, venv_path='custom/env') + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + isolate.install('example') + + expected_venv_path = isolate.directory / 'custom' / 'env' + + assert expected_venv_path.exists() + assert any(str(expected_venv_path) in [str(part) for part in args] for args, _kwargs in calls) + + isolate.run('anything') + + latest_run_call = calls[-1] + + assert latest_run_call[0] == ('anything',) + assert isinstance(latest_run_call[1]['add_env'], dict) + assert latest_run_call[1]['add_env']['VIRTUAL_ENV'] == str(expected_venv_path) + assert str(expected_venv_path / 'bin') in str(latest_run_call[1]['add_env']['PATH']) + + +def test_install_venv_creation_failure_raises_install_error(tmp_path, monkeypatch): + """Verify that virtual environment creation failure raises InstallError before pip is invoked.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='venv-fail', stderr='venv failed', returncode=2) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with pytest.raises(InstallError, match=match('venv failed')): + isolate.install('example') + + assert calls == [(executable, '-m', 'venv', str(isolate.directory / '.venv'))] + + +def test_install_rejects_venv_command_that_does_not_create_python(tmp_path, monkeypatch): + """Verify that install rejects a reported-successful venv creation when its Python executable is absent.""" + logger = MemoryLogger() + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='venv-incomplete', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + python_path = isolate.directory / '.venv' / 'bin' / 'python' + + with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is missing: {python_path}')): + isolate.install('example', logger=logger) + + assert_any_message_contains(logger.data.exception, 'install', 'virtual environment python executable is missing') + + +def test_install_absolute_venv_path_rejected(tmp_path): + """Verify that an absolute venv_path is rejected as escaping the isolate directory.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), venv_path=str(tmp_path / 'outside')) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment path must be relative to the isolate directory, got absolute path: {tmp_path / "outside"}')): + isolate.install('example') + + +def test_install_parent_escape_venv_path_rejected(tmp_path): + """Verify that a parent-directory venv_path is rejected as escaping the isolate directory.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), venv_path='../venv') + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with pytest.raises(InvalidVirtualEnvPathError, match=match('Virtual environment path escapes outside the isolate directory: ../venv')): + isolate.install('example') + + +@pytest.mark.parametrize('operation_name', ['dump', 'load']) +def test_archive_operation_invalid_venv_path_is_logged(tmp_path, operation_name): + """Verify that dump and load log configuration failures raised while computing venv exclusions.""" + logger = MemoryLogger() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), venv_path='../outside', compression='none') + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + operations = { + 'dump': lambda: isolate.dump(logger=logger), + 'load': lambda: isolate.load(make_tar_bytes({'file.txt': b'content'}), logger=logger), + } + + with pytest.raises(InvalidVirtualEnvPathError, match=match('Virtual environment path escapes outside the isolate directory: ../outside')): + operations[operation_name]() + + assert_any_message_contains(logger.data.exception, operation_name, 'virtual environment', 'invalid') + + +def test_install_use_venv_false_global_pip(tmp_path, monkeypatch): + """Verify that use_venv=False installs with the current interpreter instead of a virtual environment.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='global-install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + isolate.install('example') + + assert calls == [(executable, '-m', 'pip', 'install', 'example')] + + +def test_install_multiple_packages(tmp_path, monkeypatch): + """Verify that installing multiple packages forwards all package names to pip.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='global-install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + isolate.install(('first-package', 'second-package')) + + assert calls == [(executable, '-m', 'pip', 'install', 'first-package', 'second-package')] + + +def test_install_empty_package_sequence_is_noop(tmp_path, monkeypatch): + """Verify that an empty package sequence does not invoke pip or venv creation.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='global-install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + isolate.install(()) + + assert calls == [] + + +@pytest.mark.parametrize('package_request', ['', ('',)]) +def test_install_empty_package_name_is_rejected_before_pip(tmp_path, monkeypatch, package_request): + """Verify that empty dependency names are rejected before any pip or venv subprocess is called.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='global-install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with pytest.raises(InstallError, match=match('Dependency name cannot be empty.')): + isolate.install(package_request) + + assert calls == [] + + +def test_install_failure(tmp_path, monkeypatch): + """Verify that a pip failure is wrapped as InstallError with the pip diagnostic.""" + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='pip-fail', stderr='bad package', returncode=2) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with pytest.raises(InstallError, match=match('bad package')): + isolate.install('bad-package') + + +def test_install_wraps_nested_command_execution_error(tmp_path, monkeypatch): + """Verify that install surfaces a nested run startup failure as InstallError and logs it.""" + logger = MemoryLogger() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + def failing_run(_self, *_args, **_kwargs): + raise CommandExecutionError('pip command could not be started.') + + monkeypatch.setattr(DirectoryIsolate, 'run', failing_run) + + with pytest.raises(InstallError, match=match('pip command could not be started.')): + isolate.install('example', logger=logger) + + assert_any_message_contains(logger.data.exception, 'install', 'pip command could not be started') + + +def test_run_activates_existing_valid_venv(tmp_path, monkeypatch): + """Verify that run activates an existing valid virtual environment through VIRTUAL_ENV and PATH.""" + observed_env: Optional[Mapping[str, str]] = None + + def fake_run(*_args, **kwargs): + nonlocal observed_env + observed_env = kwargs.get('add_env') + return SubprocessResult(id='venv-run', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python.parent.mkdir(parents=True) + venv_python.write_text('') + venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) + + isolate.run('anything') + + assert observed_env is not None + assert observed_env['VIRTUAL_ENV'] == str(isolate.directory / '.venv') + assert str((isolate.directory / '.venv' / 'bin')) in observed_env['PATH'] + + +def test_run_does_not_activate_virtual_environment_before_it_exists(tmp_path, monkeypatch): + """Verify the plan rule that use_venv alone does not inject activation variables before install creates the venv.""" + observed_add_env: Optional[Mapping[str, str]] = {'unexpected': 'value'} + + def fake_run(*_args, **kwargs): + nonlocal observed_add_env + observed_add_env = kwargs.get('add_env') + return SubprocessResult(id='no-venv-yet', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + isolate.run('anything') + + assert not (isolate.directory / '.venv').exists() + assert observed_add_env is None + + +def test_run_venv_path_prepends_user_add_env_path(tmp_path, monkeypatch): + """Verify that the venv bin directory is prepended before a user-provided add_env PATH.""" + observed_env: Optional[Mapping[str, str]] = None + + def fake_run(*_args, **kwargs): + nonlocal observed_env + observed_env = kwargs.get('add_env') + return SubprocessResult(id='venv-run', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python.parent.mkdir(parents=True) + venv_python.write_text('') + venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) + + isolate.run('anything', add_env={'PATH': 'custom-bin'}) + + assert observed_env is not None + assert observed_env['PATH'] == f'{venv_python.parent}{pathsep}custom-bin' + + +def test_run_venv_path_uses_user_env_path_when_add_env_path_is_absent(tmp_path, monkeypatch): + """Verify that the venv bin directory is prepended before an env PATH when add_env lacks PATH.""" + observed_env: Optional[Mapping[str, str]] = None + observed_add_env: Optional[Mapping[str, str]] = None + + def fake_run(*_args, **kwargs): + nonlocal observed_env, observed_add_env + observed_env = kwargs.get('env') + observed_add_env = kwargs.get('add_env') + return SubprocessResult(id='venv-run', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python.parent.mkdir(parents=True) + venv_python.write_text('') + venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) + + isolate.run('anything', env={'PATH': 'env-bin', 'ONLY_ENV': 'visible'}) + + assert observed_env == {'PATH': 'env-bin', 'ONLY_ENV': 'visible'} + assert observed_add_env is not None + assert observed_add_env['PATH'] == f'{venv_python.parent}{pathsep}env-bin' + + +def test_run_venv_path_respects_explicit_empty_environment(tmp_path): + """Verify that venv activation does not restore the parent PATH when env explicitly replaces it with nothing.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python.parent.mkdir(parents=True) + venv_python.write_text('') + venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) + + result = isolate.run( + executable, + '-c', + 'import os; print(os.environ.get("PATH", ""))', + catch_output=True, + env={}, + split=False, + ) + + assert result.stdout is not None + assert result.stdout.strip() == str(venv_python.parent.resolve()) + + +def test_run_broken_venv_error(tmp_path): + """Verify that run rejects a broken virtual environment when its python executable is missing.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + (isolate.directory / '.venv').mkdir() + + with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is missing: {isolate.directory / ".venv" / "bin" / "python"}')): + isolate.run('anything') + + +@pytest.mark.parametrize('operation_name', ['run', 'install']) +def test_operations_reject_directory_as_venv_python_executable(tmp_path, monkeypatch, operation_name): + """Verify that run and install reject a venv whose Python executable path is a directory.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='unexpected', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + python_path = isolate.directory / '.venv' / 'bin' / 'python' + python_path.mkdir(parents=True) + operations = { + 'run': lambda: isolate.run('anything'), + 'install': lambda: isolate.install('example'), + } + + with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is not a regular file: {python_path}')): + operations[operation_name]() + + assert calls == [] + + +@pytest.mark.skipif(os_name == 'nt', reason='POSIX executable permission checks do not apply on Windows') +@pytest.mark.parametrize('operation_name', ['run', 'install']) +def test_operations_reject_non_executable_venv_python(tmp_path, monkeypatch, operation_name): + """Verify that run and install reject a POSIX venv Python file without execute permission.""" + calls: List[Tuple[object, ...]] = [] + + def fake_run(*args, **_kwargs): + calls.append(args) + return SubprocessResult(id='unexpected', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + python_path = isolate.directory / '.venv' / 'bin' / 'python' + python_path.parent.mkdir(parents=True) + python_path.write_text('') + python_path.chmod(S_IREAD | S_IWRITE) + operations = { + 'run': lambda: isolate.run('anything'), + 'install': lambda: isolate.install('example'), + } + + with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is not executable: {python_path}')): + operations[operation_name]() + + assert calls == [] + + +def test_run_activates_windows_virtual_environment_python_layout(tmp_path, monkeypatch): + """The plan requires platform-specific venv activation; this checks the Windows Scripts layout.""" + observed_add_env: Dict[str, str] = {} + + def fake_run(*_args, **kwargs): + observed_add_env.update(kwargs['add_env']) + return SubprocessResult(id='windows-venv', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.os_name', 'nt') + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + python_path = isolate.directory / '.venv' / 'Scripts' / 'python.exe' + python_path.parent.mkdir(parents=True) + python_path.write_text('') + + isolate.run('anything') + + assert observed_add_env['VIRTUAL_ENV'] == str(isolate.directory / '.venv') + assert observed_add_env['PATH'].split(pathsep)[0] == str(python_path.parent) + + +def test_throng_logger_inherited_by_isolate(tmp_path, monkeypatch): + """Verify that a logger passed to throngs is inherited by created isolates and receives operation logs.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + isolate = throngs(logger=logger)['local'].get_isolate() + isolate.run('printf inherited', catch_output=True) + + assert_any_message_contains(logger.data.info, 'isolate') + assert_any_message_contains(logger.data.info, 'run') + + +def test_operation_logger_grouped_with_inherited_logger(tmp_path, monkeypatch): + """Verify that an operation logger is grouped with the inherited logger and both receive logs.""" + monkeypatch.chdir(tmp_path) + + inherited_logger = MemoryLogger() + operation_logger = MemoryLogger() + isolate = throngs(logger=inherited_logger)['local'].get_isolate() + + isolate.run('printf grouped', catch_output=True, logger=operation_logger) + + assert_any_message_contains(inherited_logger.data.info, 'run') + assert_any_message_contains(operation_logger.data.info, 'run') + + +def test_install_operation_logger_records_nested_run_once_in_each_logger(tmp_path, monkeypatch): + """Verify that install and its nested run emit concise records once to both configured loggers.""" + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + inherited_logger = MemoryLogger() + operation_logger = MemoryLogger() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(logger=inherited_logger, config=config).get_isolate() + info_before_install = len(inherited_logger.data.info) + + isolate.install('example', logger=operation_logger) + + expected_info = [ + 'Installing 1 dependency.', + f'Starting run in "{isolate.directory}".', + 'Run completed successfully.', + 'Install completed successfully.', + ] + expected_debug = [ + 'Installing dependencies with the current interpreter.', + 'Run environment additions: [].', + ] + assert [str(call.message) for call in inherited_logger.data.info[info_before_install:]] == expected_info + assert [str(call.message) for call in operation_logger.data.info] == expected_info + assert [str(call.message) for call in inherited_logger.data.debug] == expected_debug + assert [str(call.message) for call in operation_logger.data.debug] == expected_debug + + +def test_same_inherited_and_operation_logger_does_not_duplicate_records(tmp_path, monkeypatch): + """Verify that passing the inherited logger again does not duplicate log records.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + isolate = throngs(logger=logger)['local'].get_isolate() + before = len(logger.data.info) + + isolate.run('printf dedupe', catch_output=True, logger=logger) + + messages = [str(call.message) for call in logger.data.info[before:]] + assert messages + assert len(messages) == len(set(messages)) + + +def test_default_operation_logger_does_not_duplicate_empty_logger(tmp_path, monkeypatch): + """Verify that the default EmptyLogger path emits the same inherited logs as an explicit EmptyLogger.""" + monkeypatch.chdir(tmp_path) + + default_logger = MemoryLogger() + explicit_empty_logger = MemoryLogger() + default_isolate = throngs(logger=default_logger)['local'].get_isolate() + explicit_empty_isolate = throngs(logger=explicit_empty_logger)['local'].get_isolate() + + default_isolate.run('printf inherited-only', catch_output=True) + explicit_empty_isolate.run('printf inherited-only', catch_output=True, logger=EmptyLogger()) + + default_messages = [str(call.message) for call in default_logger.data.info] + explicit_empty_messages = [str(call.message) for call in explicit_empty_logger.data.info] + assert explicit_empty_messages == default_messages + + +def test_logging_run_success_levels(tmp_path, monkeypatch): + """Verify that a successful run records one throng lifecycle without suby duplication.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + isolate = throngs(logger=logger)['local'].get_isolate() + + isolate.run('printf ok', catch_output=True) + + assert [str(call.message) for call in logger.data.info] == [ + f'Creating local isolate in current working directory "{tmp_path}".', + f'Starting run in "{tmp_path}".', + 'Run completed successfully.', + ] + assert_any_message_contains(logger.data.debug, 'environment') + + +@pytest.mark.parametrize('explicit_log_run', [None, False]) +def test_log_run_disabled_omits_suby_logger_and_backend_messages(tmp_path, monkeypatch, explicit_log_run): + """Verify that omitted or false log_run keeps suby internal command messages out of the operation logger.""" + logger = MemoryLogger() + observed_keyword_arguments: Dict[str, object] = {} + configuration_arguments = {'log_run': explicit_log_run} if explicit_log_run is not None else {} + config = TemporaryDirectoryIsolationConfig( + base_directory=str(tmp_path), + use_venv=False, + **configuration_arguments, + ) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + def observing_run(*args, **kwargs): + observed_keyword_arguments.update(kwargs) + return directory_run_suby(*args, **kwargs) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', observing_run) + + isolate.run('printf hidden-backend', logger=logger, catch_output=True) + + messages = [str(call.message) for call in logger.data.info] + assert config.log_run is False + assert 'logger' not in observed_keyword_arguments + assert messages == [ + f'Starting run in "{isolate.directory}".', + 'Run completed successfully.', + ] + + +def test_log_run_enabled_forwards_operation_logger_and_backend_messages(tmp_path, monkeypatch): + """Verify that log_run=True deliberately forwards the operation logger so suby records are visible.""" + logger = MemoryLogger() + observed_backend_logger: Optional[object] = None + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False, log_run=True) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + def observing_run(*args, **kwargs): + nonlocal observed_backend_logger + observed_backend_logger = kwargs.get('logger') + return directory_run_suby(*args, **kwargs) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', observing_run) + + isolate.run('printf visible-backend', logger=logger, catch_output=True) + + messages = [str(call.message) for call in logger.data.info] + assert observed_backend_logger is logger + assert messages == [ + f'Starting run in "{isolate.directory}".', + 'The beginning of the execution of the command "printf visible-backend".', + 'The command "printf visible-backend" has been successfully executed.', + 'Run completed successfully.', + ] + + +def test_logging_run_failure_levels(tmp_path, monkeypatch): + """Verify that a failing run writes a useful non-zero diagnostic without suby lifecycle records.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + isolate = throngs(logger=logger)['local'].get_isolate() + + expected_message = f'Command failed or output decoding failed: Error when executing the command "{executable} -c "import sys; sys.exit(5)"".' + with pytest.raises(CommandExecutionError, match=match(expected_message)): + isolate.run(executable, '-c', 'import sys; sys.exit(5)', split=False) + + assert_any_message_contains(logger.data.error, 'run', 'non-zero', '5') + assert [str(call.message) for call in logger.data.info] == [ + f'Creating local isolate in current working directory "{tmp_path}".', + f'Starting run in "{tmp_path}".', + ] + + +def test_logging_captured_nonzero_run_does_not_report_success(tmp_path, monkeypatch): + """Verify that a captured non-zero subprocess result is reported as a failed command, not as success.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + isolate = throngs(logger=logger)['local'].get_isolate() + + result = isolate.run(executable, '-c', 'import sys; sys.exit(7)', catch_exceptions=True, split=False) + + assert result.returncode == 7 + assert_any_message_contains(logger.data.error, 'run', 'non-zero', '7') + assert all(str(call.message) != 'Run completed successfully.' for call in logger.data.info) + + +def test_logging_run_does_not_expose_command_arguments(tmp_path, monkeypatch): + """Verify that lifecycle logging does not expose potentially sensitive command arguments.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + secret_argument = 'token=private-run-secret' + isolate = throngs(logger=logger)['local'].get_isolate() + + isolate.run(executable, '-c', 'pass', secret_argument, split=False) + + messages = [ + str(call.message) + for level in (logger.data.debug, logger.data.info, logger.data.error, logger.data.exception) + for call in level + ] + + assert all(secret_argument not in message for message in messages) + + +def test_logging_install_success_and_failure(tmp_path, monkeypatch): + """Verify that install logs concise success and diagnostic failure events.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + calls: List[Tuple[object, ...]] = [] + + def successful_run(*args, **_kwargs): + calls.append(args) + if '-m' in args and 'venv' in args: + venv_path = Path(args[-1]) + (venv_path / 'bin').mkdir(parents=True) + (venv_path / 'bin' / 'python').write_text('') + (venv_path / 'bin' / 'python').chmod(S_IREAD | S_IWRITE | S_IXUSR) + return SubprocessResult(id='install-ok', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', successful_run) + isolate = throngs(logger=logger)['local'].get_isolate() + + isolate.install('example-package') + + assert calls + assert_any_message_contains(logger.data.info, 'install') + assert_any_message_contains(logger.data.debug, 'creating', 'virtual environment') + + def failing_run(*_args, **_kwargs): + return SubprocessResult(id='install-fail', stderr='pip failed', returncode=1) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', failing_run) + with pytest.raises(InstallError, match=match('pip failed')): + isolate.install('broken-package') + + assert_any_message_contains(logger.data.exception, 'install', 'failed', 'pip failed') + + +def test_logging_install_does_not_expose_dependency_specification(tmp_path, monkeypatch): + """Verify that install logging does not expose potentially sensitive dependency specifications.""" + secret_dependency = 'private-package @ https://user:secret-token@example.invalid/archive.whl' + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='install', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + logger = MemoryLogger() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + isolate.install(secret_dependency) + + messages = [ + str(call.message) + for level in (logger.data.debug, logger.data.info, logger.data.error, logger.data.exception) + for call in level + ] + + assert all(secret_dependency not in message for message in messages) + assert all('secret-token' not in message for message in messages) + + +def test_logging_failed_install_does_not_expose_dependency_specification(tmp_path, monkeypatch): + """Verify that install failure logs preserve diagnostics without exposing a secret dependency URL.""" + secret_dependency = 'private-package @ https://user:secret-token@example.invalid/archive.whl' + logger = MemoryLogger() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='failure', stderr=f'pip could not install {secret_dependency}', returncode=1) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + with pytest.raises(InstallError, match=match(f'pip could not install {secret_dependency}')): + isolate.install(secret_dependency) + + messages = [ + str(call.message) + for level in (logger.data.debug, logger.data.info, logger.data.error, logger.data.exception) + for call in level + ] + assert any('pip could not install' in message for message in messages) + assert all('secret-token' not in message for message in messages) + + +def test_logging_failed_install_redacts_overlapping_dependency_specifications(tmp_path, monkeypatch): + """Verify that a longer dependency specification is fully redacted even when another name is its prefix.""" + logger = MemoryLogger() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='failure', stderr='pip could not install foobar or foo', returncode=1) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + with pytest.raises(InstallError, match=match('pip could not install foobar or foo')): + isolate.install(('foo', 'foobar')) + + assert [str(call.message) for call in logger.data.exception] == [ + 'Install failed: pip could not install or .', + ] + + +def test_logging_dump_load_success_and_failure(tmp_path, monkeypatch): + """Verify that dump and load success and failure paths write the expected logs.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + isolate = cast(DirectoryIsolate, throngs(logger=logger)['temporary_directory'].get_isolate()) + (isolate.directory / 'file.txt').write_text('content') + + dumped = isolate.dump() + isolate.load(dumped) + + assert_any_message_contains(logger.data.info, 'dump') + assert_any_message_contains(logger.data.info, 'load') + assert_any_message_contains(logger.data.debug, 'compression') + + with pytest.raises(ArchiveUnpackError, match=match('archive unpack failed: not a gzip file')): + isolate.load(b'not an archive') + + assert [str(call.message) for call in logger.data.exception] == ['Archive unpack failed: not a gzip file.'] + + +def test_operation_memory_logger_isolation(temporary_isolate): + """Verify that independent operation loggers receive only their own isolate operation records.""" + first = MemoryLogger() + second = MemoryLogger() + first_isolate = temporary_isolate() + second_isolate = temporary_isolate() + + first_isolate.dump(logger=first) + second_isolate.dump(logger=second) + + first_messages = [str(call.message) for call in first.data.info] + second_messages = [str(call.message) for call in second.data.info] + + assert any(str(first_isolate.directory) in message for message in first_messages) + assert any(str(second_isolate.directory) in message for message in second_messages) + assert all(str(second_isolate.directory) not in message for message in first_messages) + assert all(str(first_isolate.directory) not in message for message in second_messages) + + +@pytest.mark.parametrize('compression', ['gzip', 'bz2', 'lzma', 'none']) +def test_dump_load_roundtrip_gzip_bz2_lzma_none(compression: str, temporary_isolate): + """Verify that dump/load round-trips file bytes for every supported compression mode.""" + source = temporary_isolate(compression=compression) + target = temporary_isolate(compression=compression) + (source.directory / 'nested').mkdir() + (source.directory / 'nested' / 'file.txt').write_text('content') + (source.directory / 'root.bin').write_bytes(b'\x00\xff') + + dumped = source.dump() + target.load(dumped) + + assert read_tree(target.directory) == { + 'nested/file.txt': b'content', + 'root.bin': b'\x00\xff', + } + + +def test_none_compression_plain_tar(temporary_isolate): + """Verify that none compression produces a plain uncompressed tar archive.""" + isolate = temporary_isolate(compression='none') + (isolate.directory / 'file.txt').write_text('content') + + dumped = isolate.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert archive.getnames() == ['file.txt'] + assert not dumped.startswith(b'\x1f\x8b') + assert not dumped.startswith(b'BZh') + assert not dumped.startswith(b'\xfd7zXZ\x00') + + +def test_dump_binary_file(temporary_isolate): + """Verify that binary files round-trip byte-for-byte through dump and load.""" + source = temporary_isolate() + target = temporary_isolate() + payload = bytes(range(256)) + (source.directory / 'payload.bin').write_bytes(payload) + + target.load(source.dump()) + + assert (target.directory / 'payload.bin').read_bytes() == payload + + +def test_dump_omits_symbolic_links_from_serialized_contents(temporary_isolate): + """Verify that dump serializes regular file contents but does not emit symbolic-link archive members.""" + if os_name == 'nt': + pytest.skip('symlink creation often requires elevated privileges on Windows') + + source = temporary_isolate() + target = temporary_isolate() + regular_file = source.directory / 'regular.txt' + regular_file.write_text('content') + (source.directory / 'symbolic.txt').symlink_to(regular_file) + + dumped = source.dump() + target.load(dumped) + + assert read_tree(target.directory) == {'regular.txt': b'content'} + assert not (target.directory / 'symbolic.txt').exists() + + +def test_dump_serializes_hardlink_paths_as_regular_files(temporary_isolate): + """Verify that each hardlink path is dumped as regular file data so that load can restore the archive.""" + source = temporary_isolate() + target = temporary_isolate() + first_file = source.directory / 'first.txt' + second_file = source.directory / 'second.txt' + first_file.write_text('content') + + try: + link(str(first_file), str(second_file)) + except OSError: + pytest.skip('hardlinks are not supported in this temporary filesystem') + + dumped = source.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + archived_files = {member.name: member.isfile() for member in archive.getmembers()} + + target.load(dumped) + + assert archived_files == {'first.txt': True, 'second.txt': True} + assert read_tree(target.directory) == {'first.txt': b'content', 'second.txt': b'content'} + + +def test_dump_omits_named_pipes_from_serialized_contents(temporary_isolate): + """Verify that dump omits a POSIX named pipe because snapshots contain regular file data only.""" + if os_name == 'nt': + pytest.skip('POSIX named pipes are not available on Windows') + + source = temporary_isolate() + target = temporary_isolate() + kept_file = source.directory / 'regular.txt' + omitted_pipe = source.directory / 'ignored.pipe' + kept_file.write_text('content') + run_process(['mkfifo', str(omitted_pipe)], check=True) + + target.load(source.dump()) + + assert read_tree(target.directory) == {'regular.txt': b'content'} + assert not (target.directory / 'ignored.pipe').exists() + + +def test_dump_preserves_ordinary_permission_mode_where_practical(temporary_isolate): + """Verify that dump/load preserves complete ordinary POSIX permission bits while retaining executable status.""" + if os_name == 'nt': + pytest.skip('POSIX mode expectations do not apply on Windows') + + source = temporary_isolate() + target = temporary_isolate() + script = source.directory / 'script.sh' + script.write_text('#!/bin/sh\nexit 0\n') + expected_mode = 0o754 + script.chmod(expected_mode) + + target.load(source.dump()) + + assert S_IMODE((target.directory / 'script.sh').stat().st_mode) == expected_mode + + +def test_load_masks_special_permission_bits(temporary_isolate): + """Verify that load strips special permission bits while preserving ordinary permissions.""" + if os_name == 'nt': + pytest.skip('POSIX mode expectations do not apply on Windows') + + isolate = temporary_isolate() + special_bits = S_ISUID | S_ISGID | S_ISVTX + mode = special_bits | S_IRWXU + + isolate.load(make_tar_bytes({'script.sh': b'#!/bin/sh\n'}, modes={'script.sh': mode})) + + loaded_mode = (isolate.directory / 'script.sh').stat().st_mode + assert loaded_mode & special_bits == 0 + assert loaded_mode & S_IXUSR + + +def test_dump_excludes_configured_paths(temporary_isolate): + """Verify that configured exclude patterns omit matching paths from dumps.""" + source = temporary_isolate(dump_exclude=['ignored/**']) + (source.directory / 'kept.txt').write_text('kept') + (source.directory / 'ignored').mkdir() + (source.directory / 'ignored' / 'file.txt').write_text('ignored') + + dumped = source.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert archive.getnames() == ['kept.txt'] + + +def test_dump_excludes_venv_by_default(temporary_isolate): + """Verify that the effective virtual environment path is excluded from dumps by default.""" + isolate = temporary_isolate() + (isolate.directory / '.venv').mkdir() + (isolate.directory / '.venv' / 'installed.txt').write_text('dependency') + + dumped = isolate.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert '.venv/installed.txt' not in archive.getnames() + + +def test_dump_excludes_custom_venv_path_by_default(temporary_isolate): + """Verify that the configured virtual environment path is excluded from dumps by default.""" + isolate = temporary_isolate(venv_path='custom/env') + custom_venv = isolate.directory / 'custom' / 'env' + custom_venv.mkdir(parents=True) + (custom_venv / 'installed.txt').write_text('dependency') + + dumped = isolate.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert 'custom/env/installed.txt' not in archive.getnames() + + +@pytest.mark.parametrize('venv_path', ['[env]', 'venv*', '!']) +def test_dump_excludes_custom_venv_path_as_a_literal_directory(venv_path: str, temporary_isolate): + """Verify that a custom venv path containing glob syntax excludes only that literal directory during dump.""" + isolate = temporary_isolate(venv_path=venv_path) + virtual_environment = isolate.directory / venv_path + virtual_environment.mkdir() + (virtual_environment / 'installed.txt').write_text('dependency') + unrelated_directory = isolate.directory / 'venv-neighbor' + unrelated_directory.mkdir() + (unrelated_directory / 'ordinary.txt').write_text('ordinary') + + dumped = isolate.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + archived_names = archive.getnames() + + assert f'{venv_path}/installed.txt' not in archived_names + assert 'venv-neighbor/ordinary.txt' in archived_names + + +def test_dump_can_include_venv_when_exclude_disabled(temporary_isolate): + """Verify that disabling venv exclusion allows the venv path to appear in dumps.""" + isolate = temporary_isolate(exclude_venv=False) + (isolate.directory / '.venv').mkdir() + (isolate.directory / '.venv' / 'installed.txt').write_text('dependency') + + dumped = isolate.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert '.venv/installed.txt' in archive.getnames() + + +def test_dump_includes_default_venv_path_when_virtual_environments_are_disabled(temporary_isolate): + """Verify that `.venv` is ordinary serializable content when installation does not use virtual environments.""" + isolate = temporary_isolate(use_venv=False) + (isolate.directory / '.venv').mkdir() + (isolate.directory / '.venv' / 'ordinary.txt').write_text('ordinary') + + dumped = isolate.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert '.venv/ordinary.txt' in archive.getnames() + + +def test_load_preserves_excluded_venv(temporary_isolate): + """Verify that load preserves an existing excluded venv directory.""" + isolate = temporary_isolate() + (isolate.directory / '.venv').mkdir() + (isolate.directory / '.venv' / 'installed.txt').write_text('dependency') + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert (isolate.directory / '.venv' / 'installed.txt').read_text() == 'dependency' + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +@pytest.mark.parametrize('venv_path', ['[env]', 'venv*', '!']) +def test_load_preserves_custom_venv_path_as_a_literal_directory(venv_path: str, temporary_isolate): + """Verify that load preserves only the literal custom venv directory even when its name resembles a glob.""" + isolate = temporary_isolate(venv_path=venv_path) + virtual_environment = isolate.directory / venv_path + virtual_environment.mkdir() + (virtual_environment / 'installed.txt').write_text('dependency') + unrelated_directory = isolate.directory / 'venv-neighbor' + unrelated_directory.mkdir() + (unrelated_directory / 'ordinary.txt').write_text('ordinary') + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert (virtual_environment / 'installed.txt').read_text() == 'dependency' + assert not unrelated_directory.exists() + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_replaces_default_venv_path_when_virtual_environments_are_disabled(temporary_isolate): + """Verify that load replaces `.venv` as ordinary content when virtual environments are disabled.""" + isolate = temporary_isolate(use_venv=False) + (isolate.directory / '.venv').mkdir() + (isolate.directory / '.venv' / 'old.txt').write_text('old') + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert not (isolate.directory / '.venv').exists() + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_preserves_empty_excluded_venv(temporary_isolate): + """Verify that load preserves an empty excluded venv directory.""" + isolate = temporary_isolate() + (isolate.directory / '.venv').mkdir() + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert (isolate.directory / '.venv').is_dir() + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_preserves_excluded_named_pipe(temporary_isolate): + """Verify that load leaves a pre-existing excluded POSIX named pipe unchanged.""" + if os_name == 'nt': + pytest.skip('POSIX named pipes are not available on Windows') + + isolate = temporary_isolate(dump_exclude=['kept.pipe']) + kept_pipe = isolate.directory / 'kept.pipe' + run_process(['mkfifo', str(kept_pipe)], check=True) + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert kept_pipe.is_fifo() + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_preserves_excluded_symbolic_link(tmp_path, temporary_isolate): + """Verify that load retains an excluded symbolic link and its existing external target.""" + if os_name == 'nt': + pytest.skip('symlink creation often requires elevated privileges on Windows') + + external_target = tmp_path / 'external.txt' + external_target.write_text('outside') + isolate = temporary_isolate(dump_exclude=['kept-link']) + kept_link = isolate.directory / 'kept-link' + kept_link.symlink_to(external_target) + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert kept_link.is_symlink() + assert kept_link.read_text() == 'outside' + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_preserves_excluded_file_over_conflicting_archive_directory(temporary_isolate): + """Verify that an excluded existing file wins over an archive directory at the same path.""" + isolate = temporary_isolate(dump_exclude=['kept']) + kept_file = isolate.directory / 'kept' + kept_file.write_text('old') + + isolate.load(make_tar_bytes({'kept/new.txt': b'new', 'fresh.txt': b'fresh'})) + + assert kept_file.read_text() == 'old' + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_preserves_excluded_directory_over_conflicting_archive_file(temporary_isolate): + """Verify that an excluded existing directory wins over an archive file at the same path.""" + isolate = temporary_isolate(dump_exclude=['kept', 'kept/**']) + kept_directory = isolate.directory / 'kept' + kept_directory.mkdir() + (kept_directory / 'old.txt').write_text('old') + + isolate.load(make_tar_bytes({'kept': b'new', 'fresh.txt': b'fresh'})) + + assert (kept_directory / 'old.txt').read_text() == 'old' + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_dump_empty_sandbox(temporary_isolate): + """Verify that dumping an empty isolate produces a valid archive that can be loaded.""" + source = temporary_isolate() + target = temporary_isolate() + + dumped = source.dump() + target.load(dumped) + + assert read_tree(target.directory) == {} + + +def test_load_empty_archive_replaces_non_excluded_contents(temporary_isolate): + """Verify that loading an empty archive removes old non-excluded files.""" + source = temporary_isolate() + target = temporary_isolate() + (target.directory / 'old.txt').write_text('old') + + target.load(source.dump()) + + assert read_tree(target.directory) == {} + + +def test_load_corrupted_bytes(temporary_isolate): + """Verify that corrupted archive bytes raise ArchiveUnpackError with the unpack reason.""" + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match('archive unpack failed: truncated header')): + isolate.load(b'garbage') + + +def test_load_empty_bytes(temporary_isolate): + """Verify that empty archive bytes raise ArchiveUnpackError with the empty-file reason.""" + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match('archive unpack failed: empty file')): + isolate.load(b'') + + +def test_load_compression_mismatch(temporary_isolate): + """Verify that loading bytes with the wrong configured compression raises ArchiveUnpackError.""" + isolate = temporary_isolate(compression='bz2') + + with pytest.raises(ArchiveUnpackError, match=match('archive unpack failed: not a bzip2 file')): + isolate.load(make_tar_bytes({'file.txt': b'content'}, compression='gzip')) + + +def test_load_ignores_archived_entry_matching_excluded_path(temporary_isolate): + """Verify that incoming data for an excluded path cannot overwrite its existing contents.""" + isolate = temporary_isolate(dump_exclude=['kept.txt']) + kept_file = isolate.directory / 'kept.txt' + kept_file.write_text('old') + + isolate.load(make_tar_bytes({'kept.txt': b'new', 'fresh.txt': b'fresh'})) + + assert kept_file.read_text() == 'old' + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_creates_explicit_archive_directories(temporary_isolate): + """Verify that load accepts a safe explicit directory member and places nested file data inside it.""" + isolate = temporary_isolate() + archive_buffer = BytesIO() + + with open_tar(fileobj=archive_buffer, mode='w') as archive: + directory_info = TarInfo('nested') + directory_info.type = DIRTYPE + archive.addfile(directory_info) + + file_info = TarInfo('nested/file.txt') + payload = b'content' + file_info.size = len(payload) + archive.addfile(file_info, BytesIO(payload)) + + isolate.load(archive_buffer.getvalue()) + + assert (isolate.directory / 'nested').is_dir() + assert (isolate.directory / 'nested' / 'file.txt').read_bytes() == b'content' + + +def test_load_rejects_absolute_path(temporary_isolate): + """Verify that load rejects archive entries with absolute paths.""" + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains an absolute path: /x')): + isolate.load(make_tar_bytes({'/x': b'x'})) + + +def test_load_rejects_traversal(temporary_isolate): + """Verify that load rejects archive entries that traverse outside the isolate directory.""" + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains path traversal outside the isolate: a/../../x')): + isolate.load(make_tar_bytes({'a/../../x': b'x'})) + + +@pytest.mark.parametrize('path', ['C:/outside.txt', 'C:\\outside.txt', '..\\outside.txt']) +def test_load_rejects_windows_style_unsafe_paths(path: str, temporary_isolate): + """Verify that load rejects Windows-style absolute and traversal paths.""" + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match(expected_unsafe_path_message(path))): + isolate.load(make_tar_bytes({path: b'x'})) + + +def expected_unsafe_path_message(path: str) -> str: + if path == 'C:/outside.txt': + return 'Archive contains an absolute Windows drive path: C:/outside.txt' + if path == 'C:\\outside.txt': + return 'Archive contains an unsafe backslash path: C:\\outside.txt' + return 'Archive contains an unsafe backslash path: ..\\outside.txt' + + +@pytest.mark.parametrize('path', ['bad\x00name.txt', 'bad\x01name.txt', 'bad\x1fname.txt', 'bad\x7fname.txt']) +def test_load_rejects_null_and_control_character_paths(path: str, temporary_isolate): + """Verify that load rejects archive entry names containing null, control, or DEL characters.""" + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match(f'Archive contains an unsafe control character in path: {path!r}')): + isolate.load(make_pax_tar_bytes(path, b'x')) + + +def make_pax_tar_bytes(path: str, content: bytes) -> bytes: + archive_buffer = BytesIO() + + with open_tar(fileobj=archive_buffer, mode='w', format=PAX_FORMAT) as archive: + member_info = TarInfo('placeholder') + member_info.pax_headers = {'path': path} + member_info.size = len(content) + archive.addfile(member_info, BytesIO(content)) + + return archive_buffer.getvalue() + + +def make_unsafe_tar_bytes(member: TarInfo) -> bytes: + archive_buffer = BytesIO() + + with open_tar(fileobj=archive_buffer, mode='w') as archive: + if member.isfile(): + content = b'content' + member.size = len(content) + archive.addfile(member, BytesIO(content)) + else: + archive.addfile(member) + + return archive_buffer.getvalue() + + +@pytest.mark.parametrize('path', ['', '.']) +def test_load_skips_empty_and_current_directory_entries(path: str, temporary_isolate): + """Verify that empty and current-directory tar entries are ignored safely.""" + isolate = temporary_isolate() + (isolate.directory / 'old.txt').write_text('old') + + isolate.load(make_tar_bytes({path: b''})) + + assert read_tree(isolate.directory) == {} + + +def test_load_rejects_duplicate_entries(temporary_isolate): + """Verify that load rejects archives containing duplicate normalized entry paths.""" + isolate = temporary_isolate() + archive_buffer = BytesIO() + + with open_tar(fileobj=archive_buffer, mode='w') as archive: + for _ in range(2): + member_info = TarInfo('same.txt') + payload = b'x' + member_info.size = len(payload) + archive.addfile(member_info, BytesIO(payload)) + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains a duplicate entry: same.txt')): + isolate.load(archive_buffer.getvalue()) + + +@pytest.mark.parametrize( + 'member', + [ + TarInfo('link'), + TarInfo('hardlink'), + TarInfo('device'), + ], +) +def test_load_rejects_symlink_hardlink_device(member: TarInfo, temporary_isolate): + """Verify that load rejects symlinks, hardlinks, and device-like tar entries as unsupported archive entries.""" + if member.name == 'link': + member.type = SYMTYPE + member.linkname = 'target' + elif member.name == 'hardlink': + member.type = LNKTYPE + member.linkname = 'target' + else: + member.type = CHRTYPE + + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match(f'Archive contains unsafe entry: {member.name}')): + isolate.load(make_unsafe_tar_bytes(member)) + + +@pytest.mark.parametrize( + ('member_name', 'member_type'), + [ + ('link', SYMTYPE), + ('hardlink', LNKTYPE), + ('device', CHRTYPE), + ], +) +def test_load_rejects_excluded_symlink_hardlink_device(member_name: str, member_type: bytes, temporary_isolate): + """Verify that exclusion rules cannot conceal unsupported archive entry types during load validation.""" + member = TarInfo(member_name) + member.type = member_type + if member_type in (SYMTYPE, LNKTYPE): + member.linkname = 'target' + + isolate = temporary_isolate(dump_exclude=[member_name]) + + with pytest.raises(ArchiveUnpackError, match=match(f'Archive contains unsafe entry: {member_name}')): + isolate.load(make_unsafe_tar_bytes(member)) + + +@pytest.mark.parametrize('member_type', [SYMTYPE, LNKTYPE, CHRTYPE]) +def test_load_rejects_unsafe_current_directory_entry(member_type: bytes, temporary_isolate): + """Verify that an ignored current-directory archive name cannot conceal an unsafe member type.""" + member = TarInfo('.') + member.type = member_type + if member_type in (SYMTYPE, LNKTYPE): + member.linkname = 'target' + + isolate = temporary_isolate() + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains unsafe entry: .')): + isolate.load(make_unsafe_tar_bytes(member)) + + +def test_load_rejects_file_directory_conflict(temporary_isolate): + """Verify that load rejects archives where a file blocks a child directory entry.""" + isolate = temporary_isolate() + archive_buffer = BytesIO() + + with open_tar(fileobj=archive_buffer, mode='w') as archive: + file_info = TarInfo('conflict') + payload = b'x' + file_info.size = len(payload) + archive.addfile(file_info, BytesIO(payload)) + + nested_info = TarInfo('conflict/nested.txt') + nested_info.size = len(payload) + archive.addfile(nested_info, BytesIO(payload)) + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains a file/directory conflict: conflict/nested.txt')): + isolate.load(archive_buffer.getvalue()) + + +def test_load_rejects_directory_file_conflict(temporary_isolate): + """Verify that load rejects archives where a directory blocks a file at the same path.""" + isolate = temporary_isolate() + archive_buffer = BytesIO() + + with open_tar(fileobj=archive_buffer, mode='w') as archive: + nested_info = TarInfo('conflict/nested.txt') + payload = b'x' + nested_info.size = len(payload) + archive.addfile(nested_info, BytesIO(payload)) + + file_info = TarInfo('conflict') + file_info.size = len(payload) + archive.addfile(file_info, BytesIO(payload)) + + with pytest.raises(ArchiveUnpackError, match=match('Archive contains a file/directory conflict: conflict')): + isolate.load(archive_buffer.getvalue()) + + +def test_load_replaces_non_excluded_contents(temporary_isolate): + """Verify that load replaces old non-excluded contents with the archive contents.""" + isolate = temporary_isolate() + (isolate.directory / 'old.txt').write_text('old') + + isolate.load(make_tar_bytes({'new.txt': b'new'})) + + assert not (isolate.directory / 'old.txt').exists() + assert (isolate.directory / 'new.txt').read_text() == 'new' + + +def test_load_keeps_excluded_contents(temporary_isolate): + """Verify that load keeps excluded files that are absent from the archive.""" + isolate = temporary_isolate(dump_exclude=['keep/**']) + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('old') + + isolate.load(make_tar_bytes({'new.txt': b'new'})) + + assert (isolate.directory / 'keep' / 'old.txt').read_text() == 'old' + assert (isolate.directory / 'new.txt').read_text() == 'new' + + +def test_root_anchored_exclude_pattern_has_the_same_dump_and_load_semantics(temporary_isolate): + """Verify that a root-anchored user pattern omits a dumped path and preserves the matching loaded path.""" + source = temporary_isolate(dump_exclude=['/keep.txt']) + target = temporary_isolate(dump_exclude=['/keep.txt']) + (source.directory / 'keep.txt').write_text('source exclusion') + (source.directory / 'fresh.txt').write_text('fresh') + (target.directory / 'keep.txt').write_text('preserved') + (target.directory / 'stale.txt').write_text('remove') + + dumped = source.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert archive.getnames() == ['fresh.txt'] + + target.load(dumped) + + assert (target.directory / 'keep.txt').read_text() == 'preserved' + assert (target.directory / 'fresh.txt').read_text() == 'fresh' + assert not (target.directory / 'stale.txt').exists() + + +def test_negated_exclude_pattern_has_the_same_dump_and_load_semantics(temporary_isolate): + """Verify that a negated user pattern re-includes the same nested path in both snapshot directions.""" + patterns = ['keep/**', '!keep/included.txt'] + source = temporary_isolate(dump_exclude=patterns) + target = temporary_isolate(dump_exclude=patterns) + (source.directory / 'keep').mkdir() + (source.directory / 'keep' / 'excluded.txt').write_text('excluded from dump') + (source.directory / 'keep' / 'included.txt').write_text('new included value') + (target.directory / 'keep').mkdir() + (target.directory / 'keep' / 'excluded.txt').write_text('preserved') + (target.directory / 'keep' / 'included.txt').write_text('stale') + + dumped = source.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert archive.getnames() == ['keep/included.txt'] + + target.load(dumped) + + assert (target.directory / 'keep' / 'excluded.txt').read_text() == 'preserved' + assert (target.directory / 'keep' / 'included.txt').read_text() == 'new included value' + + +def test_load_preserves_nested_excluded_path_without_preserving_siblings(temporary_isolate): + """Verify that preserving a nested excluded path does not preserve its non-excluded siblings.""" + isolate = temporary_isolate(dump_exclude=['keep/nested/**']) + (isolate.directory / 'keep' / 'nested').mkdir(parents=True) + (isolate.directory / 'keep' / 'nested' / 'old.txt').write_text('old') + (isolate.directory / 'keep' / 'sibling.txt').write_text('remove me') + + isolate.load(make_tar_bytes({'new.txt': b'new'})) + + assert (isolate.directory / 'keep' / 'nested' / 'old.txt').read_text() == 'old' + assert not (isolate.directory / 'keep' / 'sibling.txt').exists() + assert (isolate.directory / 'new.txt').read_text() == 'new' + + +def test_load_replaces_concurrent_excluded_destination_with_preserved_value(monkeypatch, temporary_isolate): + """The plan requires excluded paths to survive load; this verifies restoration over a new destination.""" + isolate = temporary_isolate(dump_exclude=['keep.txt']) + preserved_path = isolate.directory / 'keep.txt' + preserved_path.write_text('preserved') + + def move_and_create_destination(source, destination): + source_path = Path(source) + destination_path = Path(destination) + result = directory_move(source, destination) + + if source_path.parent.name.startswith('throng-load-') and destination_path == isolate.directory / 'fresh.txt': + preserved_path.write_text('external replacement') + + return result + + monkeypatch.setattr('throng.plugins.directory_isolate.move', move_and_create_destination) + + isolate.load(make_tar_bytes({'fresh.txt': b'fresh'})) + + assert preserved_path.read_text() == 'preserved' + assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' + + +def test_load_commit_failure_rolls_back_preexisting_contents(temporary_isolate): + """Verify that a natural commit conflict restores the original tree and reports no unrestored paths.""" + isolate = temporary_isolate(dump_exclude=['keep/old.txt']) + logger = MemoryLogger() + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('old') + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: keep.'), + ) as raised: + isolate.load(make_tar_bytes({'keep': b'new', 'fresh.txt': b'fresh'}), logger=logger) + + assert 'Unrestored paths:' not in str(raised.value) + assert read_tree(isolate.directory) == {'keep/old.txt': b'old'} + assert_any_message_contains(logger.data.exception, 'archive', 'commit failed') + + +def test_load_commit_failure_removes_new_directory_during_rollback(temporary_isolate): + """Verify that rollback removes a newly staged directory before restoring pre-existing contents.""" + isolate = temporary_isolate(dump_exclude=['keep/old.txt']) + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('old') + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: keep.'), + ): + isolate.load(make_tar_bytes({'created/new.txt': b'new', 'keep': b'conflict'})) + + assert read_tree(isolate.directory) == {'keep/old.txt': b'old'} + assert not (isolate.directory / 'created').exists() + + +def test_load_commit_failure_rolls_back_excluded_path_restored_before_later_conflict(temporary_isolate): + """Verify that rollback retains an excluded file restored before a later excluded-path conflict aborts commit.""" + isolate = temporary_isolate(dump_exclude=['a.txt', 'z/kept.txt']) + (isolate.directory / 'a.txt').write_text('preserved first') + (isolate.directory / 'z').mkdir() + (isolate.directory / 'z' / 'kept.txt').write_text('preserved later') + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: z.'), + ): + isolate.load(make_tar_bytes({'z': b'conflict', 'fresh.txt': b'fresh'})) + + assert read_tree(isolate.directory) == { + 'a.txt': b'preserved first', + 'z/kept.txt': b'preserved later', + } + + +def test_load_rollback_reports_excluded_path_that_cannot_be_moved_back(monkeypatch, temporary_isolate): + """The plan requires best-effort rollback diagnostics; this reports an excluded path lost while undoing restoration.""" + isolate = temporary_isolate(dump_exclude=['a.txt', 'z/kept.txt']) + (isolate.directory / 'a.txt').write_text('preserved first') + (isolate.directory / 'z').mkdir() + (isolate.directory / 'z' / 'kept.txt').write_text('preserved later') + moves_to_backup = 0 + + def fail_second_move_of_restored_excluded_path(source, destination): + nonlocal moves_to_backup + source_path = Path(source) + destination_path = Path(destination) + + if source_path == isolate.directory / 'a.txt' and destination_path.parent.name.startswith('throng-backup-'): + moves_to_backup += 1 + if moves_to_backup == 2: + raise PermissionError('cannot move restored excluded path back to backup') + + return directory_move(source, destination) + + monkeypatch.setattr('throng.plugins.directory_isolate.move', fail_second_move_of_restored_excluded_path) + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: z. Unrestored paths: a.txt.'), + ): + isolate.load(make_tar_bytes({'z': b'conflict', 'fresh.txt': b'fresh'})) + + assert not (isolate.directory / 'a.txt').exists() + assert (isolate.directory / 'z' / 'kept.txt').read_text() == 'preserved later' + + +def test_load_rollback_reports_new_content_that_cannot_be_removed(monkeypatch, temporary_isolate): + """The plan requires best-effort rollback diagnostics; this reports staged content that cannot be removed.""" + isolate = temporary_isolate(dump_exclude=['keep/old.txt']) + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('old') + original_remove_path = DirectoryIsolate._remove_path + + def fail_removing_created_directory(self, path): + if path == isolate.directory / 'created': + raise PermissionError('cannot remove newly staged directory') + original_remove_path(self, path) + + monkeypatch.setattr(DirectoryIsolate, '_remove_path', fail_removing_created_directory) + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: keep. Unrestored paths: created.'), + ): + isolate.load(make_tar_bytes({'created/new.txt': b'new', 'keep': b'conflict'})) + + assert (isolate.directory / 'created' / 'new.txt').read_text() == 'new' + assert (isolate.directory / 'keep' / 'old.txt').read_text() == 'old' + + +def test_load_rollback_does_not_report_backed_up_path_restored_after_transient_removal_failure(monkeypatch, temporary_isolate): + """The plan reports only unrestored rollback paths; this checks recovery after a transient replacement failure.""" + isolate = temporary_isolate(dump_exclude=['keep/old.txt']) + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('old') + original_remove_path = DirectoryIsolate._remove_path + failed_once = False + + def fail_first_removal_of_backed_up_destination(self, path): + nonlocal failed_once + + if path == isolate.directory / 'keep' and not failed_once: + failed_once = True + raise PermissionError('temporary refusal while removing replacement') + + original_remove_path(self, path) + + monkeypatch.setattr(DirectoryIsolate, '_remove_path', fail_first_removal_of_backed_up_destination) + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: keep.'), + ) as raised: + isolate.load(make_tar_bytes({'keep': b'conflict'})) + + assert failed_once is True + assert 'Unrestored paths:' not in str(raised.value) + assert read_tree(isolate.directory) == {'keep/old.txt': b'old'} + + +def test_load_rollback_removes_external_destination_before_restoring_backup(monkeypatch, temporary_isolate): + """The plan requires rollback to restore original paths; this checks replacement of an intervening path.""" + isolate = temporary_isolate(dump_exclude=['keep/old.txt']) + (isolate.directory / 'old.txt').write_text('original') + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('preserved') + original_remove_path = DirectoryIsolate._remove_path + + def remove_staged_path_then_create_external_destination(self, path): + original_remove_path(self, path) + + if path == isolate.directory / 'fresh.txt': + (isolate.directory / 'old.txt').write_text('external replacement') + + monkeypatch.setattr(DirectoryIsolate, '_remove_path', remove_staged_path_then_create_external_destination) + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: keep.'), + ): + isolate.load(make_tar_bytes({'fresh.txt': b'fresh', 'keep': b'conflict'})) + + assert (isolate.directory / 'old.txt').read_text() == 'original' + assert (isolate.directory / 'keep' / 'old.txt').read_text() == 'preserved' + + +def test_load_rollback_reports_original_path_that_cannot_be_restored(monkeypatch, temporary_isolate): + """The plan requires best-effort rollback diagnostics; this reports an original path that cannot be restored.""" + isolate = temporary_isolate(dump_exclude=['keep/old.txt']) + (isolate.directory / 'old.txt').write_text('original') + (isolate.directory / 'keep').mkdir() + (isolate.directory / 'keep' / 'old.txt').write_text('preserved') + + def fail_restoring_original_file(source, destination): + source_path = Path(source) + destination_path = Path(destination) + + if source_path.parent.name.startswith('throng-backup-') and source_path.name == 'old.txt' and destination_path == isolate.directory / 'old.txt': + raise PermissionError('cannot restore original path') + + return directory_move(source, destination) + + monkeypatch.setattr('throng.plugins.directory_isolate.move', fail_restoring_original_file) + + with pytest.raises( + ArchiveUnpackError, + match=match('Archive commit failed; rollback attempted. Cause: Archive commit failed; excluded path parent is a file: keep. Unrestored paths: old.txt.'), + ): + isolate.load(make_tar_bytes({'keep': b'conflict', 'fresh.txt': b'fresh'})) + + assert not (isolate.directory / 'old.txt').exists() + assert (isolate.directory / 'keep' / 'old.txt').read_text() == 'preserved' + + +def test_config_rejects_unsupported_compression_mode(tmp_path): + """Verify that unsupported compression modes are rejected by skelet config validation.""" + with pytest.raises(ValueError, match=match('Unsupported compression mode.')): + TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), compression='zstd') + + +def test_config_rejects_malformed_dump_exclude_pattern(tmp_path): + """Verify that malformed archive exclude patterns are rejected before any dump or load operation can start.""" + with pytest.raises(ValueError, match=match('Invalid dump exclude pattern.')): + TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), dump_exclude=['!']) + + +@pytest.mark.parametrize('operation_name', ['dump', 'load']) +def test_operation_rejects_malformed_dump_exclude_added_after_configuration(operation_name: str, temporary_isolate): + """Verify that mutating configured excludes cannot expose a raw pathspec error during dump or load.""" + isolate = temporary_isolate() + isolate.config.dump_exclude.append('!') + logger = MemoryLogger() + operation = { + 'dump': lambda: isolate.dump(logger=logger), + 'load': lambda: isolate.load(make_tar_bytes({'file.txt': b'content'}), logger=logger), + }[operation_name] + + with pytest.raises(ValueError, match=match('Invalid dump exclude pattern.')): + operation() + + assert_any_message_contains(logger.data.exception, operation_name, 'invalid', 'exclude') + + +def test_load_tempdir_creation_failure_is_wrapped_and_logged(tmp_path, monkeypatch, temporary_isolate): + """Verify that a real tempfile creation failure is wrapped as ArchiveUnpackError and logged.""" + tempdir_file = tmp_path / 'not-a-directory' + tempdir_file.write_text('content') + monkeypatch.setattr(tempfile, 'tempdir', str(tempdir_file)) + isolate = temporary_isolate() + logger = MemoryLogger() + + with pytest.raises(ArchiveUnpackError, match=match('archive unpack failed: cannot create temporary load directories: Not a directory')): + isolate.load(make_tar_bytes({'new.txt': b'new'}), logger=logger) + + assert [str(call.message) for call in logger.data.exception] == ['Archive unpack failed: cannot create temporary load directories: Not a directory.'] + + +def test_load_permission_denied_write(request, temporary_isolate): + """Verify that a write failure reports only genuinely unrestored paths and retains untouched old data.""" + if os_name == 'nt': + pytest.skip('permission mode semantics differ on Windows') + + isolate = temporary_isolate() + (isolate.directory / 'old.txt').write_text('old') + request.addfinalizer(lambda: isolate.directory.chmod(S_IREAD | S_IWRITE | S_IXUSR)) + isolate.directory.chmod(S_IREAD | S_IXUSR) + logger = MemoryLogger() + + with pytest.raises(ArchiveUnpackError, match=match('Archive commit failed; rollback attempted. Cause: Permission denied.')) as raised: + isolate.load(make_tar_bytes({'new.txt': b'new'}), logger=logger) + + assert 'Unrestored paths:' not in str(raised.value) + assert (isolate.directory / 'old.txt').read_text() == 'old' + assert not (isolate.directory / 'new.txt').exists() + assert_any_message_contains(logger.data.exception, 'archive', 'failed') + + +def test_dump_permission_denied_read(request, temporary_isolate): + """Verify that a read failure during dump propagates the read error and logs the failure.""" + if os_name == 'nt': + pytest.skip('permission mode semantics differ on Windows') + + isolate = temporary_isolate() + unreadable = isolate.directory / 'unreadable.txt' + unreadable.write_text('secret') + request.addfinalizer(lambda: unreadable.chmod(S_IREAD | S_IWRITE) if unreadable.exists() else None) + unreadable.chmod(0) + + logger = MemoryLogger() + + with pytest.raises(PermissionError, match=match(f"[Errno 13] Permission denied: '{unreadable}'")): + isolate.dump(logger=logger) + + assert_any_message_contains(logger.data.exception, 'dump', 'failed') diff --git a/tests/plugins/test_local_throng.py b/tests/plugins/test_local_throng.py new file mode 100644 index 0000000..712556c --- /dev/null +++ b/tests/plugins/test_local_throng.py @@ -0,0 +1,253 @@ +from concurrent.futures import ThreadPoolExecutor +from sys import executable +from threading import Barrier, Lock + +import pytest +from cantok import SimpleToken +from full_match import match +from locklib import LockTraceWrapper +from suby.subprocess_result import SubprocessResult + +from tests.helpers import make_tar_bytes +from throng import ( + CommandExecutionError, + IsolateDeletedError, + OperationCancelledError, + throngs, +) +from throng.plugins.directory_isolate import DirectoryIsolate, DirectoryIsolationConfig +from throng.plugins.local_throng import LocalDirectoryThrong +from throng.result import RunResult + + +def assert_isolate_deleted(operation): + with pytest.raises(IsolateDeletedError, match=match('Isolate has been deleted.')): + operation() + + +def test_local_uses_current_working_directory(tmp_path, monkeypatch): + """Verify that the local plugin runs commands in the current working directory.""" + monkeypatch.chdir(tmp_path) + + marker = tmp_path / 'cwd-marker.txt' + + result = throngs()['local'].get_isolate().run( + executable, + '-c', + 'from pathlib import Path; Path("cwd-marker.txt").write_text("ok")', + catch_output=True, + split=False, + ) + + assert result.success is True + assert marker.read_text() == 'ok' + + +def test_local_same_throng_runs_enter_per_throng_lock(tmp_path, monkeypatch): + """Verify that run calls from two isolates of one local throng enter its shared lock.""" + monkeypatch.chdir(tmp_path) + + local_throng = LocalDirectoryThrong() + lock_trace = LockTraceWrapper(Lock()) + local_throng.lock = lock_trace + first = local_throng.get_isolate() + second = local_throng.get_isolate() + + def fake_run(*args, **_kwargs): + lock_trace.notify(str(args[0])) + return SubprocessResult(id=str(args[0]), returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + first.run('first') + second.run('second') + + assert lock_trace.was_event_locked('first') + assert lock_trace.was_event_locked('second') + + +def test_local_different_throng_instances_do_not_share_lock(tmp_path, monkeypatch): + """Verify that explicit separate LocalDirectoryThrong objects use separate locks and can overlap.""" + monkeypatch.chdir(tmp_path) + + barrier = Barrier(2) + + def fake_run(*_args, **_kwargs): + barrier.wait(timeout=5) + return SubprocessResult(id='different-throng', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + first = LocalDirectoryThrong().get_isolate() + second = LocalDirectoryThrong().get_isolate() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(first.run, 'anything'), + executor.submit(second.run, 'anything'), + ] + for future in futures: + future.result() + + assert barrier.n_waiting == 0 + + +def test_local_lock_released_after_success(tmp_path, monkeypatch): + """Verify that the local lock is released after a successful command.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + isolate.run('printf first', catch_output=True) + result = isolate.run('printf second', catch_output=True) + + assert result.stdout == 'second' + + +def test_local_lock_released_after_command_error(tmp_path, monkeypatch): + """Verify that the local lock is released after CommandExecutionError.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + expected_message = f'Command failed or output decoding failed: Error when executing the command "{executable} -c "import sys; sys.exit(3)"".' + with pytest.raises(CommandExecutionError, match=match(expected_message)): + isolate.run(executable, '-c', 'import sys; sys.exit(3)', split=False) + + result = isolate.run('printf after-error', catch_output=True) + + assert result.stdout == 'after-error' + + +def test_local_lock_released_after_cancellation(tmp_path, monkeypatch): + """Verify that the local lock is released after OperationCancelledError.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + with pytest.raises(OperationCancelledError, match=match('The operation was cancelled.')): + isolate.run('printf never', token=SimpleToken().cancel()) + + result = isolate.run('printf after-cancel', catch_output=True) + + assert result.stdout == 'after-cancel' + + +def test_local_delete_does_not_remove_current_directory(tmp_path, monkeypatch): + """Verify that local delete marks the isolate deleted without removing the current working directory.""" + monkeypatch.chdir(tmp_path) + + marker = tmp_path / 'kept.txt' + marker.write_text('content') + isolate = throngs()['local'].get_isolate() + + isolate.delete() + + assert tmp_path.is_dir() + assert marker.read_text() == 'content' + + +@pytest.mark.parametrize('operation', ['run', 'install', 'dump', 'load', 'delete']) +def test_local_operations_after_delete_raise(tmp_path, monkeypatch, operation): + """Verify that every local isolate operation raises IsolateDeletedError after delete.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + isolate.delete() + + operations = { + 'run': lambda: isolate.run('printf never'), + 'install': lambda: isolate.install('example'), + 'dump': isolate.dump, + 'load': lambda: isolate.load(make_tar_bytes({'file.txt': b'content'})), + 'delete': isolate.delete, + } + + assert_isolate_deleted(operations[operation]) + + +def test_local_plugin_dump_install_load_serialized_with_run(tmp_path, monkeypatch): + """Verify that operations from different local isolates all enter the one lock owned by their throng.""" + monkeypatch.chdir(tmp_path) + + local_throng = LocalDirectoryThrong(config=DirectoryIsolationConfig(use_venv=False)) + lock_trace = LockTraceWrapper(Lock()) + local_throng.lock = lock_trace + + def fake_run(_self, *_args, **_kwargs): + lock_trace.notify('run') + return RunResult(id='run', stdout=None, stderr=None, returncode=0) + + def fake_install(_self, *_args, **_kwargs): + lock_trace.notify('install') + + def fake_dump(_self, *_args, **_kwargs): + lock_trace.notify('dump') + return b'' + + def fake_load(_self, *_args, **_kwargs): + lock_trace.notify('load') + + monkeypatch.setattr(DirectoryIsolate, '_run_unlocked', fake_run) + monkeypatch.setattr(DirectoryIsolate, '_install_unlocked', fake_install) + monkeypatch.setattr(DirectoryIsolate, '_dump_unlocked', fake_dump) + monkeypatch.setattr(DirectoryIsolate, '_load_unlocked', fake_load) + + run_isolate = local_throng.get_isolate() + install_isolate = local_throng.get_isolate() + dump_isolate = local_throng.get_isolate() + load_isolate = local_throng.get_isolate() + + run_isolate.run('anything') + install_isolate.install('example') + dump_isolate.dump() + load_isolate.load(b'') + + assert lock_trace.was_event_locked('run') + assert lock_trace.was_event_locked('install') + assert lock_trace.was_event_locked('dump') + assert lock_trace.was_event_locked('load') + + +def test_local_isolate_operations_are_inside_per_throng_lock(tmp_path, monkeypatch): + """Verify with LockTraceWrapper that every local isolate operation enters the per-throng lock.""" + monkeypatch.chdir(tmp_path) + + local_throng = LocalDirectoryThrong() + lock_trace = LockTraceWrapper(Lock()) + local_throng.lock = lock_trace + isolate = local_throng.get_isolate() + + def traced_run(_self, *_args, **_kwargs): + lock_trace.notify('run') + return RunResult(id='run', stdout=None, stderr=None, returncode=0) + + def traced_install(_self, *_args, **_kwargs): + lock_trace.notify('install') + + def traced_dump(_self, *_args, **_kwargs): + lock_trace.notify('dump') + return b'' + + def traced_load(_self, *_args, **_kwargs): + lock_trace.notify('load') + + def traced_delete(_self, *_args, **_kwargs): + lock_trace.notify('delete') + + monkeypatch.setattr(DirectoryIsolate, '_run_unlocked', traced_run) + monkeypatch.setattr(DirectoryIsolate, '_install_unlocked', traced_install) + monkeypatch.setattr(DirectoryIsolate, '_dump_unlocked', traced_dump) + monkeypatch.setattr(DirectoryIsolate, '_load_unlocked', traced_load) + monkeypatch.setattr(DirectoryIsolate, '_delete_unlocked', traced_delete) + + isolate.run('anything') + isolate.install('example') + isolate.dump() + isolate.load(b'') + isolate.delete() + + assert lock_trace.was_event_locked('run') + assert lock_trace.was_event_locked('install') + assert lock_trace.was_event_locked('dump') + assert lock_trace.was_event_locked('load') + assert lock_trace.was_event_locked('delete') diff --git a/tests/plugins/test_plugins.py b/tests/plugins/test_plugins.py new file mode 100644 index 0000000..b911162 --- /dev/null +++ b/tests/plugins/test_plugins.py @@ -0,0 +1,77 @@ +from subprocess import run as run_process +from sys import executable + +import pytest +from emptylog import EmptyLogger, LoggerProtocol +from full_match import match +from pristan.errors import PrimadonnaPluginError + +from throng import AbstractIsolate, AbstractThrong, throngs + + +def test_plugins_return_builtins(): + """Verify that pristan entry point loading returns the built-in local and temporary_directory plugins.""" + plugins = throngs() + + assert set(plugins) >= {'local', 'temporary_directory'} + assert isinstance(plugins['local'], AbstractThrong) + assert isinstance(plugins['temporary_directory'], AbstractThrong) + + +def test_builtin_plugins_are_discovered_from_entry_points(): + """Verify that built-in plugins are discovered through entry points instead of eager imports from the slot module.""" + completed_process = run_process( + [ + executable, + '-c', + ( + 'from throng.slots import throngs\n' + 'print(len(throngs))\n' + 'from throng.plugins.directory_isolate import DirectoryIsolate\n' + 'print(len(throngs))\n' + 'print(",".join(sorted(throngs().keys())))\n' + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed_process.stdout.splitlines() == ['0', '0', 'local,temporary_directory'] + + +def test_builtin_plugins_read_independent_skelet_sources(monkeypatch): + """Verify that each built-in plugin reads settings only from its plugin-specific skelet source.""" + monkeypatch.setenv('LOCAL_COMPRESSION', 'bz2') + monkeypatch.setenv('TEMPORARY_DIRECTORY_COMPRESSION', 'lzma') + + completed_process = run_process( + [ + executable, + '-c', + ( + 'from throng import throngs\n' + 'plugins = throngs()\n' + 'print(plugins["local"].config.compression)\n' + 'print(plugins["temporary_directory"].config.compression)\n' + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed_process.stdout.splitlines() == ['bz2', 'lzma'] + + +def test_builtin_plugin_name_collision(): + """Verify that registering a plugin with a built-in name is rejected as a name collision.""" + + class OtherLocalThrong(AbstractThrong): + def get_isolate(self) -> AbstractIsolate: + raise RuntimeError('not needed') + + with pytest.raises(PrimadonnaPluginError, match=match('Plugin "local" claims to be unique, but there are other plugins with the same name.')): + @throngs.plugin('local') + def local(_logger: LoggerProtocol = EmptyLogger()) -> AbstractThrong: # noqa: B008 + return OtherLocalThrong() diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py new file mode 100644 index 0000000..5905a6c --- /dev/null +++ b/tests/plugins/test_temporary_directory_throng.py @@ -0,0 +1,493 @@ +from concurrent.futures import ThreadPoolExecutor +from gc import collect +from os import name as os_name +from pathlib import Path +from stat import S_IEXEC, S_IREAD, S_IWRITE +from sys import executable +from tempfile import TemporaryDirectory, gettempdir +from threading import Barrier, Condition, Event, Lock +from typing import cast + +import pytest +from emptylog import MemoryLogger +from full_match import match +from locklib import LockTraceWrapper +from suby.subprocess_result import SubprocessResult + +from tests.helpers import make_tar_bytes +from throng import ( + InvalidBaseDirectoryError, + IsolateDeletedError, + throngs, +) +from throng.plugins.directory_isolate import DirectoryIsolate, DirectoryIsolationConfig +from throng.plugins.empty_lock import EmptyLock +from throng.plugins.temporary_directory_throng import ( + TemporaryDirectoryIsolationConfig, + TemporaryDirectoryThrong, +) + + +def assert_isolate_deleted(operation): + with pytest.raises(IsolateDeletedError, match=match('Isolate has been deleted.')): + operation() + + +def test_temp_base_none_uses_stdlib_temp(tmp_path, monkeypatch): + """Verify that default temporary isolates use distinct stdlib temp roots and log both creations.""" + monkeypatch.chdir(tmp_path) + + logger = MemoryLogger() + throng = TemporaryDirectoryThrong(logger=logger) + first = throng.get_isolate() + second = throng.get_isolate() + + assert first.directory.exists() + assert second.directory.exists() + assert first.directory != second.directory + assert Path(gettempdir()).resolve() in first.directory.resolve().parents + assert Path(gettempdir()).resolve() in second.directory.resolve().parents + assert tmp_path not in first.directory.parents + assert tmp_path not in second.directory.parents + assert [str(call.message) for call in logger.data.info] == [ + f'Creating temporary isolate in stdlib temporary directory "{first.directory}".', + f'Creating temporary isolate in stdlib temporary directory "{second.directory}".', + ] + + +def test_temp_base_directory_uuid_subdir(tmp_path): + """Verify that a configured temporary base receives a UUID-hex child and logs its creation.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path)) + logger = MemoryLogger() + + isolate = TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + assert isolate.directory.parent == tmp_path + assert len(isolate.directory.name) == 32 + assert all(character in '0123456789abcdef' for character in isolate.directory.name) + assert isolate.directory.is_dir() + assert [str(call.message) for call in logger.data.info] == [ + f'Creating temporary isolate "{isolate.directory}" inside base directory "{tmp_path}".', + ] + + +def test_temp_base_missing(tmp_path): + """Verify that a missing temporary base directory raises and logs InvalidBaseDirectoryError.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path / 'missing')) + logger = MemoryLogger() + + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory does not exist: {tmp_path / "missing"}')): + TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + assert [str(call.message) for call in logger.data.error] == [ + f'Temporary base directory does not exist: {tmp_path / "missing"}', + ] + + +def test_temp_base_file(tmp_path): + """Verify that a file temporary base path raises and logs InvalidBaseDirectoryError.""" + file_path = tmp_path / 'file' + file_path.write_text('content') + config = TemporaryDirectoryIsolationConfig(base_directory=str(file_path)) + logger = MemoryLogger() + + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base path is not a directory: {file_path}')): + TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + assert [str(call.message) for call in logger.data.error] == [ + f'Temporary base path is not a directory: {file_path}', + ] + + +def test_temp_base_symlink_to_file(tmp_path): + """Verify that a file symlink temporary base path raises and logs InvalidBaseDirectoryError.""" + if os_name == 'nt': + pytest.skip('symlink creation often requires elevated privileges on Windows') + + target_file = tmp_path / 'file' + target_file.write_text('content') + symlink_path = tmp_path / 'link' + symlink_path.symlink_to(target_file) + config = TemporaryDirectoryIsolationConfig(base_directory=str(symlink_path)) + logger = MemoryLogger() + + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base path is not a directory: {symlink_path}')): + TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + assert [str(call.message) for call in logger.data.error] == [ + f'Temporary base path is not a directory: {symlink_path}', + ] + + +def test_temp_base_not_writable(tmp_path, request): + """Verify that a non-writable temporary base directory is rejected and logged where permissions apply.""" + if os_name == 'nt': + pytest.skip('permission mode semantics differ on Windows') + + base_directory = tmp_path / 'base' + base_directory.mkdir() + request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) + base_directory.chmod(S_IREAD | S_IEXEC) + config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) + logger = MemoryLogger() + + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')): + TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + assert [str(call.message) for call in logger.data.error] == [ + f'Temporary base directory is not writable: {base_directory}', + ] + + +def test_temp_isolates_are_distinct(tmp_path): + """Verify that temporary isolates get separate directories and do not share files.""" + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path)) + throng = TemporaryDirectoryThrong(config=config) + first = throng.get_isolate() + second = throng.get_isolate() + + (first.directory / 'only-first').write_text('content') + + assert first.directory != second.directory + assert (first.directory / 'only-first').read_text() == 'content' + assert not (second.directory / 'only-first').exists() + + +def test_temp_cross_isolate_file_contamination(tmp_path): + """Verify that commands in one temporary isolate cannot see files created in another isolate.""" + throng = TemporaryDirectoryThrong(config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + first = throng.get_isolate() + second = throng.get_isolate() + + first.run(executable, '-c', 'from pathlib import Path; Path("created.txt").write_text("first")', split=False) + + assert (first.directory / 'created.txt').read_text() == 'first' + assert not (second.directory / 'created.txt').exists() + + +def test_temp_no_shared_command_lock(tmp_path, monkeypatch): + """Verify that run calls on different temporary isolates can overlap because they do not share a command lock.""" + barrier = Barrier(2) + + def fake_run(*_args, **_kwargs): + barrier.wait(timeout=5) + return SubprocessResult(id='temp-distinct', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + throng = TemporaryDirectoryThrong(config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + first = throng.get_isolate() + second = throng.get_isolate() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(first.run, 'anything'), + executor.submit(second.run, 'anything'), + ] + for future in futures: + future.result() + + assert barrier.n_waiting == 0 + + +def test_temp_same_isolate_operations_are_not_serialized(tmp_path, monkeypatch): + """Verify that run and install on one temporary isolate can overlap because the isolate uses EmptyLock.""" + entered = 0 + barrier = Barrier(2) + guard = Lock() + + def fake_run(*_args, **_kwargs): + nonlocal entered + with guard: + entered += 1 + barrier.wait(timeout=5) + return SubprocessResult(id='temp-serialized', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(isolate.run, 'anything'), + executor.submit(isolate.install, 'example'), + ] + for future in futures: + future.result() + + assert entered == 2 + assert barrier.n_waiting == 0 + + +def test_temporary_throng_isolate_empty_lock_does_not_serialize_operations(tmp_path, monkeypatch): + """Verify that an isolate returned by TemporaryDirectoryThrong enters a non-serializing lock for every operation.""" + entered = 0 + barrier = Barrier(5) + guard = Lock() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + lock_trace = LockTraceWrapper(isolate.lock) + isolate.lock = lock_trace + + def traced_operation(name, operation_result): + def wrapper(*_args, **_kwargs): + nonlocal entered + lock_trace.notify(name) + with guard: + entered += 1 + barrier.wait(timeout=5) + return operation_result + + return wrapper + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', traced_operation('run', SubprocessResult(id='temp-run', returncode=0))) + monkeypatch.setattr(DirectoryIsolate, '_install_unlocked', traced_operation('install', None)) + monkeypatch.setattr(DirectoryIsolate, '_dump_unlocked', traced_operation('dump', b'')) + monkeypatch.setattr(DirectoryIsolate, '_load_unlocked', traced_operation('load', None)) + monkeypatch.setattr(DirectoryIsolate, '_delete_unlocked', traced_operation('delete', None)) + + with ThreadPoolExecutor(max_workers=5) as executor: + futures = [ + executor.submit(isolate.run, 'anything'), + executor.submit(isolate.install, 'example'), + executor.submit(isolate.dump), + executor.submit(isolate.load, b''), + executor.submit(isolate.delete), + ] + for future in futures: + future.result() + + assert lock_trace.was_event_locked('run') + assert lock_trace.was_event_locked('install') + assert lock_trace.was_event_locked('dump') + assert lock_trace.was_event_locked('load') + assert lock_trace.was_event_locked('delete') + assert entered == 5 + assert barrier.n_waiting == 0 + + +def test_temporary_throng_isolate_empty_lock_allows_concurrent_runs(tmp_path, monkeypatch): + """Verify that an isolate returned by TemporaryDirectoryThrong permits concurrent runs.""" + entered = 0 + barrier = Barrier(2) + guard = Lock() + config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False) + isolate = TemporaryDirectoryThrong(config=config).get_isolate() + lock_trace = LockTraceWrapper(isolate.lock) + isolate.lock = lock_trace + + def traced_run(*_args, **_kwargs): + nonlocal entered + lock_trace.notify('run') + with guard: + entered += 1 + barrier.wait(timeout=5) + return SubprocessResult(id='temp-run', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', traced_run) + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(isolate.run, 'anything'), + executor.submit(isolate.run, 'anything'), + ] + for future in futures: + future.result() + + assert lock_trace.was_event_locked('run') + assert entered == 2 + assert barrier.n_waiting == 0 + + +def test_temp_delete_removes_owned_directory(tmp_path): + """Verify that delete removes an owned temporary-directory isolate when removal is deterministic.""" + throng = TemporaryDirectoryThrong(config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + isolate = throng.get_isolate() + isolate_directory = isolate.directory + + isolate.delete() + + assert not isolate_directory.exists() + + +def test_temp_delete_operation_logger_is_added_to_inherited_logger(tmp_path): + """Verify that delete lifecycle records are emitted to inherited and operation loggers.""" + inherited_logger = MemoryLogger() + operation_logger = MemoryLogger() + throng = TemporaryDirectoryThrong( + logger=inherited_logger, + config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path)), + ) + isolate = throng.get_isolate() + inherited_info_before_delete = len(inherited_logger.data.info) + + isolate.delete(logger=operation_logger) + + expected_delete_messages = [ + f'Deleting isolate "{isolate.directory}".', + 'Delete completed successfully.', + ] + assert [str(call.message) for call in inherited_logger.data.info[inherited_info_before_delete:]] == expected_delete_messages + assert [str(call.message) for call in operation_logger.data.info] == expected_delete_messages + + +def test_temp_configured_base_is_cleaned_when_isolate_is_collected(tmp_path): + """Verify that a temporary isolate inside a configured base is cleaned up if its owner is collected.""" + throng = TemporaryDirectoryThrong(config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + isolate = throng.get_isolate() + isolate_directory = isolate.directory + + del isolate + collect() + + assert not isolate_directory.exists() + + +def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request): + """Verify that a failed temporary delete is logged and can be retried once filesystem access is restored.""" + if os_name == 'nt': + pytest.skip('permission mode semantics differ on Windows') + + base_directory = tmp_path / 'base' + base_directory.mkdir() + logger = MemoryLogger() + throng = TemporaryDirectoryThrong(logger=logger, config=TemporaryDirectoryIsolationConfig(base_directory=str(base_directory))) + isolate = throng.get_isolate() + isolate_directory = isolate.directory + request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) + base_directory.chmod(S_IREAD | S_IEXEC) + + with pytest.raises(PermissionError, match=match(f"[Errno 13] Permission denied: '{isolate_directory}'")): + isolate.delete() + + assert isolate_directory.exists() + assert any('Delete failed' in str(call.message) for call in logger.data.exception) + assert all(str(call.message) != 'Delete completed successfully.' for call in logger.data.info) + + base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC) + isolate.delete() + + assert not isolate_directory.exists() + assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1 + + +def test_temp_delete_after_delete_raises(tmp_path): + """Verify that a second delete call is rejected and logged as a delete operation.""" + logger = MemoryLogger() + throng = TemporaryDirectoryThrong(logger=logger, config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + isolate = throng.get_isolate() + + isolate.delete() + + assert_isolate_deleted(isolate.delete) + assert any(str(call.message) == 'Delete rejected because the isolate has been deleted.' for call in logger.data.error) + + +def test_temp_delete_stdlib_temp_manager(tmp_path, monkeypatch): + """Verify that delete removes stdlib-managed temporary isolate directories.""" + monkeypatch.chdir(tmp_path) + + isolate = cast(DirectoryIsolate, throngs()['temporary_directory'].get_isolate()) + isolate_directory = isolate.directory + + isolate.delete() + + assert not isolate_directory.exists() + + +def test_temp_delete_does_not_wait_for_running_operation(tmp_path, monkeypatch): + """Verify that temporary isolate delete is not serialized behind an in-flight run and does not hide the run result.""" + started = Event() + release = Event() + delete_finished = Event() + + def blocking_run(*_args, **_kwargs): + started.set() + release.wait(timeout=5) + return SubprocessResult(id='blocking-run', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', blocking_run) + throng = TemporaryDirectoryThrong(config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + isolate = throng.get_isolate() + isolate_directory = isolate.directory + + with ThreadPoolExecutor(max_workers=2) as executor: + run_future = executor.submit(isolate.run, 'anything') + assert started.wait(timeout=5) + delete_future = executor.submit(isolate.delete) + delete_future.add_done_callback(lambda _future: delete_finished.set()) + + assert delete_finished.wait(timeout=5) + assert not isolate_directory.exists() + + release.set() + delete_future.result() + assert run_future.result().id == 'blocking-run' + + +def test_temp_concurrent_delete_only_succeeds_once(tmp_path): + """Verify that concurrent temporary isolate deletion has one winner and then rejects the already-deleted isolate.""" + release_cleanup = Event() + first_cleanup_entered = Event() + second_future_done = False + cleanup_entries = 0 + condition = Condition() + + class BlockingCleanupManager: + def cleanup(self): + nonlocal cleanup_entries + with condition: + cleanup_entries += 1 + if cleanup_entries == 1: + first_cleanup_entered.set() + condition.notify_all() + release_cleanup.wait(timeout=5) + + isolate = DirectoryIsolate( + tmp_path, + DirectoryIsolationConfig(), + lock=EmptyLock(), + temporary_directory_manager=cast('TemporaryDirectory[str]', BlockingCleanupManager()), + ) + + def delete_once(): + try: + isolate.delete() + except IsolateDeletedError: + return 'already-deleted' + return 'deleted' + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(delete_once) + assert first_cleanup_entered.wait(timeout=5) + second_future = executor.submit(delete_once) + + def mark_second_done(_future): + nonlocal second_future_done + with condition: + second_future_done = True + condition.notify_all() + + second_future.add_done_callback(mark_second_done) + with condition: + assert condition.wait_for(lambda: cleanup_entries == 2 or second_future_done, timeout=5) + release_cleanup.set() + results = [first_future.result(), second_future.result()] + + assert sorted(results) == ['already-deleted', 'deleted'] + + +@pytest.mark.parametrize('operation', ['run', 'install', 'dump', 'load', 'delete']) +def test_temp_operations_after_delete_raise(tmp_path, operation): + """Verify that every temporary isolate operation raises IsolateDeletedError after delete.""" + isolate = TemporaryDirectoryThrong(config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))).get_isolate() + isolate.delete() + + operations = { + 'run': lambda: isolate.run('printf never'), + 'install': lambda: isolate.install('example'), + 'dump': isolate.dump, + 'load': lambda: isolate.load(make_tar_bytes({'file.txt': b'content'})), + 'delete': isolate.delete, + } + + assert_isolate_deleted(operations[operation]) diff --git a/tests/test_result.py b/tests/test_result.py new file mode 100644 index 0000000..8faec74 --- /dev/null +++ b/tests/test_result.py @@ -0,0 +1,122 @@ +from sys import executable + +from suby.subprocess_result import SubprocessResult + +from throng import RunResult, throngs + + +def test_run_result_success_stdout(tmp_path, monkeypatch): + """Verify that a successful command captures stdout and maps to a successful RunResult.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + result = isolate.run('printf hello', catch_output=True) + + assert result.stdout == 'hello' + assert result.stderr in (None, '') + assert result.returncode == 0 + assert result.success is True + + +def test_run_result_success_stderr(tmp_path, monkeypatch): + """Verify that a successful command can capture stderr while still reporting success.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + result = isolate.run(executable, '-c', 'import sys; sys.stderr.write("warn")', catch_output=True, split=False) + + assert result.stderr == 'warn' + assert result.returncode == 0 + assert result.success is True + + +def test_run_result_nonzero_captured(tmp_path, monkeypatch): + """Verify that a captured non-zero command preserves stderr, return code, and success=False.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + result = isolate.run(executable, '-c', 'import sys; sys.stderr.write("bad"); sys.exit(7)', catch_output=True, catch_exceptions=True, split=False) + + assert result.stderr == 'bad' + assert result.returncode == 7 + assert result.success is False + + +def test_run_result_no_output(tmp_path, monkeypatch): + """Verify that a successful command with no output maps empty output and success=True.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + result = isolate.run(executable, '-c', 'pass', catch_output=True, split=False) + + assert result.stdout in (None, '') + assert result.stderr in (None, '') + assert result.returncode == 0 + assert result.success is True + + +def test_run_result_default_forwards_and_maps_output(tmp_path, monkeypatch, capsys): + """Verify that default run forwards stdout while still mapping suby's returned stream content.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + result = isolate.run(executable, '-c', 'print("forwarded")', split=False) + captured = capsys.readouterr() + + assert captured.out == 'forwarded\n' + assert result.stdout == 'forwarded\n' + assert result.stderr in (None, '') + assert result.returncode == 0 + assert result.success is True + + +def test_run_result_startup_failure_returncode(tmp_path, monkeypatch): + """Verify that a nonexistent command maps to suby's startup-failure result shape.""" + monkeypatch.chdir(tmp_path) + + isolate = throngs()['local'].get_isolate() + + result = isolate.run('definitely-not-a-real-command-for-throng', catch_output=True, catch_exceptions=True) + + assert result.id + assert result.stdout == '' + assert result.stderr == '' + assert result.returncode == 1 + assert result.success is False + + +def test_run_result_no_killed_by_token_attribute(): + """Verify that RunResult deliberately does not expose suby's killed_by_token attribute.""" + result = RunResult(id='abc', stdout=None, stderr=None, returncode=0) + + assert not hasattr(result, 'killed_by_token') + + +def test_run_result_id_maps_suby_id(monkeypatch, tmp_path): + """Verify that the subprocess result is fully mapped into RunResult, including the suby id.""" + monkeypatch.chdir(tmp_path) + + def fake_run(*_args, **_kwargs): + return SubprocessResult(id='fixed-id', stdout='out', stderr='err', returncode=0) + + monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) + + result = throngs()['local'].get_isolate().run('anything', catch_output=True) + + assert result.id == 'fixed-id' + assert result.stdout == 'out' + assert result.stderr == 'err' + assert result.returncode == 0 + assert result.success is True + + +def test_run_result_success_property_values(): + """Verify that RunResult.success is true only when returncode is exactly zero.""" + assert RunResult(id='ok', stdout=None, stderr=None, returncode=0).success is True + assert RunResult(id='fail', stdout=None, stderr=None, returncode=1).success is False + assert RunResult(id='none', stdout=None, stderr=None, returncode=None).success is False diff --git a/tests/test_slots.py b/tests/test_slots.py new file mode 100644 index 0000000..0100f5f --- /dev/null +++ b/tests/test_slots.py @@ -0,0 +1,46 @@ +from typing import cast + +import pytest +from emptylog import EmptyLogger, LoggerProtocol +from full_match import match + +from throng import AbstractIsolate, AbstractThrong, throngs +from throng import throngs as package_throngs +from throng.slots import throngs as slot_throngs + + +def test_slots_source_and_reexport(): + """Verify that throng.throngs is the exact slot object exported from throng.slots.""" + assert package_throngs is slot_throngs + + +def test_external_plugin_registration(request): + """Verify that an external plugin registered through the slot is returned as an AbstractThrong instance.""" + + class CustomThrong(AbstractThrong): + def get_isolate(self) -> AbstractIsolate: + raise RuntimeError('not needed') + + custom_throng = CustomThrong() + request.addfinalizer(lambda: throngs.pop('custom_registration_test', None)) + + @throngs.plugin('custom_registration_test') + def custom_registration_test(_logger: LoggerProtocol = EmptyLogger()) -> AbstractThrong: # noqa: B008 + return custom_throng + + registered_throngs = throngs() + + assert registered_throngs['custom_registration_test'] is custom_throng + assert isinstance(registered_throngs['custom_registration_test'], AbstractThrong) + + +def test_plugin_returning_non_throng_fails(request): + """Verify that a plugin returning a non-AbstractThrong value is rejected by slot type checking.""" + request.addfinalizer(lambda: throngs.pop('invalid_registration_test', None)) + + @throngs.plugin('invalid_registration_test') + def invalid_registration_test(_logger: LoggerProtocol = EmptyLogger()) -> AbstractThrong: # noqa: B008 + return cast(AbstractThrong, 'not a throng') + + with pytest.raises(TypeError, match=match('The type str of the plugin\'s "invalid_registration_test" return value \'not a throng\' does not match the expected type AbstractThrong.')): + throngs() From 2f8deba8c29f22a876dd1ba35e34757167131b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:03:25 +0300 Subject: [PATCH 26/59] Add tests for normalizing suby errors in run API --- tests/plugins/test_directory_isolate.py | 44 ++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index d001d4b..1cfed09 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -3,6 +3,7 @@ from os import environ, link, pathsep from os import name as os_name from pathlib import Path +from shutil import rmtree as remove_tree from stat import S_IMODE, S_IREAD, S_IRWXU, S_ISGID, S_ISUID, S_ISVTX, S_IWRITE, S_IXUSR from subprocess import run as run_process from sys import executable @@ -15,7 +16,7 @@ from cantok import CounterToken, SimpleToken, TimeoutToken from emptylog import EmptyLogger, MemoryLogger from full_match import match -from suby import RunningCommandError +from suby import RunningCommandError, WrongCommandError, WrongDirectoryError from suby.subprocess_result import SubprocessResult from tests.helpers import ( @@ -491,6 +492,47 @@ def test_run_env_conflict_raises_command_error_and_logs_failure(tmp_path, monkey assert_any_message_contains(logger.data.exception, 'run', 'failed') +def test_run_malformed_expression_normalizes_wrong_command_error(tmp_path): + """ + Verify that the public run API does not expose suby's parsing error type. + + A naturally malformed quoted command makes suby raise + ``WrongCommandError``; throng must expose ``CommandExecutionError`` and + retain the backend failure only as its chained cause. + """ + logger = MemoryLogger() + isolate = TemporaryDirectoryThrong( + config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False), + ).get_isolate() + + with pytest.raises(CommandExecutionError, match=match('The expression ""unterminated" cannot be parsed.')) as raised: + isolate.run('"unterminated', logger=logger) + + assert isinstance(raised.value.__cause__, WrongCommandError) + assert_any_message_contains(logger.data.exception, 'run', 'failed') + + +def test_run_missing_isolate_directory_normalizes_wrong_directory_error(tmp_path): + """ + Verify that the public run API does not expose suby's directory error type. + + Removing a temporary isolate directory externally makes suby reject its + forced working directory; throng must expose ``CommandExecutionError`` and + retain ``WrongDirectoryError`` only as its chained cause. + """ + logger = MemoryLogger() + isolate = TemporaryDirectoryThrong( + config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=False), + ).get_isolate() + remove_tree(isolate.directory) + + with pytest.raises(CommandExecutionError, match=match(f"The directory '{isolate.directory}' does not exist.")) as raised: + isolate.run('printf never', logger=logger) + + assert isinstance(raised.value.__cause__, WrongDirectoryError) + assert_any_message_contains(logger.data.exception, 'run', 'failed') + + def test_run_nonzero_raises_by_default(tmp_path, monkeypatch): """Verify that a non-zero command raises throng CommandExecutionError by default and does not leak suby errors.""" monkeypatch.chdir(tmp_path) From cd2248190ee600d18c8800e69f9577e3a0f6460d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:04:37 +0300 Subject: [PATCH 27/59] Update test to handle CPython 3.12+ permission error formatting --- tests/plugins/test_temporary_directory_throng.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 5905a6c..62dd08f 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -1,9 +1,10 @@ from concurrent.futures import ThreadPoolExecutor +from errno import EACCES from gc import collect from os import name as os_name from pathlib import Path from stat import S_IEXEC, S_IREAD, S_IWRITE -from sys import executable +from sys import executable, version_info from tempfile import TemporaryDirectory, gettempdir from threading import Barrier, Condition, Event, Lock from typing import cast @@ -343,7 +344,13 @@ def test_temp_configured_base_is_cleaned_when_isolate_is_collected(tmp_path): def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request): - """Verify that a failed temporary delete is logged and can be retried once filesystem access is restored.""" + """ + Verify that a natural delete permission failure is logged and retryable. + + CPython 3.12 preserves the ``Path`` argument in ``shutil.rmtree`` error + text, while earlier supported versions stringify it; the operation + contract is the same in both forms. + """ if os_name == 'nt': pytest.skip('permission mode semantics differ on Windows') @@ -355,8 +362,10 @@ def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request): isolate_directory = isolate.directory request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) base_directory.chmod(S_IREAD | S_IEXEC) + denied_path = isolate_directory if version_info >= (3, 12) else str(isolate_directory) + permission_error_message = str(PermissionError(EACCES, 'Permission denied', denied_path)) - with pytest.raises(PermissionError, match=match(f"[Errno 13] Permission denied: '{isolate_directory}'")): + with pytest.raises(PermissionError, match=match(permission_error_message)): isolate.delete() assert isolate_directory.exists() From a79a5dc49bb1c626e3538d6c6a7804c16ba2f03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:15:47 +0300 Subject: [PATCH 28/59] Add helper to detect hardlink support and update tests to skip on Windows where unsupported --- tests/plugins/test_directory_isolate.py | 53 +++++++++---------- .../test_temporary_directory_throng.py | 12 ++--- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index 1cfed09..9fbc48c 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -79,6 +79,21 @@ def create( return create +def can_create_hardlinks_in_temporary_directory(): + """Return whether this test environment supports hardlinks in its temporary filesystem.""" + with tempfile.TemporaryDirectory() as temporary_directory: + original_file = Path(temporary_directory) / 'original' + linked_file = Path(temporary_directory) / 'linked' + original_file.touch() + + try: + link(str(original_file), str(linked_file)) + except OSError: + return False + + return True + + def test_run_precancelled_token_stops_before_suby(tmp_path, monkeypatch): """Verify that a pre-cancelled run token stops before the subprocess runner is called.""" monkeypatch.chdir(tmp_path) @@ -1455,11 +1470,9 @@ def test_dump_binary_file(temporary_isolate): assert (target.directory / 'payload.bin').read_bytes() == payload +@pytest.mark.skipif(os_name == 'nt', reason='symlink creation often requires elevated privileges on Windows') def test_dump_omits_symbolic_links_from_serialized_contents(temporary_isolate): """Verify that dump serializes regular file contents but does not emit symbolic-link archive members.""" - if os_name == 'nt': - pytest.skip('symlink creation often requires elevated privileges on Windows') - source = temporary_isolate() target = temporary_isolate() regular_file = source.directory / 'regular.txt' @@ -1473,6 +1486,7 @@ def test_dump_omits_symbolic_links_from_serialized_contents(temporary_isolate): assert not (target.directory / 'symbolic.txt').exists() +@pytest.mark.skipif(not can_create_hardlinks_in_temporary_directory(), reason='hardlinks are not supported in this temporary filesystem') def test_dump_serializes_hardlink_paths_as_regular_files(temporary_isolate): """Verify that each hardlink path is dumped as regular file data so that load can restore the archive.""" source = temporary_isolate() @@ -1481,10 +1495,7 @@ def test_dump_serializes_hardlink_paths_as_regular_files(temporary_isolate): second_file = source.directory / 'second.txt' first_file.write_text('content') - try: - link(str(first_file), str(second_file)) - except OSError: - pytest.skip('hardlinks are not supported in this temporary filesystem') + link(str(first_file), str(second_file)) dumped = source.dump() @@ -1497,11 +1508,9 @@ def test_dump_serializes_hardlink_paths_as_regular_files(temporary_isolate): assert read_tree(target.directory) == {'first.txt': b'content', 'second.txt': b'content'} +@pytest.mark.skipif(os_name == 'nt', reason='POSIX named pipes are not available on Windows') def test_dump_omits_named_pipes_from_serialized_contents(temporary_isolate): """Verify that dump omits a POSIX named pipe because snapshots contain regular file data only.""" - if os_name == 'nt': - pytest.skip('POSIX named pipes are not available on Windows') - source = temporary_isolate() target = temporary_isolate() kept_file = source.directory / 'regular.txt' @@ -1515,11 +1524,9 @@ def test_dump_omits_named_pipes_from_serialized_contents(temporary_isolate): assert not (target.directory / 'ignored.pipe').exists() +@pytest.mark.skipif(os_name == 'nt', reason='POSIX mode expectations do not apply on Windows') def test_dump_preserves_ordinary_permission_mode_where_practical(temporary_isolate): """Verify that dump/load preserves complete ordinary POSIX permission bits while retaining executable status.""" - if os_name == 'nt': - pytest.skip('POSIX mode expectations do not apply on Windows') - source = temporary_isolate() target = temporary_isolate() script = source.directory / 'script.sh' @@ -1532,11 +1539,9 @@ def test_dump_preserves_ordinary_permission_mode_where_practical(temporary_isola assert S_IMODE((target.directory / 'script.sh').stat().st_mode) == expected_mode +@pytest.mark.skipif(os_name == 'nt', reason='POSIX mode expectations do not apply on Windows') def test_load_masks_special_permission_bits(temporary_isolate): """Verify that load strips special permission bits while preserving ordinary permissions.""" - if os_name == 'nt': - pytest.skip('POSIX mode expectations do not apply on Windows') - isolate = temporary_isolate() special_bits = S_ISUID | S_ISGID | S_ISVTX mode = special_bits | S_IRWXU @@ -1683,11 +1688,9 @@ def test_load_preserves_empty_excluded_venv(temporary_isolate): assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' +@pytest.mark.skipif(os_name == 'nt', reason='POSIX named pipes are not available on Windows') def test_load_preserves_excluded_named_pipe(temporary_isolate): """Verify that load leaves a pre-existing excluded POSIX named pipe unchanged.""" - if os_name == 'nt': - pytest.skip('POSIX named pipes are not available on Windows') - isolate = temporary_isolate(dump_exclude=['kept.pipe']) kept_pipe = isolate.directory / 'kept.pipe' run_process(['mkfifo', str(kept_pipe)], check=True) @@ -1698,11 +1701,9 @@ def test_load_preserves_excluded_named_pipe(temporary_isolate): assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' +@pytest.mark.skipif(os_name == 'nt', reason='symlink creation often requires elevated privileges on Windows') def test_load_preserves_excluded_symbolic_link(tmp_path, temporary_isolate): """Verify that load retains an excluded symbolic link and its existing external target.""" - if os_name == 'nt': - pytest.skip('symlink creation often requires elevated privileges on Windows') - external_target = tmp_path / 'external.txt' external_target.write_text('outside') isolate = temporary_isolate(dump_exclude=['kept-link']) @@ -2354,11 +2355,9 @@ def test_load_tempdir_creation_failure_is_wrapped_and_logged(tmp_path, monkeypat assert [str(call.message) for call in logger.data.exception] == ['Archive unpack failed: cannot create temporary load directories: Not a directory.'] +@pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_load_permission_denied_write(request, temporary_isolate): """Verify that a write failure reports only genuinely unrestored paths and retains untouched old data.""" - if os_name == 'nt': - pytest.skip('permission mode semantics differ on Windows') - isolate = temporary_isolate() (isolate.directory / 'old.txt').write_text('old') request.addfinalizer(lambda: isolate.directory.chmod(S_IREAD | S_IWRITE | S_IXUSR)) @@ -2374,11 +2373,9 @@ def test_load_permission_denied_write(request, temporary_isolate): assert_any_message_contains(logger.data.exception, 'archive', 'failed') +@pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_dump_permission_denied_read(request, temporary_isolate): """Verify that a read failure during dump propagates the read error and logs the failure.""" - if os_name == 'nt': - pytest.skip('permission mode semantics differ on Windows') - isolate = temporary_isolate() unreadable = isolate.directory / 'unreadable.txt' unreadable.write_text('secret') diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 62dd08f..aa2c36a 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -100,11 +100,9 @@ def test_temp_base_file(tmp_path): ] +@pytest.mark.skipif(os_name == 'nt', reason='symlink creation often requires elevated privileges on Windows') def test_temp_base_symlink_to_file(tmp_path): """Verify that a file symlink temporary base path raises and logs InvalidBaseDirectoryError.""" - if os_name == 'nt': - pytest.skip('symlink creation often requires elevated privileges on Windows') - target_file = tmp_path / 'file' target_file.write_text('content') symlink_path = tmp_path / 'link' @@ -120,11 +118,9 @@ def test_temp_base_symlink_to_file(tmp_path): ] +@pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_temp_base_not_writable(tmp_path, request): """Verify that a non-writable temporary base directory is rejected and logged where permissions apply.""" - if os_name == 'nt': - pytest.skip('permission mode semantics differ on Windows') - base_directory = tmp_path / 'base' base_directory.mkdir() request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) @@ -343,6 +339,7 @@ def test_temp_configured_base_is_cleaned_when_isolate_is_collected(tmp_path): assert not isolate_directory.exists() +@pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request): """ Verify that a natural delete permission failure is logged and retryable. @@ -351,9 +348,6 @@ def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request): text, while earlier supported versions stringify it; the operation contract is the same in both forms. """ - if os_name == 'nt': - pytest.skip('permission mode semantics differ on Windows') - base_directory = tmp_path / 'base' base_directory.mkdir() logger = MemoryLogger() From a611ec9a0300269629f58a595a8f6c2fa8191802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:24:22 +0300 Subject: [PATCH 29/59] Skip hardlink test if hardlinks unsupported --- tests/plugins/test_directory_isolate.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index 9fbc48c..98c9f25 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -79,21 +79,6 @@ def create( return create -def can_create_hardlinks_in_temporary_directory(): - """Return whether this test environment supports hardlinks in its temporary filesystem.""" - with tempfile.TemporaryDirectory() as temporary_directory: - original_file = Path(temporary_directory) / 'original' - linked_file = Path(temporary_directory) / 'linked' - original_file.touch() - - try: - link(str(original_file), str(linked_file)) - except OSError: - return False - - return True - - def test_run_precancelled_token_stops_before_suby(tmp_path, monkeypatch): """Verify that a pre-cancelled run token stops before the subprocess runner is called.""" monkeypatch.chdir(tmp_path) @@ -1486,7 +1471,6 @@ def test_dump_omits_symbolic_links_from_serialized_contents(temporary_isolate): assert not (target.directory / 'symbolic.txt').exists() -@pytest.mark.skipif(not can_create_hardlinks_in_temporary_directory(), reason='hardlinks are not supported in this temporary filesystem') def test_dump_serializes_hardlink_paths_as_regular_files(temporary_isolate): """Verify that each hardlink path is dumped as regular file data so that load can restore the archive.""" source = temporary_isolate() From ffbec13ed5795fe7060bb4cc0d0d3d7bfdfc4342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:26:29 +0300 Subject: [PATCH 30/59] Update coverage to be version-specific by Python version --- requirements_dev.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 4c89aee..3a09486 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,7 +1,8 @@ pytest==8.3.5 pytest-xdist==3.6.1; python_version < '3.9' pytest-xdist==3.8.0; python_version >= '3.9' -coverage==7.6.1 +coverage==7.6.1; python_version == '3.8' +coverage==7.6.10; python_version >= '3.9' coverage-pyver-pragma==0.4.0 build==1.2.2.post1 mypy==1.14.1 From 7de8b48c479e736887e8964c3e3a0e65fe29ea78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:39:15 +0300 Subject: [PATCH 31/59] Fix virtual env path error messages to use config value instead of resolved path --- throng/plugins/directory_isolate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/throng/plugins/directory_isolate.py b/throng/plugins/directory_isolate.py index 240bd02..e837648 100644 --- a/throng/plugins/directory_isolate.py +++ b/throng/plugins/directory_isolate.py @@ -786,13 +786,13 @@ def _resolve_venv_path(self) -> Path: configured_path = Path(self.config.venv_path) if configured_path.is_absolute(): - raise InvalidVirtualEnvPathError(f'Virtual environment path must be relative to the isolate directory, got absolute path: {configured_path}') + raise InvalidVirtualEnvPathError(f'Virtual environment path must be relative to the isolate directory, got absolute path: {self.config.venv_path}') resolved_path = (self.directory / configured_path).resolve() isolate_path = self.directory.resolve() if resolved_path != isolate_path and isolate_path not in resolved_path.parents: - raise InvalidVirtualEnvPathError(f'Virtual environment path escapes outside the isolate directory: {configured_path}') + raise InvalidVirtualEnvPathError(f'Virtual environment path escapes outside the isolate directory: {self.config.venv_path}') return resolved_path From 894b8a5508155e3da2e5414149114037fd63b7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 22:40:49 +0300 Subject: [PATCH 32/59] Factor out Python executable path for cross-platform venv support --- tests/plugins/test_directory_isolate.py | 88 ++++++++++++++++++------- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index 98c9f25..6565d75 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -41,6 +41,8 @@ TemporaryDirectoryThrong, ) +VENV_PYTHON_RELATIVE_PATH = Path('Scripts') / 'python.exe' if os_name == 'nt' else Path('bin') / 'python' + class TemporaryIsolateFactory(Protocol): def __call__( @@ -526,7 +528,9 @@ def test_run_missing_isolate_directory_normalizes_wrong_directory_error(tmp_path ).get_isolate() remove_tree(isolate.directory) - with pytest.raises(CommandExecutionError, match=match(f"The directory '{isolate.directory}' does not exist.")) as raised: + expected_message = f'The directory {str(isolate.directory)!r} does not exist.' + + with pytest.raises(CommandExecutionError, match=match(expected_message)) as raised: isolate.run('printf never', logger=logger) assert isinstance(raised.value.__cause__, WrongDirectoryError) @@ -613,9 +617,10 @@ def fake_run(*args, **_kwargs): calls.append(args) if '-m' in args and 'venv' in args: venv_path = Path(args[-1]) - (venv_path / 'bin').mkdir(parents=True) - (venv_path / 'bin' / 'python').write_text('') - (venv_path / 'bin' / 'python').chmod(S_IREAD | S_IWRITE | S_IXUSR) + python_path = venv_path / VENV_PYTHON_RELATIVE_PATH + python_path.parent.mkdir(parents=True) + python_path.write_text('') + python_path.chmod(S_IREAD | S_IWRITE | S_IXUSR) return SubprocessResult(id='install', returncode=0) monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) @@ -639,7 +644,7 @@ def fake_run(*args, **_kwargs): monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH venv_python.parent.mkdir(parents=True) venv_python.write_text('') venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) @@ -658,9 +663,10 @@ def fake_run(*args, **kwargs): calls.append((args, kwargs)) if '-m' in args and 'venv' in args: venv_path = Path(args[-1]) - (venv_path / 'bin').mkdir(parents=True) - (venv_path / 'bin' / 'python').write_text('') - (venv_path / 'bin' / 'python').chmod(S_IREAD | S_IWRITE | S_IXUSR) + python_path = venv_path / VENV_PYTHON_RELATIVE_PATH + python_path.parent.mkdir(parents=True) + python_path.write_text('') + python_path.chmod(S_IREAD | S_IWRITE | S_IXUSR) return SubprocessResult(id='install', returncode=0) monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) @@ -681,7 +687,7 @@ def fake_run(*args, **kwargs): assert latest_run_call[0] == ('anything',) assert isinstance(latest_run_call[1]['add_env'], dict) assert latest_run_call[1]['add_env']['VIRTUAL_ENV'] == str(expected_venv_path) - assert str(expected_venv_path / 'bin') in str(latest_run_call[1]['add_env']['PATH']) + assert str(expected_venv_path / VENV_PYTHON_RELATIVE_PATH.parent) in str(latest_run_call[1]['add_env']['PATH']) def test_install_venv_creation_failure_raises_install_error(tmp_path, monkeypatch): @@ -712,7 +718,7 @@ def fake_run(*_args, **_kwargs): monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - python_path = isolate.directory / '.venv' / 'bin' / 'python' + python_path = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is missing: {python_path}')): isolate.install('example', logger=logger) @@ -869,7 +875,7 @@ def fake_run(*_args, **kwargs): monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH venv_python.parent.mkdir(parents=True) venv_python.write_text('') venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) @@ -878,7 +884,7 @@ def fake_run(*_args, **kwargs): assert observed_env is not None assert observed_env['VIRTUAL_ENV'] == str(isolate.directory / '.venv') - assert str((isolate.directory / '.venv' / 'bin')) in observed_env['PATH'] + assert str(venv_python.parent) in observed_env['PATH'] def test_run_does_not_activate_virtual_environment_before_it_exists(tmp_path, monkeypatch): @@ -912,7 +918,7 @@ def fake_run(*_args, **kwargs): monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH venv_python.parent.mkdir(parents=True) venv_python.write_text('') venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) @@ -937,7 +943,7 @@ def fake_run(*_args, **kwargs): monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH venv_python.parent.mkdir(parents=True) venv_python.write_text('') venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) @@ -953,7 +959,7 @@ def test_run_venv_path_respects_explicit_empty_environment(tmp_path): """Verify that venv activation does not restore the parent PATH when env explicitly replaces it with nothing.""" config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - venv_python = isolate.directory / '.venv' / 'bin' / 'python' + venv_python = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH venv_python.parent.mkdir(parents=True) venv_python.write_text('') venv_python.chmod(S_IREAD | S_IWRITE | S_IXUSR) @@ -976,8 +982,9 @@ def test_run_broken_venv_error(tmp_path): config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() (isolate.directory / '.venv').mkdir() + python_path = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH - with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is missing: {isolate.directory / ".venv" / "bin" / "python"}')): + with pytest.raises(InvalidVirtualEnvPathError, match=match(f'Virtual environment python executable is missing: {python_path}')): isolate.run('anything') @@ -993,7 +1000,7 @@ def fake_run(*args, **_kwargs): monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', fake_run) config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() - python_path = isolate.directory / '.venv' / 'bin' / 'python' + python_path = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH python_path.mkdir(parents=True) operations = { 'run': lambda: isolate.run('anything'), @@ -1280,9 +1287,10 @@ def successful_run(*args, **_kwargs): calls.append(args) if '-m' in args and 'venv' in args: venv_path = Path(args[-1]) - (venv_path / 'bin').mkdir(parents=True) - (venv_path / 'bin' / 'python').write_text('') - (venv_path / 'bin' / 'python').chmod(S_IREAD | S_IWRITE | S_IXUSR) + python_path = venv_path / VENV_PYTHON_RELATIVE_PATH + python_path.parent.mkdir(parents=True) + python_path.write_text('') + python_path.chmod(S_IREAD | S_IWRITE | S_IXUSR) return SubprocessResult(id='install-ok', returncode=0) monkeypatch.setattr('throng.plugins.directory_isolate.run_suby', successful_run) @@ -1575,7 +1583,14 @@ def test_dump_excludes_custom_venv_path_by_default(temporary_isolate): assert 'custom/env/installed.txt' not in archive.getnames() -@pytest.mark.parametrize('venv_path', ['[env]', 'venv*', '!']) +@pytest.mark.parametrize( + 'venv_path', + [ + '[env]', + pytest.param('venv*', marks=pytest.mark.skipif(os_name == 'nt', reason='Windows does not permit "*" in directory names')), + '!', + ], +) def test_dump_excludes_custom_venv_path_as_a_literal_directory(venv_path: str, temporary_isolate): """Verify that a custom venv path containing glob syntax excludes only that literal directory during dump.""" isolate = temporary_isolate(venv_path=venv_path) @@ -1631,7 +1646,14 @@ def test_load_preserves_excluded_venv(temporary_isolate): assert (isolate.directory / 'fresh.txt').read_text() == 'fresh' -@pytest.mark.parametrize('venv_path', ['[env]', 'venv*', '!']) +@pytest.mark.parametrize( + 'venv_path', + [ + '[env]', + pytest.param('venv*', marks=pytest.mark.skipif(os_name == 'nt', reason='Windows does not permit "*" in directory names')), + '!', + ], +) def test_load_preserves_custom_venv_path_as_a_literal_directory(venv_path: str, temporary_isolate): """Verify that load preserves only the literal custom venv directory even when its name resembles a glob.""" isolate = temporary_isolate(venv_path=venv_path) @@ -2325,8 +2347,9 @@ def test_operation_rejects_malformed_dump_exclude_added_after_configuration(oper assert_any_message_contains(logger.data.exception, operation_name, 'invalid', 'exclude') -def test_load_tempdir_creation_failure_is_wrapped_and_logged(tmp_path, monkeypatch, temporary_isolate): - """Verify that a real tempfile creation failure is wrapped as ArchiveUnpackError and logged.""" +@pytest.mark.skipif(os_name == 'nt', reason='Windows reports a distinct native tempfile failure reason') +def test_load_tempdir_creation_failure_is_wrapped_and_logged_on_posix(tmp_path, monkeypatch, temporary_isolate): + """Verify that a POSIX tempfile creation failure is wrapped with its native diagnostic and logged.""" tempdir_file = tmp_path / 'not-a-directory' tempdir_file.write_text('content') monkeypatch.setattr(tempfile, 'tempdir', str(tempdir_file)) @@ -2339,6 +2362,23 @@ def test_load_tempdir_creation_failure_is_wrapped_and_logged(tmp_path, monkeypat assert [str(call.message) for call in logger.data.exception] == ['Archive unpack failed: cannot create temporary load directories: Not a directory.'] +@pytest.mark.skipif(os_name != 'nt', reason='POSIX reports a distinct native tempfile failure reason') +def test_load_tempdir_creation_failure_is_wrapped_and_logged_on_windows(tmp_path, monkeypatch, temporary_isolate): + """Verify that a Windows tempfile creation failure is wrapped with its native diagnostic and logged.""" + tempdir_file = tmp_path / 'not-a-directory' + tempdir_file.write_text('content') + monkeypatch.setattr(tempfile, 'tempdir', str(tempdir_file)) + isolate = temporary_isolate() + logger = MemoryLogger() + + expected_reason = 'The system cannot find the path specified' + + with pytest.raises(ArchiveUnpackError, match=match(f'archive unpack failed: cannot create temporary load directories: {expected_reason}')): + isolate.load(make_tar_bytes({'new.txt': b'new'}), logger=logger) + + assert [str(call.message) for call in logger.data.exception] == [f'Archive unpack failed: cannot create temporary load directories: {expected_reason}.'] + + @pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_load_permission_denied_write(request, temporary_isolate): """Verify that a write failure reports only genuinely unrestored paths and retains untouched old data.""" From d6fe7dda488ff18f34cfb98b51bb12f075488053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:03:40 +0300 Subject: [PATCH 33/59] Add Windows-specific file handle helper for testing sharing violations --- tests/helpers.py | 44 +++++++++++++ tests/plugins/test_directory_isolate.py | 57 +++++++++++++++++ .../test_temporary_directory_throng.py | 62 ++++++++++++++++++- 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index 7515761..828af03 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,7 @@ +from contextlib import ExitStack, contextmanager from io import BytesIO from pathlib import Path +from sys import platform from tarfile import TarInfo from tarfile import open as open_tar from typing import Dict, Iterable, Optional @@ -7,6 +9,48 @@ from dirstree import Crawler from emptylog.call_data import LoggerCallData +if platform == 'win32': + from ctypes import ( # type: ignore[attr-defined] # POSIX typeshed omits the Windows-only API. + WinDLL, + c_uint32, + c_void_p, + c_wchar_p, + get_last_error, + ) + + +@contextmanager +def hold_windows_path_open(path: Path, *, share_mode: int, flags: int): + """ + Keep a Windows filesystem object open with explicit sharing permissions. + + This helper is called only by Windows-only tests. It uses the native API + because Python's regular ``open`` does not let tests choose delete sharing. + """ + if platform != 'win32': + raise RuntimeError('Windows handles can only be held open on Windows.') + + kernel32 = WinDLL('kernel32', use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = [c_wchar_p, c_uint32, c_uint32, c_void_p, c_uint32, c_uint32, c_void_p] + create_file.restype = c_void_p + close_handle = kernel32.CloseHandle + close_handle.argtypes = [c_void_p] + close_handle.restype = c_uint32 + + generic_read = 0x80000000 + open_existing = 3 + invalid_handle = c_void_p(-1).value + handle = create_file(str(path), generic_read, share_mode, None, open_existing, flags, None) + + if handle == invalid_handle: + raise OSError(get_last_error(), f'Could not open Windows lock target: {path}') + + with ExitStack() as resources: + resources.callback(close_handle, handle) + + yield + def assert_any_message_contains(calls: Iterable[LoggerCallData], *chunks: str) -> None: """Assert that at least one logged message contains all chunks, case-insensitively.""" diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index 6565d75..74d2c56 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -21,6 +21,7 @@ from tests.helpers import ( assert_any_message_contains, + hold_windows_path_open, make_tar_bytes, read_tree, ) @@ -42,6 +43,10 @@ ) VENV_PYTHON_RELATIVE_PATH = Path('Scripts') / 'python.exe' if os_name == 'nt' else Path('bin') / 'python' +WINDOWS_FILE_ATTRIBUTE_NORMAL = 0x00000080 +WINDOWS_FILE_SHARE_READ = 0x00000001 +WINDOWS_FILE_SHARE_WRITE = 0x00000002 +WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process' class TemporaryIsolateFactory(Protocol): @@ -2397,6 +2402,36 @@ def test_load_permission_denied_write(request, temporary_isolate): assert_any_message_contains(logger.data.exception, 'archive', 'failed') +@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX') +def test_load_windows_locked_file_reports_backup_failure_and_retains_old_data(temporary_isolate): + """ + Verify that a native Windows commit failure logs an archive failure and preserves live data. + + A readable handle that denies delete sharing lets rollback copy the existing + file into backup before deletion fails, exercising the common rollback path + that must ignore the incomplete backup copy. + """ + isolate = temporary_isolate() + existing_file = isolate.directory / 'old.txt' + existing_file.write_text('old') + logger = MemoryLogger() + + with hold_windows_path_open( + existing_file, + share_mode=WINDOWS_FILE_SHARE_READ | WINDOWS_FILE_SHARE_WRITE, + flags=WINDOWS_FILE_ATTRIBUTE_NORMAL, + ), pytest.raises( + ArchiveUnpackError, + match=match(f'Archive commit failed; rollback attempted. Cause: {WINDOWS_SHARING_VIOLATION_REASON}.'), + ) as raised: + isolate.load(make_tar_bytes({'new.txt': b'new'}), logger=logger) + + assert 'Unrestored paths:' not in str(raised.value) + assert existing_file.read_text() == 'old' + assert not (isolate.directory / 'new.txt').exists() + assert_any_message_contains(logger.data.exception, 'archive', 'failed') + + @pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_dump_permission_denied_read(request, temporary_isolate): """Verify that a read failure during dump propagates the read error and logs the failure.""" @@ -2412,3 +2447,25 @@ def test_dump_permission_denied_read(request, temporary_isolate): isolate.dump(logger=logger) assert_any_message_contains(logger.data.exception, 'dump', 'failed') + + +@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX') +def test_dump_windows_locked_file_propagates_read_failure_and_logs_failure(temporary_isolate): + """ + Verify that a native Windows read failure during dump is propagated and logged. + + An exclusive native handle prevents the archive operation from reading a + regular isolate file, so dump must fail instead of returning partial bytes. + """ + isolate = temporary_isolate() + unreadable = isolate.directory / 'unreadable.txt' + unreadable.write_text('secret') + logger = MemoryLogger() + + with hold_windows_path_open(unreadable, share_mode=0, flags=WINDOWS_FILE_ATTRIBUTE_NORMAL), pytest.raises( + PermissionError, + match=match(f"[Errno 13] Permission denied: '{unreadable}'"), + ): + isolate.dump(logger=logger) + + assert_any_message_contains(logger.data.exception, 'dump', 'failed') diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index aa2c36a..7897ea7 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -15,7 +15,7 @@ from locklib import LockTraceWrapper from suby.subprocess_result import SubprocessResult -from tests.helpers import make_tar_bytes +from tests.helpers import hold_windows_path_open, make_tar_bytes from throng import ( InvalidBaseDirectoryError, IsolateDeletedError, @@ -28,6 +28,11 @@ TemporaryDirectoryThrong, ) +WINDOWS_DIRECTORY_HANDLE_FLAGS = 0x02000000 +WINDOWS_FILE_SHARE_READ = 0x00000001 +WINDOWS_FILE_SHARE_WRITE = 0x00000002 +WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process' + def assert_isolate_deleted(operation): with pytest.raises(IsolateDeletedError, match=match('Isolate has been deleted.')): @@ -136,6 +141,29 @@ def test_temp_base_not_writable(tmp_path, request): ] +@pytest.mark.skipif(os_name != 'nt', reason='Windows read-only directory attributes do not apply on POSIX') +def test_temp_base_read_only_on_windows_is_rejected_and_logged(tmp_path, request): + """ + Verify that a Windows read-only temporary base directory is rejected and logged. + + Setting the Windows read-only attribute makes the configured base fail the + plugin's ``W_OK`` check, so no temporary isolate may be created inside it. + """ + base_directory = tmp_path / 'base' + base_directory.mkdir() + request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) + base_directory.chmod(S_IREAD) + config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) + logger = MemoryLogger() + + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')): + TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + + assert [str(call.message) for call in logger.data.error] == [ + f'Temporary base directory is not writable: {base_directory}', + ] + + def test_temp_isolates_are_distinct(tmp_path): """Verify that temporary isolates get separate directories and do not share files.""" config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path)) @@ -373,6 +401,38 @@ def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request): assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1 +@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX') +def test_temp_delete_locked_windows_directory_raises_and_can_be_retried(tmp_path): + """ + Verify that a native Windows delete failure is logged and leaves deletion retryable. + + An open directory handle without delete sharing blocks the first cleanup; + after the handle closes, the same isolate must be deletable successfully. + """ + logger = MemoryLogger() + throng = TemporaryDirectoryThrong(logger=logger, config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) + isolate = throng.get_isolate() + isolate_directory = isolate.directory + denied_path = isolate_directory if version_info >= (3, 12) else str(isolate_directory) + permission_error_message = f'[WinError 32] {WINDOWS_SHARING_VIOLATION_REASON}: {denied_path!r}' + + with hold_windows_path_open( + isolate_directory, + share_mode=WINDOWS_FILE_SHARE_READ | WINDOWS_FILE_SHARE_WRITE, + flags=WINDOWS_DIRECTORY_HANDLE_FLAGS, + ), pytest.raises(PermissionError, match=match(permission_error_message)): + isolate.delete() + + assert isolate_directory.exists() + assert any('Delete failed' in str(call.message) for call in logger.data.exception) + assert all(str(call.message) != 'Delete completed successfully.' for call in logger.data.info) + + isolate.delete() + + assert not isolate_directory.exists() + assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1 + + def test_temp_delete_after_delete_raises(tmp_path): """Verify that a second delete call is rejected and logged as a delete operation.""" logger = MemoryLogger() From 9b9240b275926e0e9c62ef2e7381b91623d04eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:17:36 +0300 Subject: [PATCH 34/59] Move Windows ctypes imports inside context manager block --- tests/helpers.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 828af03..6fc288a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -9,15 +9,6 @@ from dirstree import Crawler from emptylog.call_data import LoggerCallData -if platform == 'win32': - from ctypes import ( # type: ignore[attr-defined] # POSIX typeshed omits the Windows-only API. - WinDLL, - c_uint32, - c_void_p, - c_wchar_p, - get_last_error, - ) - @contextmanager def hold_windows_path_open(path: Path, *, share_mode: int, flags: int): @@ -30,6 +21,14 @@ def hold_windows_path_open(path: Path, *, share_mode: int, flags: int): if platform != 'win32': raise RuntimeError('Windows handles can only be held open on Windows.') + from ctypes import ( # type: ignore[attr-defined] # noqa: PLC0415 - this API exists only on Windows. + WinDLL, + c_uint32, + c_void_p, + c_wchar_p, + get_last_error, + ) + kernel32 = WinDLL('kernel32', use_last_error=True) create_file = kernel32.CreateFileW create_file.argtypes = [c_wchar_p, c_uint32, c_uint32, c_void_p, c_uint32, c_uint32, c_void_p] From 249cd3327847728432f57f06d49083cde77d828e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:18:19 +0300 Subject: [PATCH 35/59] Add pragma no cover comments for Windows paths --- throng/plugins/directory_isolate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/throng/plugins/directory_isolate.py b/throng/plugins/directory_isolate.py index e837648..2ed6811 100644 --- a/throng/plugins/directory_isolate.py +++ b/throng/plugins/directory_isolate.py @@ -801,7 +801,7 @@ def _get_venv_python_path(self, venv_path: Path) -> Path: if os_name == 'nt': return venv_path / 'Scripts' / 'python.exe' - return venv_path / 'bin' / 'python' + return venv_path / 'bin' / 'python' # pragma: no cover (Windows) def _validate_venv_python_path(self, python_path: Path) -> None: """Require a runnable Python executable at the configured venv path.""" @@ -810,7 +810,7 @@ def _validate_venv_python_path(self, python_path: Path) -> None: if not python_path.is_file(): raise InvalidVirtualEnvPathError(f'Virtual environment python executable is not a regular file: {python_path}') if os_name != 'nt' and not access(python_path, X_OK): - raise InvalidVirtualEnvPathError(f'Virtual environment python executable is not executable: {python_path}') + raise InvalidVirtualEnvPathError(f'Virtual environment python executable is not executable: {python_path}') # pragma: no cover (Windows) def _build_run_add_env(self, add_env: Optional[Mapping[str, str]], env: Optional[Mapping[str, str]]) -> Optional[Mapping[str, str]]: """ From 6a776bd613577a7482c6c4cb26699af0f46c3236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:19:31 +0300 Subject: [PATCH 36/59] Refactor temporary directory creation to handle permission errors gracefully --- throng/plugins/temporary_directory_throng.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/throng/plugins/temporary_directory_throng.py b/throng/plugins/temporary_directory_throng.py index 07a18dc..35b1e05 100644 --- a/throng/plugins/temporary_directory_throng.py +++ b/throng/plugins/temporary_directory_throng.py @@ -1,6 +1,5 @@ # mypy: disable-error-code=misc # skelet's Storage metaclass exposes Any through class-definition metadata. -from os import W_OK, access from pathlib import Path from tempfile import TemporaryDirectory from typing import Optional @@ -55,22 +54,26 @@ def get_isolate(self) -> DirectoryIsolate: self._validate_base_directory(base_directory) isolate_directory = base_directory / uuid4().hex - isolate_directory.mkdir() + + try: + isolate_directory.mkdir() + except PermissionError as error: + validation_error_message = f'Temporary base directory is not writable: {base_directory}' + self.logger.exception(validation_error_message) + raise InvalidBaseDirectoryError(validation_error_message) from error self.logger.info(f'Creating temporary isolate "{isolate_directory}" inside base directory "{base_directory}".') return DirectoryIsolate(isolate_directory, self.config, self.logger, lock=EmptyLock(), owns_directory=True) def _validate_base_directory(self, base_directory: Path) -> None: - """Reject configured bases that cannot contain newly created isolates.""" + """Reject configured bases that do not exist as directories.""" validation_error_message = None if not base_directory.exists(): validation_error_message = f'Temporary base directory does not exist: {base_directory}' elif not base_directory.is_dir(): validation_error_message = f'Temporary base path is not a directory: {base_directory}' - elif not access(base_directory, W_OK): - validation_error_message = f'Temporary base directory is not writable: {base_directory}' if validation_error_message is not None: self.logger.error(validation_error_message) From 721b6ead564a02ee4e9af1c07dc5a815bffa2107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:21:04 +0300 Subject: [PATCH 37/59] Fix permission error message matching in tests --- tests/plugins/test_directory_isolate.py | 4 ++- .../test_temporary_directory_throng.py | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index 74d2c56..d9260aa 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -1,4 +1,5 @@ import tempfile +from errno import EACCES from io import BytesIO from os import environ, link, pathsep from os import name as os_name @@ -2461,10 +2462,11 @@ def test_dump_windows_locked_file_propagates_read_failure_and_logs_failure(tempo unreadable = isolate.directory / 'unreadable.txt' unreadable.write_text('secret') logger = MemoryLogger() + permission_error_message = str(PermissionError(EACCES, 'Permission denied', str(unreadable))) with hold_windows_path_open(unreadable, share_mode=0, flags=WINDOWS_FILE_ATTRIBUTE_NORMAL), pytest.raises( PermissionError, - match=match(f"[Errno 13] Permission denied: '{unreadable}'"), + match=match(permission_error_message), ): isolate.dump(logger=logger) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 7897ea7..ba29c74 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -4,6 +4,7 @@ from os import name as os_name from pathlib import Path from stat import S_IEXEC, S_IREAD, S_IWRITE +from subprocess import run as run_process from sys import executable, version_info from tempfile import TemporaryDirectory, gettempdir from threading import Barrier, Condition, Event, Lock @@ -125,7 +126,7 @@ def test_temp_base_symlink_to_file(tmp_path): @pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows') def test_temp_base_not_writable(tmp_path, request): - """Verify that a non-writable temporary base directory is rejected and logged where permissions apply.""" + """Verify that a native POSIX creation denial is normalized, chained, and logged.""" base_directory = tmp_path / 'base' base_directory.mkdir() request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) @@ -133,33 +134,38 @@ def test_temp_base_not_writable(tmp_path, request): config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) logger = MemoryLogger() - with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')): + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')) as raised: TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() - assert [str(call.message) for call in logger.data.error] == [ + assert isinstance(raised.value.__cause__, PermissionError) + assert list(base_directory.iterdir()) == [] + assert [str(call.message) for call in logger.data.exception] == [ f'Temporary base directory is not writable: {base_directory}', ] -@pytest.mark.skipif(os_name != 'nt', reason='Windows read-only directory attributes do not apply on POSIX') -def test_temp_base_read_only_on_windows_is_rejected_and_logged(tmp_path, request): +@pytest.mark.skipif(os_name != 'nt', reason='Windows access control lists are not available on POSIX') +def test_temp_base_denied_write_on_windows_is_rejected_and_logged(tmp_path, request): """ - Verify that a Windows read-only temporary base directory is rejected and logged. + Verify that a Windows temporary base with denied write access is rejected and logged. - Setting the Windows read-only attribute makes the configured base fail the - plugin's ``W_OK`` check, so no temporary isolate may be created inside it. + A native deny-write ACL prevents creation of the isolate UUID directory. + The plugin must normalize that native denial to ``InvalidBaseDirectoryError``. """ base_directory = tmp_path / 'base' base_directory.mkdir() - request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC)) - base_directory.chmod(S_IREAD) + user_name = run_process(['whoami'], check=True, capture_output=True, text=True).stdout.strip() + request.addfinalizer(lambda: run_process(['icacls', str(base_directory), '/remove:d', user_name], check=True, capture_output=True)) + run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(OI)(CI)(W)'], check=True, capture_output=True) config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) logger = MemoryLogger() - with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')): + with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')) as raised: TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() - assert [str(call.message) for call in logger.data.error] == [ + assert isinstance(raised.value.__cause__, PermissionError) + assert list(base_directory.iterdir()) == [] + assert [str(call.message) for call in logger.data.exception] == [ f'Temporary base directory is not writable: {base_directory}', ] From d6642367218e09a3caec1d17a22bc0645569be54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:27:10 +0300 Subject: [PATCH 38/59] Remove mypy disable directives from temporary directory plugins --- throng/plugins/directory_isolate.py | 2 -- throng/plugins/temporary_directory_throng.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/throng/plugins/directory_isolate.py b/throng/plugins/directory_isolate.py index 2ed6811..fa61865 100644 --- a/throng/plugins/directory_isolate.py +++ b/throng/plugins/directory_isolate.py @@ -1,5 +1,3 @@ -# mypy: disable-error-code=misc -# skelet's Storage metaclass exposes Any through class-definition metadata. from functools import partial from io import BytesIO from os import X_OK, access, environ, pathsep diff --git a/throng/plugins/temporary_directory_throng.py b/throng/plugins/temporary_directory_throng.py index 35b1e05..7c44adc 100644 --- a/throng/plugins/temporary_directory_throng.py +++ b/throng/plugins/temporary_directory_throng.py @@ -1,5 +1,3 @@ -# mypy: disable-error-code=misc -# skelet's Storage metaclass exposes Any through class-definition metadata. from pathlib import Path from tempfile import TemporaryDirectory from typing import Optional From 0baf0969eb2d840f7767a49c779d44088e94a7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:28:09 +0300 Subject: [PATCH 39/59] Update test to verify denial of subdirectory creation on Windows --- tests/plugins/test_temporary_directory_throng.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index ba29c74..1d073f0 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -145,18 +145,18 @@ def test_temp_base_not_writable(tmp_path, request): @pytest.mark.skipif(os_name != 'nt', reason='Windows access control lists are not available on POSIX') -def test_temp_base_denied_write_on_windows_is_rejected_and_logged(tmp_path, request): +def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logged(tmp_path, request): """ - Verify that a Windows temporary base with denied write access is rejected and logged. + Verify that a Windows temporary base unable to contain a child isolate is rejected and logged. - A native deny-write ACL prevents creation of the isolate UUID directory. + A native deny-add-subdirectory ACL prevents creation of the isolate UUID directory. The plugin must normalize that native denial to ``InvalidBaseDirectoryError``. """ base_directory = tmp_path / 'base' base_directory.mkdir() user_name = run_process(['whoami'], check=True, capture_output=True, text=True).stdout.strip() request.addfinalizer(lambda: run_process(['icacls', str(base_directory), '/remove:d', user_name], check=True, capture_output=True)) - run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(OI)(CI)(W)'], check=True, capture_output=True) + run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(AD)'], check=True, capture_output=True) config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) logger = MemoryLogger() From ea156d1402ffbc7b1346596740ee4b6ea1df7897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:30:56 +0300 Subject: [PATCH 40/59] Remove Python 3.8 version flag from mypy run --- .github/workflows/lint.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ae04bd2..e9db245 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,7 +44,6 @@ jobs: shell: bash run: >- mypy - --python-version 3.8 --show-error-codes --strict --disallow-any-decorated From a0f530ef79aed7de5ceb2226f86bd865f8c05a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:39:50 +0300 Subject: [PATCH 41/59] Fix permission error message construction for Windows --- tests/plugins/test_temporary_directory_throng.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 1d073f0..51319ff 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -419,8 +419,7 @@ def test_temp_delete_locked_windows_directory_raises_and_can_be_retried(tmp_path throng = TemporaryDirectoryThrong(logger=logger, config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))) isolate = throng.get_isolate() isolate_directory = isolate.directory - denied_path = isolate_directory if version_info >= (3, 12) else str(isolate_directory) - permission_error_message = f'[WinError 32] {WINDOWS_SHARING_VIOLATION_REASON}: {denied_path!r}' + permission_error_message = str(PermissionError(EACCES, WINDOWS_SHARING_VIOLATION_REASON, str(isolate_directory), 32)) with hold_windows_path_open( isolate_directory, From 44898eb9b2f90b241a4d5e7ee8705d976f99321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:50:18 +0300 Subject: [PATCH 42/59] Add mypy disable for skelet.Storage Any workaround --- throng/plugins/directory_isolate.py | 2 ++ throng/plugins/temporary_directory_throng.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/throng/plugins/directory_isolate.py b/throng/plugins/directory_isolate.py index fa61865..44e757a 100644 --- a/throng/plugins/directory_isolate.py +++ b/throng/plugins/directory_isolate.py @@ -1,3 +1,5 @@ +# mypy: disable-error-code=misc +# Work around skelet.Storage exposing Any in its public class type: https://github.com/mutating/skelet/issues/24 from functools import partial from io import BytesIO from os import X_OK, access, environ, pathsep diff --git a/throng/plugins/temporary_directory_throng.py b/throng/plugins/temporary_directory_throng.py index 7c44adc..9c7399b 100644 --- a/throng/plugins/temporary_directory_throng.py +++ b/throng/plugins/temporary_directory_throng.py @@ -1,3 +1,5 @@ +# mypy: disable-error-code=misc +# Work around skelet.Storage exposing Any in its public class type: https://github.com/mutating/skelet/issues/24 from pathlib import Path from tempfile import TemporaryDirectory from typing import Optional From 6f519c0b104d431b71d7c6888987f9511207f8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Tue, 26 May 2026 23:50:39 +0300 Subject: [PATCH 43/59] Enhance Windows ACL test to probe native denial behavior --- .../test_temporary_directory_throng.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 51319ff..2f88b1a 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -149,14 +149,33 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge """ Verify that a Windows temporary base unable to contain a child isolate is rejected and logged. - A native deny-add-subdirectory ACL prevents creation of the isolate UUID directory. - The plugin must normalize that native denial to ``InvalidBaseDirectoryError``. + A direct probe first verifies that the native deny-add-subdirectory ACL is + effective on the runner. If it is ineffective, the failure reports the + runner ACL and privileges instead of blaming the plugin. Once effective, + the plugin must normalize the same denial to ``InvalidBaseDirectoryError``. """ base_directory = tmp_path / 'base' base_directory.mkdir() user_name = run_process(['whoami'], check=True, capture_output=True, text=True).stdout.strip() request.addfinalizer(lambda: run_process(['icacls', str(base_directory), '/remove:d', user_name], check=True, capture_output=True)) run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(AD)'], check=True, capture_output=True) + access_control_listing = run_process(['icacls', str(base_directory)], check=True, capture_output=True, text=True).stdout + privilege_listing = run_process(['whoami', '/priv'], check=True, capture_output=True, text=True).stdout + probe_directory = base_directory / 'acl-probe' + + def create_probe_directory(): + probe_directory.mkdir() + pytest.fail( + 'Windows ACL precondition failed: direct child directory creation succeeded.\n' + f'icacls output:\n{access_control_listing}\n' + f'whoami /priv output:\n{privilege_listing}', + ) + + permission_error_message = str(PermissionError(EACCES, 'Permission denied', str(probe_directory), 5)) + + with pytest.raises(PermissionError, match=match(permission_error_message)): + create_probe_directory() + config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) logger = MemoryLogger() From 444e6f3621b29d1d567a2a325eca21b9ecd74231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 00:11:20 +0300 Subject: [PATCH 44/59] Add Windows subprocess helper to test restricted DACL permissions --- .../test_temporary_directory_throng.py | 298 ++++++++++++++++-- 1 file changed, 272 insertions(+), 26 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 2f88b1a..4a757de 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -1,12 +1,17 @@ from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack from errno import EACCES from gc import collect +from json import loads +from os import environ from os import name as os_name from pathlib import Path from stat import S_IEXEC, S_IREAD, S_IWRITE +from subprocess import list2cmdline from subprocess import run as run_process from sys import executable, version_info from tempfile import TemporaryDirectory, gettempdir +from textwrap import dedent from threading import Barrier, Condition, Event, Lock from typing import cast @@ -35,6 +40,186 @@ WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process' +def run_python_without_windows_privileges(*arguments: str) -> int: # noqa: PLR0915 - Win32 process setup is deliberately localized here. + """ + Start this Python interpreter under a restricted Windows access token. + + This helper is intentionally kept next to its single test because it is + not application logic: it prepares a realistic Windows test environment. + ``CreateRestrictedToken`` clones the token belonging to the pytest + process and, with ``DISABLE_MAX_PRIVILEGE``, removes optional privileges + from the child. ``CreateProcessWithTokenW`` then starts the requested + Python command using that restricted token while preserving the current + environment and working directory. + + Preserving the environment is significant for CI coverage. The workflow + sets ``COVERAGE_PROCESS_START`` and installs a ``.pth`` startup hook in + the active interpreter. The child therefore starts coverage normally, + writes its own parallel data file on exit, and the existing + ``coverage combine`` command merges code executed in this child into the + final report. + """ + if os_name != 'nt': + raise RuntimeError('Restricted Windows Python processes can only be started on Windows.') + + from ctypes import ( # type: ignore[attr-defined] # noqa: PLC0415 - these APIs exist only on Windows. + POINTER, + Structure, + WinDLL, + byref, + c_void_p, + create_unicode_buffer, + get_last_error, + sizeof, + ) + from ctypes import ( # noqa: PLC0415 - Windows-only helper. + cast as ctypes_cast, + ) + from ctypes.wintypes import ( # noqa: PLC0415 - Windows-only helper. + BOOL, + BYTE, + DWORD, + HANDLE, + LPVOID, + LPWSTR, + WORD, + ) + + class StartupInfo(Structure): + """Declare the Win32 startup record required to create a process.""" + + _fields_ = [ # noqa: RUF012 - ctypes describes native records through mutable class metadata. + ('cb', DWORD), + ('lpReserved', LPWSTR), + ('lpDesktop', LPWSTR), + ('lpTitle', LPWSTR), + ('dwX', DWORD), + ('dwY', DWORD), + ('dwXSize', DWORD), + ('dwYSize', DWORD), + ('dwXCountChars', DWORD), + ('dwYCountChars', DWORD), + ('dwFillAttribute', DWORD), + ('dwFlags', DWORD), + ('wShowWindow', WORD), + ('cbReserved2', WORD), + ('lpReserved2', POINTER(BYTE)), + ('hStdInput', HANDLE), + ('hStdOutput', HANDLE), + ('hStdError', HANDLE), + ] + + class ProcessInformation(Structure): + """Declare the Win32 handles returned for a newly created process.""" + + _fields_ = [ # noqa: RUF012 - ctypes describes native records through mutable class metadata. + ('hProcess', HANDLE), + ('hThread', HANDLE), + ('dwProcessId', DWORD), + ('dwThreadId', DWORD), + ] + + kernel32 = WinDLL('kernel32', use_last_error=True) + advapi32 = WinDLL('advapi32', use_last_error=True) + close_handle = kernel32.CloseHandle + close_handle.argtypes = [HANDLE] + close_handle.restype = BOOL + get_current_process = kernel32.GetCurrentProcess + get_current_process.argtypes = [] + get_current_process.restype = HANDLE + wait_for_single_object = kernel32.WaitForSingleObject + wait_for_single_object.argtypes = [HANDLE, DWORD] + wait_for_single_object.restype = DWORD + get_exit_code_process = kernel32.GetExitCodeProcess + get_exit_code_process.argtypes = [HANDLE, POINTER(DWORD)] + get_exit_code_process.restype = BOOL + open_process_token = advapi32.OpenProcessToken + open_process_token.argtypes = [HANDLE, DWORD, POINTER(HANDLE)] + open_process_token.restype = BOOL + create_restricted_token = advapi32.CreateRestrictedToken + create_restricted_token.argtypes = [ + HANDLE, + DWORD, + DWORD, + LPVOID, + DWORD, + LPVOID, + DWORD, + LPVOID, + POINTER(HANDLE), + ] + create_restricted_token.restype = BOOL + create_process_with_token = advapi32.CreateProcessWithTokenW + create_process_with_token.argtypes = [ + HANDLE, + DWORD, + LPWSTR, + LPWSTR, + DWORD, + LPVOID, + LPWSTR, + POINTER(StartupInfo), + POINTER(ProcessInformation), + ] + create_process_with_token.restype = BOOL + + token_access = 0x0001 | 0x0002 | 0x0008 + disable_max_privilege = 0x00000001 + create_unicode_environment = 0x00000400 + infinite_wait = 0xFFFFFFFF + process_token = HANDLE() + restricted_token = HANDLE() + startup_information = StartupInfo() + startup_information.cb = sizeof(StartupInfo) + process_information = ProcessInformation() + command_line = create_unicode_buffer(list2cmdline([executable, *arguments])) + environment_block = create_unicode_buffer(''.join(f'{name}={value}\0' for name, value in environ.items()) + '\0') + + with ExitStack() as handles: + if not open_process_token(get_current_process(), token_access, byref(process_token)): + raise OSError(get_last_error(), 'Could not open the current Windows process token.') + + handles.callback(close_handle, process_token) + + if not create_restricted_token( + process_token, + disable_max_privilege, + 0, + None, + 0, + None, + 0, + None, + byref(restricted_token), + ): + raise OSError(get_last_error(), 'Could not create a restricted Windows process token.') + + handles.callback(close_handle, restricted_token) + + if not create_process_with_token( + restricted_token, + 0, + None, + command_line, + create_unicode_environment, + ctypes_cast(environment_block, c_void_p), + str(Path.cwd()), + byref(startup_information), + byref(process_information), + ): + raise OSError(get_last_error(), 'Could not start Python with a restricted Windows process token.') + + handles.callback(close_handle, process_information.hThread) + handles.callback(close_handle, process_information.hProcess) + wait_for_single_object(process_information.hProcess, infinite_wait) + exit_code = DWORD() + + if not get_exit_code_process(process_information.hProcess, byref(exit_code)): + raise OSError(get_last_error(), 'Could not read the restricted Python process exit code.') + + return int(exit_code.value) + + def assert_isolate_deleted(operation): with pytest.raises(IsolateDeletedError, match=match('Isolate has been deleted.')): operation() @@ -147,44 +332,105 @@ def test_temp_base_not_writable(tmp_path, request): @pytest.mark.skipif(os_name != 'nt', reason='Windows access control lists are not available on POSIX') def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logged(tmp_path, request): """ - Verify that a Windows temporary base unable to contain a child isolate is rejected and logged. - - A direct probe first verifies that the native deny-add-subdirectory ACL is - effective on the runner. If it is ineffective, the failure reports the - runner ACL and privileges instead of blaming the plugin. Once effective, - the plugin must normalize the same denial to ``InvalidBaseDirectoryError``. + Verify that Windows rejects and logs a configured base that cannot hold a child isolate. + + On Windows, a directory does not become unwritable merely because its + read-only attribute is set. Access is primarily determined by the + directory's discretionary access control list (DACL): its access control + entries may explicitly allow or deny rights to a user. ``icacls`` adds a + deny entry for ``AD`` ("add subdirectory") here, which is the exact right + needed when the plugin creates the isolate's UUID-named child directory. + + There is one additional Windows rule that matters in GitHub Actions. + Every process has an access token that identifies both the user and extra + privileges held by that process. The hosted Windows runner enables + ``SeBackupPrivilege`` and ``SeRestorePrivilege`` on pytest's token. + Restore privilege can allow a process to create filesystem objects even + when an ordinary DACL-based write attempt would fail. Consequently the + parent pytest process is intentionally unsuitable for checking this + permission-denied branch: it can bypass the denial that an ordinary user + process would observe. + + The test therefore keeps pytest as the supervising parent but executes the + library call in a second Python process created through the Win32 APIs + ``CreateRestrictedToken(DISABLE_MAX_PRIVILEGE)`` and + ``CreateProcessWithTokenW``. The child still has the same user identity, + Python installation, imports and normal filesystem access outside this + denied directory, but it no longer has optional token privileges capable + of bypassing the DACL. The child records its result and ``MemoryLogger`` + messages as JSON because Python exception and logger objects cannot be + directly asserted across process boundaries. + + This subprocess does not hide executed code from coverage. CI starts + coverage in Python subprocesses with a site ``.pth`` hook controlled by + the inherited ``COVERAGE_PROCESS_START`` variable. This child inherits + that environment, runs the same interpreter without ``-S``, and keeps the + repository as its working directory. Coverage therefore writes a normal + parallel child data file, which the workflow's existing + ``coverage combine`` step includes in the 100-percent report. """ base_directory = tmp_path / 'base' base_directory.mkdir() user_name = run_process(['whoami'], check=True, capture_output=True, text=True).stdout.strip() request.addfinalizer(lambda: run_process(['icacls', str(base_directory), '/remove:d', user_name], check=True, capture_output=True)) run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(AD)'], check=True, capture_output=True) - access_control_listing = run_process(['icacls', str(base_directory)], check=True, capture_output=True, text=True).stdout - privilege_listing = run_process(['whoami', '/priv'], check=True, capture_output=True, text=True).stdout - probe_directory = base_directory / 'acl-probe' - - def create_probe_directory(): - probe_directory.mkdir() - pytest.fail( - 'Windows ACL precondition failed: direct child directory creation succeeded.\n' - f'icacls output:\n{access_control_listing}\n' - f'whoami /priv output:\n{privilege_listing}', + result_path = tmp_path / 'restricted-child-result.json' + child_script = dedent( + """ + from json import dumps + from pathlib import Path + from subprocess import run + from sys import argv + + from emptylog import MemoryLogger + + from throng.plugins.temporary_directory_throng import ( + TemporaryDirectoryIsolationConfig, + TemporaryDirectoryThrong, ) - permission_error_message = str(PermissionError(EACCES, 'Permission denied', str(probe_directory), 5)) + base_directory = Path(argv[1]) + result_path = Path(argv[2]) + logger = MemoryLogger() + privilege_listing = run(['whoami', '/priv'], check=True, capture_output=True, text=True).stdout - with pytest.raises(PermissionError, match=match(permission_error_message)): - create_probe_directory() - - config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) - logger = MemoryLogger() + try: + TemporaryDirectoryThrong( + logger=logger, + config=TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)), + ).get_isolate() + except Exception as error: + result = { + 'exception_type': type(error).__name__, + 'message': str(error), + 'cause_type': type(error.__cause__).__name__ if error.__cause__ is not None else None, + 'exception_logs': [str(call.message) for call in logger.data.exception], + 'privilege_listing': privilege_listing, + } + else: + result = { + 'exception_type': None, + 'message': None, + 'cause_type': None, + 'exception_logs': [str(call.message) for call in logger.data.exception], + 'privilege_listing': privilege_listing, + } + + result_path.write_text(dumps(result)) + """, + ) - with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')) as raised: - TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() + child_exit_code = run_python_without_windows_privileges('-c', child_script, str(base_directory), str(result_path)) + result = loads(result_path.read_text()) - assert isinstance(raised.value.__cause__, PermissionError) + assert child_exit_code == 0 + assert not any('SeBackupPrivilege' in line and 'Enabled' in line for line in result['privilege_listing'].splitlines()) + assert not any('SeRestorePrivilege' in line and 'Enabled' in line for line in result['privilege_listing'].splitlines()) + assert result['exception_type'] == 'InvalidBaseDirectoryError' + assert result['message'] == f'Temporary base directory is not writable: {base_directory}' + assert result['cause_type'] == 'PermissionError' assert list(base_directory.iterdir()) == [] - assert [str(call.message) for call in logger.data.exception] == [ + assert result['exception_logs'] == [ f'Temporary base directory is not writable: {base_directory}', ] From 080f166dde7c5cadd5df9c7b82f05622dcb505e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 01:10:33 +0300 Subject: [PATCH 45/59] Add description of throng's purpose and use cases --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 362f63b..dadbfc6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # throng +Sometimes our programs need to execute console commands. In some cases, a command may be executed locally, while in others it may be executed in parallel across thousands of machines in the cloud. This library serves as an abstraction layer for various command execution environments, allowing you to write code once that will run anywhere. + + + ## Known limitations `load()` preserves excluded paths by temporarily moving existing isolate From 737499502015d416757c9f9bc2d17bdf9a187322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 01:17:21 +0300 Subject: [PATCH 46/59] Enhanced README with plugin architecture details and benefits --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dadbfc6..f288218 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # throng -Sometimes our programs need to execute console commands. In some cases, a command may be executed locally, while in others it may be executed in parallel across thousands of machines in the cloud. This library serves as an abstraction layer for various command execution environments, allowing you to write code once that will run anywhere. +Sometimes our programs need to execute console commands. In some cases, a command may be executed locally, while in others it may be executed in parallel across thousands of machines in the cloud. This library serves as an abstraction layer over various command execution environments, allowing you to write code once that will run anywhere. Specific execution environments are connected here as plugins (and you can even write your own!) with a unified API, and your code doesn’t need to know the internal workings of a specific plugin to run commands within it. +This library provides: + +- Complete isolation of the program’s core logic from where and how commands are executed. The logic remains compact and describes the essence of the problem. +- The ability to easily swap one implementation for another. For example, you can replace local command execution with execution inside a Docker container or a cloud virtual machine without changing the code. +- Parallelization is simple. ## Known limitations From 14a58d3230366d97d849d7bda0491dc49b179d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 01:23:28 +0300 Subject: [PATCH 47/59] Enhance parallelization description to clarify plugin autonomy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f288218..ad633e7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This library provides: - Complete isolation of the program’s core logic from where and how commands are executed. The logic remains compact and describes the essence of the problem. - The ability to easily swap one implementation for another. For example, you can replace local command execution with execution inside a Docker container or a cloud virtual machine without changing the code. -- Parallelization is simple. +- Parallelization is simple. Each plugin decides for itself what level of parallelism it needs, so the main code doesn’t even need to know that it’s running in parallel. ## Known limitations From 6efa06c55d0598a1ec0627b122a735585a4db893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 12:12:22 +0300 Subject: [PATCH 48/59] Use temporary .py files on Windows to bypass CreateProcessWithTokenW command-line limit --- tests/plugins/test_temporary_directory_throng.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 4a757de..b547807 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -58,6 +58,11 @@ def run_python_without_windows_privileges(*arguments: str) -> int: # noqa: PLR0 writes its own parallel data file on exit, and the existing ``coverage combine`` command merges code executed in this child into the final report. + + ``CreateProcessWithTokenW`` accepts at most 1024 command-line + characters. Callers must therefore pass a script file path rather than + a substantial inline ``python -c`` program; otherwise Windows rejects + the process creation call before Python starts. """ if os_name != 'nt': raise RuntimeError('Restricted Windows Python processes can only be started on Windows.') @@ -368,6 +373,11 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge repository as its working directory. Coverage therefore writes a normal parallel child data file, which the workflow's existing ``coverage combine`` step includes in the 100-percent report. + + The child program is saved to a temporary ``.py`` file instead of being + supplied through ``python -c``. This is required because + ``CreateProcessWithTokenW`` has a 1024-character command-line limit and + this deliberately explanatory test program is longer than that limit. """ base_directory = tmp_path / 'base' base_directory.mkdir() @@ -375,6 +385,7 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge request.addfinalizer(lambda: run_process(['icacls', str(base_directory), '/remove:d', user_name], check=True, capture_output=True)) run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(AD)'], check=True, capture_output=True) result_path = tmp_path / 'restricted-child-result.json' + child_script_path = tmp_path / 'restricted-child.py' child_script = dedent( """ from json import dumps @@ -419,8 +430,9 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge result_path.write_text(dumps(result)) """, ) + child_script_path.write_text(child_script) - child_exit_code = run_python_without_windows_privileges('-c', child_script, str(base_directory), str(result_path)) + child_exit_code = run_python_without_windows_privileges(str(child_script_path), str(base_directory), str(result_path)) result = loads(result_path.read_text()) assert child_exit_code == 0 From 0835cf98263417500fe099d3f3e01bc15ba13d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 12:19:11 +0300 Subject: [PATCH 49/59] Update Windows restricted token process launcher to use CreateProcessAsUserW --- .../test_temporary_directory_throng.py | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index b547807..3538e5d 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -48,9 +48,12 @@ def run_python_without_windows_privileges(*arguments: str) -> int: # noqa: PLR0 not application logic: it prepares a realistic Windows test environment. ``CreateRestrictedToken`` clones the token belonging to the pytest process and, with ``DISABLE_MAX_PRIVILEGE``, removes optional privileges - from the child. ``CreateProcessWithTokenW`` then starts the requested - Python command using that restricted token while preserving the current - environment and working directory. + from the child. Microsoft documents ``CreateProcessAsUserW`` as the + launcher for a process that uses this restricted token. For a restricted + version of the caller's own primary token, Windows does not require + ``SeAssignPrimaryTokenPrivilege``; the API temporarily enables a required + privilege that is already present but disabled on the caller's token. + The child preserves the current environment and working directory. Preserving the environment is significant for CI coverage. The workflow sets ``COVERAGE_PROCESS_START`` and installs a ``.pth`` startup hook in @@ -59,10 +62,10 @@ def run_python_without_windows_privileges(*arguments: str) -> int: # noqa: PLR0 ``coverage combine`` command merges code executed in this child into the final report. - ``CreateProcessWithTokenW`` accepts at most 1024 command-line - characters. Callers must therefore pass a script file path rather than - a substantial inline ``python -c`` program; otherwise Windows rejects - the process creation call before Python starts. + The caller passes a script file path rather than a substantial inline + ``python -c`` program. Apart from keeping native process-launch details + readable, this avoids coupling the test to a launcher's command-line + length and quoting behavior. """ if os_name != 'nt': raise RuntimeError('Restricted Windows Python processes can only be started on Windows.') @@ -154,19 +157,21 @@ class ProcessInformation(Structure): POINTER(HANDLE), ] create_restricted_token.restype = BOOL - create_process_with_token = advapi32.CreateProcessWithTokenW - create_process_with_token.argtypes = [ + create_process_as_user = advapi32.CreateProcessAsUserW + create_process_as_user.argtypes = [ HANDLE, - DWORD, LPWSTR, LPWSTR, + LPVOID, + LPVOID, + BOOL, DWORD, LPVOID, LPWSTR, POINTER(StartupInfo), POINTER(ProcessInformation), ] - create_process_with_token.restype = BOOL + create_process_as_user.restype = BOOL token_access = 0x0001 | 0x0002 | 0x0008 disable_max_privilege = 0x00000001 @@ -201,18 +206,20 @@ class ProcessInformation(Structure): handles.callback(close_handle, restricted_token) - if not create_process_with_token( + if not create_process_as_user( restricted_token, - 0, - None, + str(executable), command_line, + None, + None, + False, create_unicode_environment, ctypes_cast(environment_block, c_void_p), str(Path.cwd()), byref(startup_information), byref(process_information), ): - raise OSError(get_last_error(), 'Could not start Python with a restricted Windows process token.') + raise OSError(get_last_error(), 'Could not start Python through a restricted Windows process token.') handles.callback(close_handle, process_information.hThread) handles.callback(close_handle, process_information.hProcess) @@ -359,7 +366,9 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge The test therefore keeps pytest as the supervising parent but executes the library call in a second Python process created through the Win32 APIs ``CreateRestrictedToken(DISABLE_MAX_PRIVILEGE)`` and - ``CreateProcessWithTokenW``. The child still has the same user identity, + ``CreateProcessAsUserW``. This is the documented API pair for running a + process under a restricted copy of the caller's own primary token: the + child still has the same user identity, Python installation, imports and normal filesystem access outside this denied directory, but it no longer has optional token privileges capable of bypassing the DACL. The child records its result and ``MemoryLogger`` @@ -375,9 +384,8 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge ``coverage combine`` step includes in the 100-percent report. The child program is saved to a temporary ``.py`` file instead of being - supplied through ``python -c``. This is required because - ``CreateProcessWithTokenW`` has a 1024-character command-line limit and - this deliberately explanatory test program is longer than that limit. + supplied through ``python -c`` so that native process-launch mechanics do + not depend on the length or quoting of this explanatory test program. """ base_directory = tmp_path / 'base' base_directory.mkdir() From df800683338af6eda6abf6a4c60ceeb6ab2f2869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 12:30:20 +0300 Subject: [PATCH 50/59] Refactor Windows privilege test to use context manager for impersonation --- .../test_temporary_directory_throng.py | 268 +++++------------- 1 file changed, 65 insertions(+), 203 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 3538e5d..0fe31a8 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -1,19 +1,15 @@ from concurrent.futures import ThreadPoolExecutor -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from errno import EACCES from gc import collect -from json import loads -from os import environ from os import name as os_name from pathlib import Path from stat import S_IEXEC, S_IREAD, S_IWRITE -from subprocess import list2cmdline from subprocess import run as run_process from sys import executable, version_info from tempfile import TemporaryDirectory, gettempdir -from textwrap import dedent from threading import Barrier, Condition, Event, Lock -from typing import cast +from typing import Iterator, cast import pytest from emptylog import MemoryLogger @@ -40,93 +36,49 @@ WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process' -def run_python_without_windows_privileges(*arguments: str) -> int: # noqa: PLR0915 - Win32 process setup is deliberately localized here. +@contextmanager +def impersonate_without_windows_privileges() -> Iterator[None]: """ - Start this Python interpreter under a restricted Windows access token. + Temporarily run the current thread under a restricted Windows access token. This helper is intentionally kept next to its single test because it is not application logic: it prepares a realistic Windows test environment. - ``CreateRestrictedToken`` clones the token belonging to the pytest - process and, with ``DISABLE_MAX_PRIVILEGE``, removes optional privileges - from the child. Microsoft documents ``CreateProcessAsUserW`` as the - launcher for a process that uses this restricted token. For a restricted - version of the caller's own primary token, Windows does not require - ``SeAssignPrimaryTokenPrivilege``; the API temporarily enables a required - privilege that is already present but disabled on the caller's token. - The child preserves the current environment and working directory. - - Preserving the environment is significant for CI coverage. The workflow - sets ``COVERAGE_PROCESS_START`` and installs a ``.pth`` startup hook in - the active interpreter. The child therefore starts coverage normally, - writes its own parallel data file on exit, and the existing - ``coverage combine`` command merges code executed in this child into the - final report. - - The caller passes a script file path rather than a substantial inline - ``python -c`` program. Apart from keeping native process-launch details - readable, this avoids coupling the test to a launcher's command-line - length and quoting behavior. + On Windows, a process has a primary access token describing its identity, + group memberships and privileges. Threads normally use that token for + filesystem access checks. ``CreateRestrictedToken`` clones it and, + with ``DISABLE_MAX_PRIVILEGE``, disables optional privileges such as the + restore privilege that lets the hosted CI runner bypass an ACL denial. + + ``ImpersonateLoggedOnUser`` attaches that restricted token only to the + current test thread. While the context is active, filesystem operations + made by the library use the restricted token for their access checks; the + pytest worker process itself is not replaced or restarted. + ``RevertToSelf`` removes the impersonation before handles are closed and + before the test performs cleanup, so subsequent pytest activity resumes + under the runner's original security context. + + Running the operation in this same Python process is also important for + coverage. The permission-denied exception handler is executed directly + inside the already-instrumented pytest worker, rather than in a spawned + child whose separate coverage data would have to be transported and + combined reliably on every Windows/Python combination. """ if os_name != 'nt': - raise RuntimeError('Restricted Windows Python processes can only be started on Windows.') + raise RuntimeError('Restricted Windows impersonation can only be used on Windows.') from ctypes import ( # type: ignore[attr-defined] # noqa: PLC0415 - these APIs exist only on Windows. POINTER, - Structure, WinDLL, byref, - c_void_p, - create_unicode_buffer, get_last_error, - sizeof, - ) - from ctypes import ( # noqa: PLC0415 - Windows-only helper. - cast as ctypes_cast, ) from ctypes.wintypes import ( # noqa: PLC0415 - Windows-only helper. BOOL, - BYTE, DWORD, HANDLE, LPVOID, - LPWSTR, - WORD, ) - class StartupInfo(Structure): - """Declare the Win32 startup record required to create a process.""" - - _fields_ = [ # noqa: RUF012 - ctypes describes native records through mutable class metadata. - ('cb', DWORD), - ('lpReserved', LPWSTR), - ('lpDesktop', LPWSTR), - ('lpTitle', LPWSTR), - ('dwX', DWORD), - ('dwY', DWORD), - ('dwXSize', DWORD), - ('dwYSize', DWORD), - ('dwXCountChars', DWORD), - ('dwYCountChars', DWORD), - ('dwFillAttribute', DWORD), - ('dwFlags', DWORD), - ('wShowWindow', WORD), - ('cbReserved2', WORD), - ('lpReserved2', POINTER(BYTE)), - ('hStdInput', HANDLE), - ('hStdOutput', HANDLE), - ('hStdError', HANDLE), - ] - - class ProcessInformation(Structure): - """Declare the Win32 handles returned for a newly created process.""" - - _fields_ = [ # noqa: RUF012 - ctypes describes native records through mutable class metadata. - ('hProcess', HANDLE), - ('hThread', HANDLE), - ('dwProcessId', DWORD), - ('dwThreadId', DWORD), - ] - kernel32 = WinDLL('kernel32', use_last_error=True) advapi32 = WinDLL('advapi32', use_last_error=True) close_handle = kernel32.CloseHandle @@ -135,12 +87,6 @@ class ProcessInformation(Structure): get_current_process = kernel32.GetCurrentProcess get_current_process.argtypes = [] get_current_process.restype = HANDLE - wait_for_single_object = kernel32.WaitForSingleObject - wait_for_single_object.argtypes = [HANDLE, DWORD] - wait_for_single_object.restype = DWORD - get_exit_code_process = kernel32.GetExitCodeProcess - get_exit_code_process.argtypes = [HANDLE, POINTER(DWORD)] - get_exit_code_process.restype = BOOL open_process_token = advapi32.OpenProcessToken open_process_token.argtypes = [HANDLE, DWORD, POINTER(HANDLE)] open_process_token.restype = BOOL @@ -157,33 +103,17 @@ class ProcessInformation(Structure): POINTER(HANDLE), ] create_restricted_token.restype = BOOL - create_process_as_user = advapi32.CreateProcessAsUserW - create_process_as_user.argtypes = [ - HANDLE, - LPWSTR, - LPWSTR, - LPVOID, - LPVOID, - BOOL, - DWORD, - LPVOID, - LPWSTR, - POINTER(StartupInfo), - POINTER(ProcessInformation), - ] - create_process_as_user.restype = BOOL - - token_access = 0x0001 | 0x0002 | 0x0008 + impersonate_logged_on_user = advapi32.ImpersonateLoggedOnUser + impersonate_logged_on_user.argtypes = [HANDLE] + impersonate_logged_on_user.restype = BOOL + revert_to_self = advapi32.RevertToSelf + revert_to_self.argtypes = [] + revert_to_self.restype = BOOL + + token_access = 0x0002 | 0x0008 disable_max_privilege = 0x00000001 - create_unicode_environment = 0x00000400 - infinite_wait = 0xFFFFFFFF process_token = HANDLE() restricted_token = HANDLE() - startup_information = StartupInfo() - startup_information.cb = sizeof(StartupInfo) - process_information = ProcessInformation() - command_line = create_unicode_buffer(list2cmdline([executable, *arguments])) - environment_block = create_unicode_buffer(''.join(f'{name}={value}\0' for name, value in environ.items()) + '\0') with ExitStack() as handles: if not open_process_token(get_current_process(), token_access, byref(process_token)): @@ -206,30 +136,16 @@ class ProcessInformation(Structure): handles.callback(close_handle, restricted_token) - if not create_process_as_user( - restricted_token, - str(executable), - command_line, - None, - None, - False, - create_unicode_environment, - ctypes_cast(environment_block, c_void_p), - str(Path.cwd()), - byref(startup_information), - byref(process_information), - ): - raise OSError(get_last_error(), 'Could not start Python through a restricted Windows process token.') + if not impersonate_logged_on_user(restricted_token): + raise OSError(get_last_error(), 'Could not impersonate a restricted Windows access token.') - handles.callback(close_handle, process_information.hThread) - handles.callback(close_handle, process_information.hProcess) - wait_for_single_object(process_information.hProcess, infinite_wait) - exit_code = DWORD() + def revert_security_context() -> None: + if not revert_to_self(): + raise OSError(get_last_error(), 'Could not restore the original Windows access token.') - if not get_exit_code_process(process_information.hProcess, byref(exit_code)): - raise OSError(get_last_error(), 'Could not read the restricted Python process exit code.') + handles.callback(revert_security_context) - return int(exit_code.value) + yield def assert_isolate_deleted(operation): @@ -363,94 +279,40 @@ def test_temp_base_denied_subdirectory_creation_on_windows_is_rejected_and_logge permission-denied branch: it can bypass the denial that an ordinary user process would observe. - The test therefore keeps pytest as the supervising parent but executes the - library call in a second Python process created through the Win32 APIs - ``CreateRestrictedToken(DISABLE_MAX_PRIVILEGE)`` and - ``CreateProcessAsUserW``. This is the documented API pair for running a - process under a restricted copy of the caller's own primary token: the - child still has the same user identity, - Python installation, imports and normal filesystem access outside this - denied directory, but it no longer has optional token privileges capable - of bypassing the DACL. The child records its result and ``MemoryLogger`` - messages as JSON because Python exception and logger objects cannot be - directly asserted across process boundaries. - - This subprocess does not hide executed code from coverage. CI starts - coverage in Python subprocesses with a site ``.pth`` hook controlled by - the inherited ``COVERAGE_PROCESS_START`` variable. This child inherits - that environment, runs the same interpreter without ``-S``, and keeps the - repository as its working directory. Coverage therefore writes a normal - parallel child data file, which the workflow's existing - ``coverage combine`` step includes in the 100-percent report. - - The child program is saved to a temporary ``.py`` file instead of being - supplied through ``python -c`` so that native process-launch mechanics do - not depend on the length or quoting of this explanatory test program. + The test therefore leaves the pytest worker alive and temporarily changes + only the current thread's security context. It obtains the worker's + primary token, creates a restricted copy with + ``CreateRestrictedToken(DISABLE_MAX_PRIVILEGE)``, then applies that copy + with ``ImpersonateLoggedOnUser``. Windows uses this restricted token for + filesystem access checks performed by the thread while the context is + active. The library operation consequently sees the ACL denial as a real + ``PermissionError``. On leaving the context, ``RevertToSelf`` restores + the original runner token before any assertions or cleanup are performed. + + This same-thread approach is also what makes the coverage assertion + reliable. The four error-handling lines in + ``TemporaryDirectoryThrong.get_isolate`` execute directly inside the + pytest worker already being traced by coverage, rather than in a separate + restricted process whose coverage files would have to survive and be + combined by CI. """ base_directory = tmp_path / 'base' base_directory.mkdir() user_name = run_process(['whoami'], check=True, capture_output=True, text=True).stdout.strip() request.addfinalizer(lambda: run_process(['icacls', str(base_directory), '/remove:d', user_name], check=True, capture_output=True)) run_process(['icacls', str(base_directory), '/deny', f'{user_name}:(AD)'], check=True, capture_output=True) - result_path = tmp_path / 'restricted-child-result.json' - child_script_path = tmp_path / 'restricted-child.py' - child_script = dedent( - """ - from json import dumps - from pathlib import Path - from subprocess import run - from sys import argv - - from emptylog import MemoryLogger - - from throng.plugins.temporary_directory_throng import ( - TemporaryDirectoryIsolationConfig, - TemporaryDirectoryThrong, - ) - - base_directory = Path(argv[1]) - result_path = Path(argv[2]) - logger = MemoryLogger() - privilege_listing = run(['whoami', '/priv'], check=True, capture_output=True, text=True).stdout - - try: - TemporaryDirectoryThrong( - logger=logger, - config=TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)), - ).get_isolate() - except Exception as error: - result = { - 'exception_type': type(error).__name__, - 'message': str(error), - 'cause_type': type(error.__cause__).__name__ if error.__cause__ is not None else None, - 'exception_logs': [str(call.message) for call in logger.data.exception], - 'privilege_listing': privilege_listing, - } - else: - result = { - 'exception_type': None, - 'message': None, - 'cause_type': None, - 'exception_logs': [str(call.message) for call in logger.data.exception], - 'privilege_listing': privilege_listing, - } - - result_path.write_text(dumps(result)) - """, - ) - child_script_path.write_text(child_script) + config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory)) + logger = MemoryLogger() - child_exit_code = run_python_without_windows_privileges(str(child_script_path), str(base_directory), str(result_path)) - result = loads(result_path.read_text()) + with impersonate_without_windows_privileges(), pytest.raises( + InvalidBaseDirectoryError, + match=match(f'Temporary base directory is not writable: {base_directory}'), + ) as raised: + TemporaryDirectoryThrong(logger=logger, config=config).get_isolate() - assert child_exit_code == 0 - assert not any('SeBackupPrivilege' in line and 'Enabled' in line for line in result['privilege_listing'].splitlines()) - assert not any('SeRestorePrivilege' in line and 'Enabled' in line for line in result['privilege_listing'].splitlines()) - assert result['exception_type'] == 'InvalidBaseDirectoryError' - assert result['message'] == f'Temporary base directory is not writable: {base_directory}' - assert result['cause_type'] == 'PermissionError' + assert isinstance(raised.value.__cause__, PermissionError) assert list(base_directory.iterdir()) == [] - assert result['exception_logs'] == [ + assert [str(call.message) for call in logger.data.exception] == [ f'Temporary base directory is not writable: {base_directory}', ] From d992c2cb1e2c601d4702501260cc9760fc2f6c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 12:41:08 +0300 Subject: [PATCH 51/59] Add Windows-specific environment for subprocess execution --- tests/plugins/test_directory_isolate.py | 33 +++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index d9260aa..1f63fd3 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -48,6 +48,7 @@ WINDOWS_FILE_SHARE_READ = 0x00000001 WINDOWS_FILE_SHARE_WRITE = 0x00000002 WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process' +PYTHON_SUBPROCESS_REQUIRED_ENV = {'SystemRoot': environ['SystemRoot']} if os_name == 'nt' else {} class TemporaryIsolateFactory(Protocol): @@ -403,7 +404,13 @@ def fake_run(*_args, **kwargs): def test_run_env_override_visible_to_subprocess(tmp_path, monkeypatch): - """Verify that an explicit env mapping replaces the child process environment and is visible to the command.""" + """ + Verify that an explicit env mapping replaces inherited application variables. + + The executed command is the real Python interpreter. On Windows it must + receive ``SystemRoot`` to start reliably, so that operating-system + prerequisite is included without preserving the parent-only test sentinel. + """ monkeypatch.chdir(tmp_path) monkeypatch.setenv('THRONG_PARENT_ONLY_ENV', 'parent') @@ -412,7 +419,7 @@ def test_run_env_override_visible_to_subprocess(tmp_path, monkeypatch): '-c', 'import os; print(os.environ.get("THRONG_TEST_ENV", "")); print(os.environ.get("THRONG_PARENT_ONLY_ENV", ""))', catch_output=True, - env={'THRONG_TEST_ENV': 'override'}, + env={**PYTHON_SUBPROCESS_REQUIRED_ENV, 'THRONG_TEST_ENV': 'override'}, split=False, ) @@ -457,7 +464,13 @@ def test_run_delete_env_hidden_from_subprocess(tmp_path, monkeypatch): def test_run_combines_env_add_env_and_delete_env(tmp_path, monkeypatch): - """Verify that env, add_env, and delete_env combine into the child process environment.""" + """ + Verify that env, add_env, and delete_env combine in the child environment. + + Windows receives only the additional ``SystemRoot`` value required to + execute the real Python command; it is unrelated to the variables whose + combination is asserted here. + """ monkeypatch.chdir(tmp_path) monkeypatch.setenv('THRONG_ENV_REMOVED', 'remove-me') @@ -471,7 +484,7 @@ def test_run_combines_env_add_env_and_delete_env(tmp_path, monkeypatch): 'print(os.environ.get("THRONG_ENV_REMOVED", ""))' ), catch_output=True, - env={'THRONG_ENV_BASE': 'base'}, + env={**PYTHON_SUBPROCESS_REQUIRED_ENV, 'THRONG_ENV_BASE': 'base'}, add_env={'THRONG_ENV_ADDED': 'added'}, delete_env=['THRONG_ENV_REMOVED'], split=False, @@ -961,8 +974,14 @@ def fake_run(*_args, **kwargs): assert observed_add_env['PATH'] == f'{venv_python.parent}{pathsep}env-bin' -def test_run_venv_path_respects_explicit_empty_environment(tmp_path): - """Verify that venv activation does not restore the parent PATH when env explicitly replaces it with nothing.""" +def test_run_venv_path_respects_explicit_environment_without_path(tmp_path): + """ + Verify that venv activation does not restore parent PATH when env omits PATH. + + The explicit environment is empty for POSIX behavior purposes. On + Windows it additionally includes ``SystemRoot``, which is required only + to start the real Python subprocess and does not supply a PATH value. + """ config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path), use_venv=True) isolate = TemporaryDirectoryThrong(config=config).get_isolate() venv_python = isolate.directory / '.venv' / VENV_PYTHON_RELATIVE_PATH @@ -975,7 +994,7 @@ def test_run_venv_path_respects_explicit_empty_environment(tmp_path): '-c', 'import os; print(os.environ.get("PATH", ""))', catch_output=True, - env={}, + env=PYTHON_SUBPROCESS_REQUIRED_ENV, split=False, ) From 4ebb0820fa45c78d794e0cab567c7e49943a57c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 13:07:04 +0300 Subject: [PATCH 52/59] The new logo --- README.md | 2 +- docs/assets/logo_1.svg | 496 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 docs/assets/logo_1.svg diff --git a/README.md b/README.md index ad633e7..d45d220 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# throng +![logo](https://raw.githubusercontent.com/mutating/throng/develop/docs/assets/logo_1.svg) Sometimes our programs need to execute console commands. In some cases, a command may be executed locally, while in others it may be executed in parallel across thousands of machines in the cloud. This library serves as an abstraction layer over various command execution environments, allowing you to write code once that will run anywhere. Specific execution environments are connected here as plugins (and you can even write your own!) with a unified API, and your code doesn’t need to know the internal workings of a specific plugin to run commands within it. diff --git a/docs/assets/logo_1.svg b/docs/assets/logo_1.svg new file mode 100644 index 0000000..9f0cac1 --- /dev/null +++ b/docs/assets/logo_1.svg @@ -0,0 +1,496 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From acd346a7f6a4669254beb4ec10b33ae76f741466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 14:19:51 +0300 Subject: [PATCH 53/59] Update suby version requirement to >=0.0.10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 882532f..aa6c09d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ description = 'Running commands in isolated environments' readme = "README.md" requires-python = ">=3.8" dependencies = [ - 'suby>=0.0.4', + 'suby>=0.0.10', 'cantok>=0.0.36', 'pristan>=0.0.15', 'skelet>=0.0.21', From 357ba270c6ce3ee015213bcd5ccd15b4dfaad3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 14:20:41 +0300 Subject: [PATCH 54/59] Shuffle the deps order --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aa6c09d..af7145a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,8 @@ dependencies = [ 'skelet>=0.0.21', 'emptylog>=0.0.12', 'dirstree>=0.0.6', - 'pathspec>=0.12.0', 'locklib>=0.0.21', + 'pathspec>=0.12.0', ] classifiers = [ "Operating System :: OS Independent", From 102d9da769e62510b15e1f8d3ca3a3e576b84c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 15:33:18 +0300 Subject: [PATCH 55/59] Enable isort combine-as-imports in Ruff config --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index af7145a..6c8e42e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,9 @@ lint.ignore = ['E501', 'E712', 'PTH123', 'PTH118', 'PLR2004', 'PTH107', 'SIM105' lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", "T20", "PT", "RSE", "RET", "SIM", "SLOT", "TID252", "ARG", "PTH", "I", "C90", "N", "E", "W", "D201", "D202", "D419", "F", "PL", "PLE", "PLR", "PLW", "RUF", "TRY201", "TRY400", "TRY401"] format.quote-style = "single" +[tool.ruff.lint.isort] +combine-as-imports = true + [project.urls] 'Source' = 'https://github.com/mutating/throng' 'Tracker' = 'https://github.com/mutating/throng/issues' From 37fd5d11f4564c2dc08950e9e98a11216994b8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 15:42:01 +0300 Subject: [PATCH 56/59] Refactor import syntax to use grouped imports across multiple files --- tests/helpers.py | 3 +- tests/plugins/test_directory_isolate.py | 45 ++++++++++++++----- tests/plugins/test_local_throng.py | 6 +-- .../test_temporary_directory_throng.py | 2 +- tests/test_result.py | 2 +- tests/test_slots.py | 3 +- throng/__init__.py | 16 ++++--- throng/plugins/directory_isolate.py | 13 +++--- 8 files changed, 54 insertions(+), 36 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 6fc288a..c84657e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,8 +2,7 @@ from io import BytesIO from pathlib import Path from sys import platform -from tarfile import TarInfo -from tarfile import open as open_tar +from tarfile import TarInfo, open as open_tar from typing import Dict, Iterable, Optional from dirstree import Crawler diff --git a/tests/plugins/test_directory_isolate.py b/tests/plugins/test_directory_isolate.py index 1f63fd3..415c857 100644 --- a/tests/plugins/test_directory_isolate.py +++ b/tests/plugins/test_directory_isolate.py @@ -1,15 +1,21 @@ import tempfile from errno import EACCES from io import BytesIO -from os import environ, link, pathsep -from os import name as os_name +from os import environ, link, name as os_name, pathsep from pathlib import Path from shutil import rmtree as remove_tree from stat import S_IMODE, S_IREAD, S_IRWXU, S_ISGID, S_ISUID, S_ISVTX, S_IWRITE, S_IXUSR from subprocess import run as run_process from sys import executable -from tarfile import CHRTYPE, DIRTYPE, LNKTYPE, PAX_FORMAT, SYMTYPE, TarInfo -from tarfile import open as open_tar +from tarfile import ( + CHRTYPE, + DIRTYPE, + LNKTYPE, + PAX_FORMAT, + SYMTYPE, + TarInfo, + open as open_tar, +) from time import monotonic from typing import Dict, List, Mapping, Optional, Protocol, Tuple, cast @@ -17,8 +23,12 @@ from cantok import CounterToken, SimpleToken, TimeoutToken from emptylog import EmptyLogger, MemoryLogger from full_match import match -from suby import RunningCommandError, WrongCommandError, WrongDirectoryError -from suby.subprocess_result import SubprocessResult +from suby import ( + RunningCommandError, + SubprocessResult, + WrongCommandError, + WrongDirectoryError, +) from tests.helpers import ( assert_any_message_contains, @@ -34,10 +44,12 @@ OperationCancelledError, throngs, ) -from throng.plugins.directory_isolate import DirectoryIsolate -from throng.plugins.directory_isolate import mkdtemp as directory_mkdtemp -from throng.plugins.directory_isolate import move as directory_move -from throng.plugins.directory_isolate import run_suby as directory_run_suby +from throng.plugins.directory_isolate import ( + DirectoryIsolate, + mkdtemp as directory_mkdtemp, + move as directory_move, + run_suby as directory_run_suby, +) from throng.plugins.temporary_directory_throng import ( TemporaryDirectoryIsolationConfig, TemporaryDirectoryThrong, @@ -310,7 +322,7 @@ def tracking_mkdtemp(*args, **kwargs): def test_load_validation_failure_removes_staging_and_backup_directories(tmp_path, monkeypatch): - """Verify that validation failure leaves no staging or rollback directory behind.""" + """Verify that rejection of an absolute archive path leaves no staging or rollback directory behind.""" config = TemporaryDirectoryIsolationConfig(compression='none', base_directory=str(tmp_path)) isolate = TemporaryDirectoryThrong(config=config).get_isolate() created_temp_dirs: List[Path] = [] @@ -1498,6 +1510,10 @@ def test_dump_omits_symbolic_links_from_serialized_contents(temporary_isolate): (source.directory / 'symbolic.txt').symlink_to(regular_file) dumped = source.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert archive.getnames() == ['regular.txt'] + target.load(dumped) assert read_tree(target.directory) == {'regular.txt': b'content'} @@ -1535,7 +1551,12 @@ def test_dump_omits_named_pipes_from_serialized_contents(temporary_isolate): kept_file.write_text('content') run_process(['mkfifo', str(omitted_pipe)], check=True) - target.load(source.dump()) + dumped = source.dump() + + with open_tar(fileobj=BytesIO(dumped), mode='r:') as archive: + assert archive.getnames() == ['regular.txt'] + + target.load(dumped) assert read_tree(target.directory) == {'regular.txt': b'content'} assert not (target.directory / 'ignored.pipe').exists() diff --git a/tests/plugins/test_local_throng.py b/tests/plugins/test_local_throng.py index 712556c..d60b197 100644 --- a/tests/plugins/test_local_throng.py +++ b/tests/plugins/test_local_throng.py @@ -6,7 +6,7 @@ from cantok import SimpleToken from full_match import match from locklib import LockTraceWrapper -from suby.subprocess_result import SubprocessResult +from suby import SubprocessResult from tests.helpers import make_tar_bytes from throng import ( @@ -118,8 +118,8 @@ def test_local_lock_released_after_command_error(tmp_path, monkeypatch): assert result.stdout == 'after-error' -def test_local_lock_released_after_cancellation(tmp_path, monkeypatch): - """Verify that the local lock is released after OperationCancelledError.""" +def test_local_lock_released_after_precancelled_operation(tmp_path, monkeypatch): + """Verify that a pre-cancelled local run releases the lock for the next command.""" monkeypatch.chdir(tmp_path) isolate = throngs()['local'].get_isolate() diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 0fe31a8..c592387 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -15,7 +15,7 @@ from emptylog import MemoryLogger from full_match import match from locklib import LockTraceWrapper -from suby.subprocess_result import SubprocessResult +from suby import SubprocessResult from tests.helpers import hold_windows_path_open, make_tar_bytes from throng import ( diff --git a/tests/test_result.py b/tests/test_result.py index 8faec74..40206eb 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -1,6 +1,6 @@ from sys import executable -from suby.subprocess_result import SubprocessResult +from suby import SubprocessResult from throng import RunResult, throngs diff --git a/tests/test_slots.py b/tests/test_slots.py index 0100f5f..65afb08 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -4,8 +4,7 @@ from emptylog import EmptyLogger, LoggerProtocol from full_match import match -from throng import AbstractIsolate, AbstractThrong, throngs -from throng import throngs as package_throngs +from throng import AbstractIsolate, AbstractThrong, throngs, throngs as package_throngs from throng.slots import throngs as slot_throngs diff --git a/throng/__init__.py b/throng/__init__.py index c6d13b1..1a4de8c 100644 --- a/throng/__init__.py +++ b/throng/__init__.py @@ -1,11 +1,13 @@ from throng.abstracts.isolate import AbstractIsolate as AbstractIsolate from throng.abstracts.throng import AbstractThrong as AbstractThrong -from throng.errors import ArchiveUnpackError as ArchiveUnpackError -from throng.errors import CommandExecutionError as CommandExecutionError -from throng.errors import InstallError as InstallError -from throng.errors import InvalidBaseDirectoryError as InvalidBaseDirectoryError -from throng.errors import InvalidVirtualEnvPathError as InvalidVirtualEnvPathError -from throng.errors import IsolateDeletedError as IsolateDeletedError -from throng.errors import OperationCancelledError as OperationCancelledError +from throng.errors import ( + ArchiveUnpackError as ArchiveUnpackError, + CommandExecutionError as CommandExecutionError, + InstallError as InstallError, + InvalidBaseDirectoryError as InvalidBaseDirectoryError, + InvalidVirtualEnvPathError as InvalidVirtualEnvPathError, + IsolateDeletedError as IsolateDeletedError, + OperationCancelledError as OperationCancelledError, +) from throng.result import RunResult as RunResult from throng.slots import throngs as throngs diff --git a/throng/plugins/directory_isolate.py b/throng/plugins/directory_isolate.py index 44e757a..ee1ae25 100644 --- a/throng/plugins/directory_isolate.py +++ b/throng/plugins/directory_isolate.py @@ -2,14 +2,11 @@ # Work around skelet.Storage exposing Any in its public class type: https://github.com/mutating/skelet/issues/24 from functools import partial from io import BytesIO -from os import X_OK, access, environ, pathsep -from os import name as os_name +from os import X_OK, access, environ, name as os_name, pathsep from pathlib import Path, PurePosixPath -from shutil import Error as ShutilError -from shutil import copyfileobj, move, rmtree +from shutil import Error as ShutilError, copyfileobj, move, rmtree from sys import executable -from tarfile import TarError, TarInfo -from tarfile import open as open_tar +from tarfile import TarError, TarInfo, open as open_tar from tempfile import TemporaryDirectory, mkdtemp from threading import Lock as ThreadLock from typing import ( @@ -37,11 +34,11 @@ from suby import ( EnvironmentVariablesConflict, RunningCommandError, + SubprocessResult, WrongCommandError, WrongDirectoryError, + run as run_suby, ) -from suby import run as run_suby -from suby.subprocess_result import SubprocessResult from throng.abstracts.isolate import AbstractIsolate from throng.errors import ( From 7f35ee2f38e5f502a4b7b8b9521ae83128c959d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 15:44:35 +0300 Subject: [PATCH 57/59] Enable isort combine-as-imports in ruff config --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c8e42e..2107009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,11 +73,9 @@ norecursedirs = ["build", "mutants"] [tool.ruff] lint.ignore = ['E501', 'E712', 'PTH123', 'PTH118', 'PLR2004', 'PTH107', 'SIM105', 'SIM102', 'RET503', 'PLR0912', 'C901', 'E731', 'F821'] lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", "T20", "PT", "RSE", "RET", "SIM", "SLOT", "TID252", "ARG", "PTH", "I", "C90", "N", "E", "W", "D201", "D202", "D419", "F", "PL", "PLE", "PLR", "PLW", "RUF", "TRY201", "TRY400", "TRY401"] +lint.isort.combine-as-imports = true format.quote-style = "single" -[tool.ruff.lint.isort] -combine-as-imports = true - [project.urls] 'Source' = 'https://github.com/mutating/throng' 'Tracker' = 'https://github.com/mutating/throng/issues' From 50ce2541a0f7984e6fa24cf9ce439485726b39ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 15:56:12 +0300 Subject: [PATCH 58/59] Add tests for retryable cleanup failures on POSIX and Windows --- .../test_temporary_directory_throng.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index c592387..2dbb52a 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -609,6 +609,71 @@ def test_temp_delete_stdlib_temp_manager(tmp_path, monkeypatch): assert not isolate_directory.exists() +@pytest.mark.skipif(os_name == 'nt', reason='POSIX parent directory permission semantics do not apply on Windows') +def test_temp_delete_stdlib_temp_manager_failure_is_logged_and_retryable_on_posix(tmp_path, monkeypatch, request): + """ + Verify that a real POSIX failure in ``TemporaryDirectory.cleanup()`` is logged and retryable. + + The stdlib cleanup implementation may repair permissions on the temporary + directory it owns before retrying deletion. Placing it in a separate + read-only parent reliably blocks removal of that owned child without + asking the implementation under test to fake a cleanup failure. + """ + temporary_root = tmp_path / 'stdlib-temporary-root' + temporary_root.mkdir() + request.addfinalizer(lambda: temporary_root.chmod(S_IREAD | S_IWRITE | S_IEXEC)) + monkeypatch.setattr('tempfile.tempdir', str(temporary_root)) + logger = MemoryLogger() + isolate = TemporaryDirectoryThrong(logger=logger).get_isolate() + isolate_directory = isolate.directory + temporary_root.chmod(S_IREAD | S_IEXEC) + permission_error_message = str(PermissionError(EACCES, 'Permission denied', str(isolate_directory))) + + with pytest.raises(PermissionError, match=match(permission_error_message)): + isolate.delete() + + assert isolate_directory.exists() + assert [str(call.message) for call in logger.data.exception] == [f'Delete failed: {permission_error_message}.'] + assert all(str(call.message) != 'Delete completed successfully.' for call in logger.data.info) + + temporary_root.chmod(S_IREAD | S_IWRITE | S_IEXEC) + isolate.delete() + + assert not isolate_directory.exists() + assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1 + + +@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX') +def test_temp_delete_stdlib_temp_manager_locked_windows_directory_raises_and_can_be_retried(): + """ + Verify that a real Windows failure in ``TemporaryDirectory.cleanup()`` is logged and retryable. + + An open directory handle without delete sharing prevents stdlib cleanup + from removing its managed directory. Closing that handle makes the same + isolate deletable on a later attempt. + """ + logger = MemoryLogger() + isolate = TemporaryDirectoryThrong(logger=logger).get_isolate() + isolate_directory = isolate.directory + permission_error_message = str(PermissionError(EACCES, WINDOWS_SHARING_VIOLATION_REASON, str(isolate_directory), 32)) + + with hold_windows_path_open( + isolate_directory, + share_mode=WINDOWS_FILE_SHARE_READ | WINDOWS_FILE_SHARE_WRITE, + flags=WINDOWS_DIRECTORY_HANDLE_FLAGS, + ), pytest.raises(PermissionError, match=match(permission_error_message)): + isolate.delete() + + assert isolate_directory.exists() + assert [str(call.message) for call in logger.data.exception] == [f'Delete failed: {permission_error_message}.'] + assert all(str(call.message) != 'Delete completed successfully.' for call in logger.data.info) + + isolate.delete() + + assert not isolate_directory.exists() + assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1 + + def test_temp_delete_does_not_wait_for_running_operation(tmp_path, monkeypatch): """Verify that temporary isolate delete is not serialized behind an in-flight run and does not hide the run result.""" started = Event() From ea06feee98e1902be90bd6447d876147e7ea4d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Wed, 27 May 2026 16:16:31 +0300 Subject: [PATCH 59/59] Add skipif for testing CPython's recursive retry behavior in TemporaryDirectory cleanup --- .../plugins/test_temporary_directory_throng.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/plugins/test_temporary_directory_throng.py b/tests/plugins/test_temporary_directory_throng.py index 2dbb52a..8ecea5f 100644 --- a/tests/plugins/test_temporary_directory_throng.py +++ b/tests/plugins/test_temporary_directory_throng.py @@ -2,6 +2,7 @@ from contextlib import ExitStack, contextmanager from errno import EACCES from gc import collect +from inspect import signature from os import name as os_name from pathlib import Path from stat import S_IEXEC, S_IREAD, S_IWRITE @@ -610,6 +611,10 @@ def test_temp_delete_stdlib_temp_manager(tmp_path, monkeypatch): @pytest.mark.skipif(os_name == 'nt', reason='POSIX parent directory permission semantics do not apply on Windows') +@pytest.mark.skipif( + 'repeated' not in signature(TemporaryDirectory._rmtree).parameters, # type: ignore[attr-defined] # private stdlib method is not present in typeshed. + reason='Affected CPython tempfile cleanup retries this natural permission denial recursively', +) def test_temp_delete_stdlib_temp_manager_failure_is_logged_and_retryable_on_posix(tmp_path, monkeypatch, request): """ Verify that a real POSIX failure in ``TemporaryDirectory.cleanup()`` is logged and retryable. @@ -617,7 +622,10 @@ def test_temp_delete_stdlib_temp_manager_failure_is_logged_and_retryable_on_posi The stdlib cleanup implementation may repair permissions on the temporary directory it owns before retrying deletion. Placing it in a separate read-only parent reliably blocks removal of that owned child without - asking the implementation under test to fake a cleanup failure. + asking the implementation under test to fake a cleanup failure. Older + CPython implementations recursively retry this exact denial until they + raise ``RecursionError``; the accompanying decorator keeps this natural + scenario on versions whose stdlib returns the filesystem error to throng. """ temporary_root = tmp_path / 'stdlib-temporary-root' temporary_root.mkdir() @@ -644,13 +652,19 @@ def test_temp_delete_stdlib_temp_manager_failure_is_logged_and_retryable_on_posi @pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX') +@pytest.mark.skipif( + 'repeated' not in signature(TemporaryDirectory._rmtree).parameters, # type: ignore[attr-defined] # private stdlib method is not present in typeshed. + reason='Affected CPython tempfile cleanup retries this natural permission denial recursively', +) def test_temp_delete_stdlib_temp_manager_locked_windows_directory_raises_and_can_be_retried(): """ Verify that a real Windows failure in ``TemporaryDirectory.cleanup()`` is logged and retryable. An open directory handle without delete sharing prevents stdlib cleanup from removing its managed directory. Closing that handle makes the same - isolate deletable on a later attempt. + isolate deletable on a later attempt. Older CPython implementations + cannot be used for this natural failure test because their stdlib cleanup + recursively retries the denial instead of returning it to throng. """ logger = MemoryLogger() isolate = TemporaryDirectoryThrong(logger=logger).get_isolate()