Update workflows and tests
This commit is contained in:
7 files changed
+386
-8
No files matched your search
@@ -77,3 +77,28 @@ jobs:
|
||||
and
|
||||
( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 )
|
||||
' pip-audit.json
|
||||
|
||||
tests:
|
||||
name: tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install Poetry
|
||||
run: pip install "poetry>=2,<3"
|
||||
|
||||
- name: Install project and dependencies
|
||||
run: poetry install --with dev
|
||||
|
||||
- name: Run tests
|
||||
run: poetry run pytest
|
||||
@@ -77,3 +77,28 @@ jobs:
|
||||
and
|
||||
( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 )
|
||||
' pip-audit.json
|
||||
|
||||
tests:
|
||||
name: tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install Poetry
|
||||
run: pip install "poetry>=2,<3"
|
||||
|
||||
- name: Install project and dependencies
|
||||
run: poetry install --with dev
|
||||
|
||||
- name: Run tests
|
||||
run: poetry run pytest
|
||||
+20
-7
@@ -58,7 +58,9 @@ src/resrm/
|
||||
core.py CLI, trash metadata, restore, delete, prune logic
|
||||
|
||||
tests/
|
||||
__init__.py test package marker; no substantive tests currently
|
||||
__init__.py test package marker
|
||||
conftest.py autouse fixture isolating trash state to a temp tree
|
||||
test_core.py focused unit tests for metadata, move, restore, prune
|
||||
|
||||
pyproject.toml Poetry package metadata and console script
|
||||
poetry.lock locked dependency graph
|
||||
@@ -493,13 +495,19 @@ Run pre-commit hooks:
|
||||
poetry run pre-commit run --all-files
|
||||
```
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
poetry run pytest
|
||||
```
|
||||
|
||||
Build release artifacts:
|
||||
|
||||
```bash
|
||||
poetry build
|
||||
```
|
||||
|
||||
There is a `tests/` package marker, but no substantive pytest suite is configured in `pyproject.toml` at the time of writing. If tests are added, add pytest as a development dependency and prefer focused tests using temporary directories and isolated metadata files.
|
||||
`pytest` is a development dependency and a focused suite exists in `tests/test_core.py`. `tests/conftest.py` provides an autouse fixture that redirects `TRASH_DIR`, `META_FILE`, `meta`, and `pwd.getpwuid` into a temporary tree, so tests never touch a real user's trash. Prefer adding focused tests using that fixture rather than real files.
|
||||
|
||||
---
|
||||
|
||||
@@ -509,11 +517,16 @@ Gitea pull request workflow:
|
||||
|
||||
```text
|
||||
.gitea/workflows/lint-and-security.yml
|
||||
-> install pre-commit
|
||||
-> pre-commit run --all-files
|
||||
-> install Poetry and poetry-plugin-export
|
||||
-> poetry export dependencies
|
||||
-> pip-audit dependency audit
|
||||
-> job lint-and-security:
|
||||
-> install pre-commit
|
||||
-> pre-commit run --all-files
|
||||
-> install Poetry and poetry-plugin-export
|
||||
-> poetry export dependencies
|
||||
-> pip-audit dependency audit
|
||||
-> job tests:
|
||||
-> install Poetry
|
||||
-> poetry install --with dev
|
||||
-> poetry run pytest
|
||||
```
|
||||
|
||||
Scheduled/manual security workflow:
|
||||
|
||||
Generated
+79
-1
@@ -27,6 +27,19 @@ files = [
|
||||
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
@@ -66,6 +79,18 @@ files = [
|
||||
[package.extras]
|
||||
license = ["ukkonen"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7"},
|
||||
{file = "iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
@@ -107,6 +132,22 @@ docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"]
|
||||
type = ["mypy (>=1.18.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "3.8.0"
|
||||
@@ -126,6 +167,43 @@ nodeenv = ">=0.11.1"
|
||||
pyyaml = ">=5.1"
|
||||
virtualenv = ">=20.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
|
||||
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
|
||||
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||
iniconfig = ">=1.0.1"
|
||||
packaging = ">=22"
|
||||
pluggy = ">=1.5,<2"
|
||||
pygments = ">=2.7.2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.6.1"
|
||||
@@ -246,4 +324,4 @@ python-discovery = ">=1.6"
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<4.0"
|
||||
content-hash = "0f02d476d4b4963c6f63ff5cce3eafd047bc5fb46d55ca79d3bac67513789da0"
|
||||
content-hash = "8bfda3e69f32fb6cd94545e455095b170aa6942c7cd7992cd831c0b70a117e61"
|
||||
@@ -22,6 +22,7 @@ packages = [{include = "resrm", from = "src"}]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pre-commit = "^3.8"
|
||||
pytest = "^9.1.1"
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from resrm import core
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_trash(tmp_path, monkeypatch):
|
||||
"""Redirect all trash state into a temporary tree.
|
||||
|
||||
resrm resolves the trash base from the file owner's home directory and
|
||||
loads metadata once at import time, so both are redirected here.
|
||||
Without this fixture a test that moved a file would write into the
|
||||
real ``~/.local/share/resrm``.
|
||||
"""
|
||||
home = tmp_path / "home"
|
||||
base = home / ".local" / "share" / "resrm"
|
||||
trash_dir = base / "files"
|
||||
meta_file = base / "metadata.json"
|
||||
trash_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(core, "TRASH_DIR", trash_dir)
|
||||
monkeypatch.setattr(core, "META_FILE", meta_file)
|
||||
monkeypatch.setattr(core, "meta", [])
|
||||
|
||||
# move_to_trash() and get_trash_base_for_user() import pwd locally and
|
||||
# resolve the owner's home, so patch the module attribute rather than
|
||||
# core's namespace.
|
||||
monkeypatch.setattr(
|
||||
"pwd.getpwuid", lambda uid: SimpleNamespace(pw_dir=str(home))
|
||||
)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return SimpleNamespace(home=home, trash_dir=trash_dir, meta_file=meta_file)
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from resrm import core
|
||||
|
||||
|
||||
def _entry(id_, orig_path, timestamp="2026-01-01T00:00:00"):
|
||||
return {"id": id_, "orig_path": str(orig_path), "timestamp": timestamp}
|
||||
|
||||
|
||||
def _iso_days_ago(days):
|
||||
moment = datetime.datetime.now() - datetime.timedelta(days=days)
|
||||
return moment.isoformat()
|
||||
|
||||
|
||||
def test_trash_base_for_root_is_fixed_path():
|
||||
assert core.get_trash_base_for_user(0) == Path("/root/.local/share/resrm")
|
||||
|
||||
|
||||
def test_trash_base_uses_user_home(isolated_trash):
|
||||
base = core.get_trash_base_for_user(12345)
|
||||
assert base == isolated_trash.home / ".local" / "share" / "resrm"
|
||||
|
||||
|
||||
def test_load_meta_missing_file_returns_empty(isolated_trash):
|
||||
assert not isolated_trash.meta_file.exists()
|
||||
assert core.load_meta() == []
|
||||
|
||||
|
||||
def test_load_meta_malformed_file_returns_empty(isolated_trash):
|
||||
isolated_trash.meta_file.write_text("{not json", encoding="utf-8")
|
||||
assert core.load_meta() == []
|
||||
|
||||
|
||||
def test_find_candidates_prefers_exact_basename(isolated_trash):
|
||||
by_name = _entry("deadbeef" + "0" * 24, "/x/report.txt")
|
||||
by_prefix = _entry("report.txt-extra", "/x/notes.md")
|
||||
core.meta = [by_prefix, by_name]
|
||||
assert core.find_candidates("report.txt") == [by_name]
|
||||
|
||||
|
||||
def test_find_candidates_matches_id_prefix(isolated_trash):
|
||||
first = _entry("abcd1234" + "0" * 24, "/x/a")
|
||||
second = _entry("abcd9999" + "0" * 24, "/x/b")
|
||||
core.meta = [first, second]
|
||||
assert core.find_candidates("abcd12") == [first]
|
||||
assert core.find_candidates("abcd") == [first, second]
|
||||
assert core.find_candidates("zzz") == []
|
||||
|
||||
|
||||
def test_move_to_trash_moves_file_and_writes_metadata(
|
||||
isolated_trash, tmp_path
|
||||
):
|
||||
src = tmp_path / "keep.txt"
|
||||
src.write_text("data", encoding="utf-8")
|
||||
|
||||
core.move_to_trash(src, interactive=False, force=False, skip_trash=False)
|
||||
|
||||
assert not src.exists()
|
||||
stored = list(isolated_trash.trash_dir.iterdir())
|
||||
assert len(stored) == 1
|
||||
saved = json.loads(isolated_trash.meta_file.read_text(encoding="utf-8"))
|
||||
assert saved[0]["orig_path"] == str(src.resolve())
|
||||
assert saved[0]["id"] == stored[0].name
|
||||
|
||||
|
||||
def test_skip_trash_deletes_file_permanently(isolated_trash, tmp_path):
|
||||
src = tmp_path / "gone.txt"
|
||||
src.write_text("x", encoding="utf-8")
|
||||
|
||||
core.move_to_trash(src, interactive=False, force=False, skip_trash=True)
|
||||
|
||||
assert not src.exists()
|
||||
assert list(isolated_trash.trash_dir.iterdir()) == []
|
||||
assert core.load_meta() == []
|
||||
|
||||
|
||||
def test_skip_trash_removes_symlink_not_target(isolated_trash, tmp_path):
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
core.move_to_trash(link, interactive=False, force=False, skip_trash=True)
|
||||
|
||||
assert not link.exists()
|
||||
assert target.exists()
|
||||
|
||||
|
||||
def test_missing_path_without_force_reports_error(
|
||||
isolated_trash, tmp_path, capsys
|
||||
):
|
||||
missing = tmp_path / "nope.txt"
|
||||
core.move_to_trash(
|
||||
missing, interactive=False, force=False, skip_trash=False
|
||||
)
|
||||
assert "No such file or directory" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_missing_path_with_force_is_silent(isolated_trash, tmp_path, capsys):
|
||||
missing = tmp_path / "nope.txt"
|
||||
core.move_to_trash(
|
||||
missing, interactive=False, force=True, skip_trash=False
|
||||
)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_directory_without_recursive_is_rejected(
|
||||
isolated_trash, tmp_path, capsys
|
||||
):
|
||||
directory = tmp_path / "adir"
|
||||
directory.mkdir()
|
||||
|
||||
core.main([str(directory)])
|
||||
|
||||
assert "Is a directory" in capsys.readouterr().out
|
||||
assert directory.is_dir()
|
||||
|
||||
|
||||
def test_restore_one_returns_to_original_path(isolated_trash, tmp_path):
|
||||
original = tmp_path / "restored.txt"
|
||||
stored = isolated_trash.trash_dir / ("a" * 32)
|
||||
stored.write_text("data", encoding="utf-8")
|
||||
entry = _entry("a" * 32, original)
|
||||
core.meta = [entry]
|
||||
|
||||
assert core.restore_one(entry) is True
|
||||
|
||||
assert original.read_text(encoding="utf-8") == "data"
|
||||
assert not stored.exists()
|
||||
assert core.meta == []
|
||||
assert core.load_meta() == []
|
||||
|
||||
|
||||
def test_restore_one_falls_back_to_cwd_when_original_exists(
|
||||
isolated_trash, tmp_path
|
||||
):
|
||||
(tmp_path / "sub").mkdir()
|
||||
original = tmp_path / "sub" / "taken.txt"
|
||||
original.write_text("original", encoding="utf-8")
|
||||
stored = isolated_trash.trash_dir / ("b" * 32)
|
||||
stored.write_text("trashed", encoding="utf-8")
|
||||
entry = _entry("b" * 32, original)
|
||||
core.meta = [entry]
|
||||
|
||||
assert core.restore_one(entry) is True
|
||||
|
||||
fallback = tmp_path / "taken.txt"
|
||||
assert fallback.read_text(encoding="utf-8") == "trashed"
|
||||
assert original.read_text(encoding="utf-8") == "original"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"life, days_old, expect_pruned",
|
||||
[
|
||||
("7", 10, True),
|
||||
("7", 1, False),
|
||||
("not-a-number", 10, True),
|
||||
("0", 2, True),
|
||||
],
|
||||
)
|
||||
def test_prune_old_trash_retention(
|
||||
isolated_trash, monkeypatch, life, days_old, expect_pruned
|
||||
):
|
||||
monkeypatch.setenv("RESRM_TRASH_LIFE", life)
|
||||
old = isolated_trash.trash_dir / ("c" * 32)
|
||||
old.write_text("old", encoding="utf-8")
|
||||
entry = _entry("c" * 32, "/x/old.txt", timestamp=_iso_days_ago(days_old))
|
||||
core.meta = [entry]
|
||||
|
||||
core.prune_old_trash()
|
||||
|
||||
if expect_pruned:
|
||||
assert not old.exists()
|
||||
assert core.meta == []
|
||||
else:
|
||||
assert old.exists()
|
||||
assert core.meta == [entry]
|
||||
|
||||
|
||||
def test_empty_trash_clears_files_and_metadata(isolated_trash):
|
||||
file_entry = isolated_trash.trash_dir / ("d" * 32)
|
||||
file_entry.write_text("1", encoding="utf-8")
|
||||
dir_entry = isolated_trash.trash_dir / ("e" * 32)
|
||||
dir_entry.mkdir()
|
||||
(dir_entry / "inner").write_text("2", encoding="utf-8")
|
||||
core.meta = [_entry("d" * 32, "/x/1"), _entry("e" * 32, "/x/2")]
|
||||
|
||||
core.empty_trash()
|
||||
|
||||
assert list(isolated_trash.trash_dir.iterdir()) == []
|
||||
assert core.meta == []
|
||||
saved = json.loads(isolated_trash.meta_file.read_text(encoding="utf-8"))
|
||||
assert saved == []
|
||||
Reference in new issue
Block a user