Compare commits

11 Commits

Author SHA1 Message Date
2013e6b645 Add function to fetch package version from pyproject.toml" 2025-11-15 18:16:50 +00:00
Marco D'Aleo
6a73270f23 Merge pull request #14 from mdaleo404/remove_dev_dependencies
Remove bandit and black from pyproject.toml
2025-11-15 16:57:17 +00:00
4f1e5043fd Remove bandit and black from pyproject.toml 2025-11-15 16:56:33 +00:00
877a490b57 Adjust bandit's severity and confidence levels 2025-11-15 08:27:04 +00:00
Marco D'Aleo
bcad54d94e Merge pull request #13 from mdaleo404/update_resrm_20251115
Add pre-commit framework and hooks config
- bandit
- black
- trailing-whitespace
- end-of-file-fixer
2025-11-15 08:06:31 +00:00
80fb24b7c5 Add pre-commit section to README 2025-11-15 08:04:30 +00:00
5f8d6c03d8 Add pre-commit framework and hooks config 2025-11-15 07:53:04 +00:00
Marco D'Aleo
be9867a007 Merge pull request #11 from mdaleo404/resrm_trash_life
Add automatic prune of trash older than $RESRM_TRASH_LIFE
2025-11-12 17:37:52 +00:00
e76c8726a2 Add automatic prune of trash older than RESRM_TRASH_LIFE value, update README, package version bump 2025-11-12 17:36:51 +00:00
657f14d95d Update README 2025-11-11 19:02:30 +00:00
Marco D'Aleo
b6c6fcc14d Merge pull request #10 from mdaleo404/flags_improvement
Flags improvement
2025-11-11 18:54:05 +00:00
7 changed files with 355 additions and 29 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
__pycache__ __pycache__
.pytest_cache .pytest_cache
dist dist

19
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,19 @@
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
hooks:
- id: bandit
files: ^src/resrm/
args: ["-lll", "-iii", "-s", "B110,B112"]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3.13
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer

View File

@@ -1,17 +1,29 @@
# resrm # resrm
**resrm** is a safe, drop-in replacement for the Linux `rm` command with **undo/restore support**. **resrm** is a safe, drop-in replacement for the Linux `rm` command with **undo/restore support**.
It moves files to a per-user _trash_ instead of permanently deleting them, while still allowing full `sudo` support for root-owned files. It moves files to a per-user _trash_ instead of permanently deleting them, while still allowing full `sudo` support for root-owned files.
--- ---
## Features ## Features
- Move files and directories to a **trash folder** instead of permanent deletion - Move files and directories to a **Trash folder** instead of permanent deletion
- Restore deleted files by **short ID or exact basename** - Restore deleted files by **short ID or exact basename**
- Empty trash safely - Empty trash safely
- Supports `-r`, `-f`, `-i`, `--perma` options - Supports `-r`, `-f`, `-i`, `--skip-trash` options
- Works with `sudo` for root-owned files - Works with `sudo` for root-owned files
- Automatically prunes Trash entries older than `$RESRM_TRASH_LIFE` days (default **7**, minimum **1**)
> Note: if you need immediate deletion, use the regular `rm` command instead.
---
## Configuration
To control how long trashed files are kept, add this line to your shell configuration (e.g. `~/.bashrc`):
```bash
export RESRM_TRASH_LIFE=10
```
--- ---
@@ -57,7 +69,7 @@ resrm -f file
resrm -i file resrm -i file
# Permanent delete (bypass trash) # Permanent delete (bypass trash)
resrm --perma file resrm --skip-trash file
# List trash entries # List trash entries
resrm -l resrm -l
@@ -74,3 +86,13 @@ resrm --empty
Normal users: `~/.local/share/resrm/files` Normal users: `~/.local/share/resrm/files`
Root user: `/root/.local/share/resrm/files` Root user: `/root/.local/share/resrm/files`
## pre-commit
This project uses [**pre-commit**](https://pre-commit.com/) to run automatic formatting and security checks before each commit (Black, Bandit, and various safety checks).
To enable it:
```
poetry install
poetry run pre-commit install
```
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.

197
poetry.lock generated
View File

@@ -1,7 +1,200 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
package = []
[[package]]
name = "cfgv"
version = "3.4.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.8"
files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "distlib"
version = "0.4.0"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
[[package]]
name = "filelock"
version = "3.20.0"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
files = [
{file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"},
{file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"},
]
[[package]]
name = "identify"
version = "2.6.15"
description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
files = [
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
]
[package.extras]
license = ["ukkonen"]
[[package]]
name = "nodeenv"
version = "1.9.1"
description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
name = "platformdirs"
version = "4.5.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
files = [
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
]
[package.extras]
docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"]
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 = "pre-commit"
version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.9"
files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "virtualenv"
version = "20.35.4"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
files = [
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<5"
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.13" python-versions = "^3.13"
content-hash = "f01b553f3895e558c34b4f10542e05acdef39bf0527c8090bd136d914dc73f94" content-hash = "0863809e99ebce15ac434c3d43856decb487945ff712f83073c120ee4dc7b850"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "resrm" name = "resrm"
version = "0.2.1" version = "0.3.1"
description = "drop-in replacement for rm with undo/restore built-in." description = "drop-in replacement for rm with undo/restore built-in."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"] authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -12,9 +12,16 @@ packages = [{include = "resrm", from = "src"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.13" python = "^3.13"
[tool.poetry.dev-dependencies]
pre-commit = "^3.8"
[tool.poetry.scripts] [tool.poetry.scripts]
resrm = "resrm.cli:main" resrm = "resrm.cli:main"
[tool.black]
line-length = 79
target-version = ["py313"]
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View File

@@ -1 +1 @@
from resrm.core import main from resrm.core import main

View File

@@ -22,10 +22,19 @@ import sys
import uuid import uuid
import datetime import datetime
import textwrap import textwrap
import importlib.metadata
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
# Config # Config
def get_version():
try:
return importlib.metadata.version("resrm")
except importlib.metadata.PackageNotFoundError:
return "unknown"
def get_trash_base_for_user(uid: int) -> Path: def get_trash_base_for_user(uid: int) -> Path:
"""Return the trash base path depending on whether user is root or normal.""" """Return the trash base path depending on whether user is root or normal."""
if uid == 0: if uid == 0:
@@ -33,6 +42,7 @@ def get_trash_base_for_user(uid: int) -> Path:
else: else:
try: try:
import pwd import pwd
user_info = pwd.getpwuid(uid) user_info = pwd.getpwuid(uid)
home_dir = Path(user_info.pw_dir) home_dir = Path(user_info.pw_dir)
except Exception: except Exception:
@@ -53,6 +63,46 @@ def get_trash_paths() -> tuple[Path, Path]:
TRASH_DIR, META_FILE = get_trash_paths() TRASH_DIR, META_FILE = get_trash_paths()
DATEFMT = "%Y-%m-%d %H:%M" DATEFMT = "%Y-%m-%d %H:%M"
def prune_old_trash():
"""Remove trash entries older than RESRM_TRASH_LIFE days (default 7)."""
try:
life_days = int(os.environ.get("RESRM_TRASH_LIFE", "7"))
except ValueError:
life_days = 7
if life_days < 1:
life_days = 1
cutoff = datetime.datetime.now() - datetime.timedelta(days=life_days)
removed = 0
for entry in list(meta): # make copy since we'll modify meta
try:
ts = datetime.datetime.fromisoformat(entry["timestamp"])
except Exception:
continue # skip malformed entries
if ts < cutoff:
f = TRASH_DIR / entry["id"]
try:
if f.exists():
if f.is_dir():
shutil.rmtree(f, ignore_errors=True)
else:
f.unlink(missing_ok=True)
meta.remove(entry)
removed += 1
except Exception as e:
print(f"Failed to prune {f}: {e}")
if removed > 0:
save_meta(meta)
print(
f"Pruned {removed} trash entr{'y' if removed == 1 else 'ies'} older than {life_days} da{'y' if life_days == 1 else 'ys'}."
)
def load_meta() -> List[Dict]: def load_meta() -> List[Dict]:
if META_FILE.exists(): if META_FILE.exists():
try: try:
@@ -62,15 +112,19 @@ def load_meta() -> List[Dict]:
return [] return []
return [] return []
def save_meta(meta: List[Dict]): def save_meta(meta: List[Dict]):
with META_FILE.open("w", encoding="utf-8") as f: with META_FILE.open("w", encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False) json.dump(meta, f, indent=2, ensure_ascii=False)
meta = load_meta() meta = load_meta()
def short_id(fullid: str) -> str: def short_id(fullid: str) -> str:
return fullid[:8] return fullid[:8]
def human_time(ts: str) -> str: def human_time(ts: str) -> str:
""" """
Convert ISO timestamp string from metadata to a human-readable format. Convert ISO timestamp string from metadata to a human-readable format.
@@ -82,13 +136,15 @@ def human_time(ts: str) -> str:
# Fallback: just return the raw string # Fallback: just return the raw string
return ts return ts
def entry_display(entry: Dict, width: int = 80) -> str: def entry_display(entry: Dict, width: int = 80) -> str:
id8 = short_id(entry["id"]) id8 = short_id(entry["id"])
ts = human_time(entry["timestamp"]) ts = human_time(entry["timestamp"])
path = entry["orig_path"] path = entry["orig_path"]
wrapped = textwrap.fill(path, width=width-32) wrapped = textwrap.fill(path, width=width - 32)
return f"{id8:<8} {ts:<19} {wrapped}" return f"{id8:<8} {ts:<19} {wrapped}"
def list_trash(): def list_trash():
if not meta: if not meta:
print("Trash empty.") print("Trash empty.")
@@ -96,16 +152,17 @@ def list_trash():
header = f"{'ID':<8} {'Deleted at':<19} {'Original path'}" header = f"{'ID':<8} {'Deleted at':<19} {'Original path'}"
print(header) print(header)
print('-' * len(header)) print("-" * len(header))
for entry in meta: for entry in meta:
id8 = short_id(entry["id"]) id8 = short_id(entry["id"])
ts = human_time(entry["timestamp"]) ts = human_time(entry["timestamp"])
path = entry["orig_path"] path = entry["orig_path"]
max_path_len = 80 max_path_len = 80
if len(path) > max_path_len: if len(path) > max_path_len:
path = "" + path[-(max_path_len - 1):] path = "" + path[-(max_path_len - 1) :]
print(f"{id8:<8} {ts:<19} {path}") print(f"{id8:<8} {ts:<19} {path}")
def find_candidates(identifier: str) -> List[Dict]: def find_candidates(identifier: str) -> List[Dict]:
# exact basename match first # exact basename match first
exact = [m for m in meta if Path(m["orig_path"]).name == identifier] exact = [m for m in meta if Path(m["orig_path"]).name == identifier]
@@ -118,6 +175,7 @@ def find_candidates(identifier: str) -> List[Dict]:
return [] return []
def restore_many(identifiers: List[str]): def restore_many(identifiers: List[str]):
"""Restore multiple files, prompting when needed.""" """Restore multiple files, prompting when needed."""
for identifier in identifiers: for identifier in identifiers:
@@ -135,7 +193,9 @@ def restore_many(identifiers: List[str]):
# Multiple matches - prompt user # Multiple matches - prompt user
print(f"Multiple matches for '{identifier}':") print(f"Multiple matches for '{identifier}':")
for i, entry in enumerate(candidates, start=1): for i, entry in enumerate(candidates, start=1):
print(f"{i}) {short_id(entry['id'])} {entry['orig_path']} ({entry['timestamp']})") print(
f"{i}) {short_id(entry['id'])} {entry['orig_path']} ({entry['timestamp']})"
)
try: try:
choice = input("Choose number to restore (or skip): ").strip() choice = input("Choose number to restore (or skip): ").strip()
@@ -153,6 +213,7 @@ def restore_many(identifiers: List[str]):
else: else:
print("Invalid selection. Skipped.") print("Invalid selection. Skipped.")
def restore_one(entry: Dict) -> bool: def restore_one(entry: Dict) -> bool:
src = TRASH_DIR / entry["id"] src = TRASH_DIR / entry["id"]
dest = Path(entry["orig_path"]) dest = Path(entry["orig_path"])
@@ -174,6 +235,7 @@ def restore_one(entry: Dict) -> bool:
print(f"Restored to: {dest}") print(f"Restored to: {dest}")
return True return True
def restore(identifier: str): def restore(identifier: str):
candidates = find_candidates(identifier) candidates = find_candidates(identifier)
if not candidates: if not candidates:
@@ -185,7 +247,9 @@ def restore(identifier: str):
# multiple candidates -> show list and ask # multiple candidates -> show list and ask
print("Multiple matches:") print("Multiple matches:")
for i, e in enumerate(candidates, start=1): for i, e in enumerate(candidates, start=1):
print(f"{i}) {short_id(e['id'])} {e['orig_path']} ({e['timestamp']})") print(
f"{i}) {short_id(e['id'])} {e['orig_path']} ({e['timestamp']})"
)
try: try:
choice = input("Choose number to restore (or abort): ").strip() choice = input("Choose number to restore (or abort): ").strip()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -200,6 +264,7 @@ def restore(identifier: str):
return return
restore_one(candidates[idx]) restore_one(candidates[idx])
def empty_trash(): def empty_trash():
"""Permanently remove all trashed files and clear metadata.""" """Permanently remove all trashed files and clear metadata."""
count = 0 count = 0
@@ -218,7 +283,10 @@ def empty_trash():
save_meta(meta) save_meta(meta)
print(f"Trash emptied ({count} entries removed).") print(f"Trash emptied ({count} entries removed).")
def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
def move_to_trash(
path: Path, interactive: bool, force: bool, skip_trash: bool
):
if not path.exists(): if not path.exists():
if force: if force:
return return
@@ -250,7 +318,9 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
try: try:
st = path.stat() st = path.stat()
if st.st_uid == 0 and os.geteuid() != 0: if st.st_uid == 0 and os.geteuid() != 0:
print(f"resrm: permission denied: '{path}' (root-owned file, try sudo)") print(
f"resrm: permission denied: '{path}' (root-owned file, try sudo)"
)
return return
except Exception: except Exception:
pass pass
@@ -258,6 +328,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
# Detect which trash to use (based on file owner) # Detect which trash to use (based on file owner)
try: try:
import pwd import pwd
owner_uid = path.stat().st_uid owner_uid = path.stat().st_uid
owner_info = pwd.getpwuid(owner_uid) owner_info = pwd.getpwuid(owner_uid)
owner_home = Path(owner_info.pw_dir) owner_home = Path(owner_info.pw_dir)
@@ -293,7 +364,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
entry = { entry = {
"id": uid, "id": uid,
"orig_path": str(path.resolve()), "orig_path": str(path.resolve()),
"timestamp": datetime.datetime.now().isoformat() "timestamp": datetime.datetime.now().isoformat(),
} }
owner_meta.append(entry) owner_meta.append(entry)
with meta_file.open("w", encoding="utf-8") as f: with meta_file.open("w", encoding="utf-8") as f:
@@ -301,20 +372,33 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
print(f"Removed '{path}' -> trash id {short_id(uid)}") print(f"Removed '{path}' -> trash id {short_id(uid)}")
def main(argv: Optional[List[str]] = None): def main(argv: Optional[List[str]] = None):
if argv is None: if argv is None:
argv = sys.argv[1:] argv = sys.argv[1:]
prune_old_trash()
parser = argparse.ArgumentParser(add_help=False) parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("paths", nargs="*", help="files to remove") parser.add_argument("paths", nargs="*", help="files to remove")
parser.add_argument("-r", action="store_true", help="recursive") parser.add_argument("-r", action="store_true", help="recursive")
parser.add_argument("-f", "--force", action="store_true", help="force") parser.add_argument("-f", "--force", action="store_true", help="force")
parser.add_argument("-i", action="store_true", help="interactive") parser.add_argument("-i", action="store_true", help="interactive")
parser.add_argument("--skip-trash", action="store_true", help="permanent delete") parser.add_argument(
parser.add_argument("--restore", nargs="+", metavar="item", help="restore by id or basename") "--skip-trash", action="store_true", help="permanent delete"
)
parser.add_argument(
"--restore",
nargs="+",
metavar="item",
help="restore by id or basename",
)
parser.add_argument("-l", action="store_true", help="list trash") parser.add_argument("-l", action="store_true", help="list trash")
parser.add_argument("--empty", action="store_true", help="empty the trash permanently") parser.add_argument(
"--empty", action="store_true", help="empty the trash permanently"
)
parser.add_argument("-h", "--help", action="store_true", help="show help") parser.add_argument("-h", "--help", action="store_true", help="show help")
parser.add_argument("-V", "--version", action="store_true", help="show version") parser.add_argument(
"-V", "--version", action="version", version=f"resrm {get_version()}"
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Always print docstring if -h or --help # Always print docstring if -h or --help
@@ -322,10 +406,6 @@ def main(argv: Optional[List[str]] = None):
print(__doc__) print(__doc__)
return return
if args.version:
print("resrm 0.2.1")
return
if not args.paths and not (args.l or args.empty or args.restore): if not args.paths and not (args.l or args.empty or args.restore):
print("resrm: missing operand") print("resrm: missing operand")
print("Try 'resrm --help' for more information.") print("Try 'resrm --help' for more information.")
@@ -356,4 +436,9 @@ def main(argv: Optional[List[str]] = None):
continue continue
print(f"resrm: cannot remove '{pth}': Is a directory") print(f"resrm: cannot remove '{pth}': Is a directory")
continue continue
move_to_trash(pth, interactive=args.i, force=args.force, skip_trash=args.skip_trash) move_to_trash(
pth,
interactive=args.i,
force=args.force,
skip_trash=args.skip_trash,
)