10 Commits

Author SHA1 Message Date
8cf2a5f1ac Update tests for mirro's new flags 2025-11-16 15:00:44 +00:00
Marco D'Aleo
7cb2c3adb2 Merge pull request #6 from mdaleo404/update_mirro_20251116
Add more functionalities:
- Add new flags and their respective functionalities: --list, --restore-last, --prune-
- Change Python dependecies version
- Remove Black target-version from pyproject
- Mirro version bump to 0.3.0
- Update README
2025-11-16 14:46:29 +00:00
a0d6acfa8a Remove CI workflow trigger 'pull_request' 2025-11-16 14:44:08 +00:00
79473eb05a Update README with mirro's new functionalities 2025-11-16 14:39:40 +00:00
fde29fe90d Add new flags and their respective functionalities: --list, --restore-last, --prune-backups. Change Python dependecies version. Remove Black target-version from pyproject.toml. Mirro version bump. 2025-11-16 14:17:11 +00:00
a068c0a5bd Fix pyproject group for dev-dependencies, remove .bak from backed up file 2025-11-16 09:40:26 +00:00
27b9039ddd Add more checks on pre-commit-config, add CI workflow 2025-11-16 06:59:02 +00:00
Marco D'Aleo
e347b12d94 Merge pull request #5 from mdaleo404/remove_dev_dependencies
Remove bandit and black from pyproject.toml
2025-11-15 17:00:13 +00:00
e213edf7b2 Remove bandit and black from pyproject.toml 2025-11-15 16:59:42 +00:00
Marco D'Aleo
695a0d33e1 Merge pull request #4 from mdaleo404/update_mirro_20251115
Add pre-commit framework and hooks config, update README
- black
- bandit
- trailing-whitespace
- end-of-file-fixer
2025-11-15 08:24:52 +00:00
7 changed files with 567 additions and 232 deletions

29
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: CI
on:
push:
jobs:
precommit-and-security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1
- name: Install pip-audit
run: pip install pip-audit
- name: Run pip-audit
run: pip-audit

View File

@@ -17,3 +17,5 @@ repos:
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml

View File

@@ -76,9 +76,59 @@ so under `sudo`:
Backups are named like:
```
filename.ext.orig.20251110T174400.bak
filename.ext.orig.20251110T174400
```
## Functionalities
### List all backup files stored in your backup directory.
```
mirro --list
```
Output includes permissions, owner/group, timestamps, and backup filenames.
### Restore the most recent backup for a given file.
```
mirro --restore-last ~/.config/myapp/config.ini
```
This:
1. finds the newest backup matching the filename,
2. strips the mirro header from it,
3. and overwrites the target file with its original contents.
### Remove old backup files.
```
mirro --prune-backups
```
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
### Remove backups older than _N_ days
```
mirro --prune-backups=14
```
This keeps the last 14 days of backups and removes everything older.
### Remove all backups
```
mirro --prune-backups=all
```
This deletes every backup in the backup directory.
### Environment Variable
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
Its default value is **30** if not set otherwise.
```
export MIRRO_BACKUPS_LIFE=7
```
Backups older than 7 days will be removed.
Invalid or non-numeric values fall back to 30 days.
**Note:** _a value of 0 is **invalid**_.
## Installation
**NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\

253
poetry.lock generated
View File

@@ -1,78 +1,5 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "bandit"
version = "1.8.6"
description = "Security oriented static analyser for python code."
optional = false
python-versions = ">=3.9"
files = [
{file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"},
{file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"},
]
[package.dependencies]
colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""}
PyYAML = ">=5.3.1"
rich = "*"
stevedore = ">=1.20.0"
[package.extras]
baseline = ["GitPython (>=3.1.30)"]
sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"]
test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"]
toml = ["tomli (>=1.1.0)"]
yaml = ["PyYAML"]
[[package]]
name = "black"
version = "25.11.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.9"
files = [
{file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"},
{file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"},
{file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"},
{file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"},
{file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"},
{file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"},
{file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"},
{file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"},
{file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"},
{file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"},
{file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"},
{file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"},
{file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"},
{file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"},
{file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"},
{file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"},
{file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"},
{file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"},
{file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"},
{file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"},
{file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"},
{file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"},
{file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"},
{file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"},
{file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"},
{file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
pytokens = ">=0.3.0"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.10)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cfgv"
version = "3.4.0"
@@ -84,20 +11,6 @@ files = [
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "click"
version = "8.3.0"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
files = [
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
@@ -210,6 +123,9 @@ files = [
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
@@ -224,6 +140,23 @@ files = [
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
]
[package.dependencies]
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "filelock"
version = "3.20.0"
@@ -260,51 +193,6 @@ files = [
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
files = [
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins (>=0.5.0)"]
profiling = ["gprof2dot"]
rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -327,17 +215,6 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.8"
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.5.0"
@@ -414,10 +291,12 @@ files = [
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
@@ -441,20 +320,6 @@ pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytokens"
version = "0.3.0"
description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons."
optional = false
python-versions = ">=3.8"
files = [
{file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"},
{file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"},
]
[package.extras]
dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -538,32 +403,65 @@ files = [
]
[[package]]
name = "rich"
version = "14.2.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
name = "tomli"
version = "2.3.0"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8.0"
python-versions = ">=3.8"
files = [
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "stevedore"
version = "5.5.0"
description = "Manage dynamic plugins for Python applications"
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf"},
{file = "stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73"},
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
@@ -581,6 +479,7 @@ files = [
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
[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)"]
@@ -588,5 +487,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = "^3.13"
content-hash = "45d3e04477c3f678a63cc033f6119e3b85cc4056c3fffd45d31b3d7f7cf5803c"
python-versions = ">=3.10,<4.0"
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "mirro"
version = "0.2.0"
version = "0.3.0"
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later"
@@ -10,21 +10,18 @@ repository = "https://github.com/mdaleo404/mirro"
packages = [{include = "mirro", from = "src"}]
[tool.poetry.dependencies]
python = "^3.13"
python = ">=3.10,<4.0"
[tool.poetry.scripts]
mirro = "mirro.main:main"
[tool.poetry.group.dev.dependencies]
[tool.poetry.dev-dependencies]
pytest = "^9.0.1"
pytest-cov = "^7.0.0"
pre-commit = "^3.8"
bandit = "^1.7"
black = "^25.0"
[tool.black]
line-length = 79
target-version = ["py313"]
[build-system]
requires = ["poetry-core"]

View File

@@ -3,6 +3,7 @@ import argparse
import tempfile
import subprocess
import os
import textwrap
from pathlib import Path
import time
@@ -31,7 +32,7 @@ def backup_original(
timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
shortstamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
backup_name = f"{original_path.name}.orig.{shortstamp}.bak"
backup_name = f"{original_path.name}.orig.{shortstamp}"
backup_path = backup_dir / backup_name
header = (
@@ -66,9 +67,207 @@ def main():
version=f"mirro {get_version()}",
)
parser.add_argument(
"--list",
action="store_true",
help="List all backups in the backup directory and exit",
)
parser.add_argument(
"--restore-last",
metavar="FILE",
type=str,
help="Restore the last backup of the given file and exit",
)
parser.add_argument(
"--prune-backups",
nargs="?",
const="default",
help="Prune backups older than MIRRO_BACKUPS_LIFE days, or 'all' to delete all backups",
)
# Parse only options. Leave everything else untouched.
args, positional = parser.parse_known_args()
if args.list:
import pwd, grp
backup_dir = Path(args.backup_dir).expanduser().resolve()
if not backup_dir.exists():
print("No backups found.")
return
backups = sorted(
backup_dir.iterdir(), key=os.path.getmtime, reverse=True
)
if not backups:
print("No backups found.")
return
def perms(mode):
is_file = "-"
perms = ""
flags = [
(mode & 0o400, "r"),
(mode & 0o200, "w"),
(mode & 0o100, "x"),
(mode & 0o040, "r"),
(mode & 0o020, "w"),
(mode & 0o010, "x"),
(mode & 0o004, "r"),
(mode & 0o002, "w"),
(mode & 0o001, "x"),
]
for bit, char in flags:
perms += char if bit else "-"
return is_file + perms
for b in backups:
stat = b.stat()
mode = perms(stat.st_mode)
try:
owner = pwd.getpwuid(stat.st_uid).pw_name
except KeyError:
owner = str(stat.st_uid)
try:
group = grp.getgrgid(stat.st_gid).gr_name
except KeyError:
group = str(stat.st_gid)
owner_group = f"{owner} {group}"
mtime = time.strftime(
"%Y-%m-%d %H:%M:%S", time.gmtime(stat.st_mtime)
)
print(f"{mode:11} {owner_group:20} {mtime} {b.name}")
return
if args.restore_last:
backup_dir = Path(args.backup_dir).expanduser().resolve()
target = Path(args.restore_last).expanduser().resolve()
if not backup_dir.exists():
print("No backup directory found.")
return 1
# backup filenames look like: <name>.orig.<timestamp>
prefix = f"{target.name}.orig."
backups = [
b for b in backup_dir.iterdir() if b.name.startswith(prefix)
]
if not backups:
print(f"No backups found for {target}")
return 1
# newest backup
last = max(backups, key=os.path.getmtime)
# read and strip header
raw = last.read_text(encoding="utf-8", errors="replace")
restored = []
skipping = True
for line in raw.splitlines(keepends=True):
# header ends at first blank line after the dashed line block
if skipping:
if line.strip() == "" and restored == []:
# allow only after header
continue
if line.startswith("#") or line.strip() == "":
continue
skipping = False
restored.append(line)
# if header wasn't present, restored = raw
if not restored:
restored_text = raw
else:
restored_text = "".join(restored)
# write the restored file back
target.write_text(restored_text, encoding="utf-8")
print(f"Restored {target} from backup {last.name}")
return
if args.prune_backups is not None:
mode = args.prune_backups
# ALL mode
if mode == "all":
prune_days = None
# default
elif mode == "default":
raw_env = os.environ.get("MIRRO_BACKUPS_LIFE", "30")
try:
prune_days = int(raw_env)
if prune_days < 1:
raise ValueError
except ValueError:
print(
f"Invalid MIRRO_BACKUPS_LIFE value: {raw_env}. "
"It must be an integer >= 1. Falling back to 30."
)
prune_days = 30
# numeric mode e.g. --prune-backups=7
else:
try:
prune_days = int(mode)
if prune_days < 1:
raise ValueError
except ValueError:
msg = f"""
Invalid value for --prune-backups: {mode}
--prune-backups use MIRRO_BACKUPS_LIFE (default: 30 days)
--prune-backups=N expire backups older than N days (N >= 1)
--prune-backups=all remove ALL backups
"""
print(textwrap.dedent(msg))
return 1
backup_dir = Path(args.backup_dir).expanduser().resolve()
if not backup_dir.exists():
print("No backup directory found.")
return 0
# prune EVERYTHING
if prune_days is None:
removed = []
for b in backup_dir.iterdir():
if b.is_file():
removed.append(b)
b.unlink()
print(f"Removed ALL backups ({len(removed)} file(s)).")
return 0
# prune by age
cutoff = time.time() - (prune_days * 86400)
removed = []
for b in backup_dir.iterdir():
if b.is_file() and b.stat().st_mtime < cutoff:
removed.append(b)
b.unlink()
if removed:
print(
f"Removed {len(removed)} backup(s) older than {prune_days} days."
)
else:
print(f"No backups older than {prune_days} days.")
return 0
# Flexible positional parsing
if not positional:
parser.error("the following arguments are required: file")

View File

@@ -54,11 +54,10 @@ def test_write_file(tmp_path):
def test_backup_original(tmp_path, monkeypatch):
original_path = tmp_path / "test.txt"
original_path = tmp_path / "a.txt"
original_content = "ABC"
backup_dir = tmp_path / "backups"
# Freeze timestamps
monkeypatch.setattr(
time,
"gmtime",
@@ -78,14 +77,14 @@ def test_backup_original(tmp_path, monkeypatch):
)
assert backup_path.exists()
text = backup_path.read_text(encoding="utf-8")
text = backup_path.read_text()
assert "mirro backup" in text
assert "Original file:" in text
assert original_content in text
assert "Original file" in text
assert "ABC" in text
# ============================================================
# Helper to run main()
# Helper to simulate main()
# ============================================================
@@ -100,11 +99,8 @@ def simulate_main(
file_exists=True,
override_access=None,
):
"""Utility to simulate mirro.main()"""
monkeypatch.setenv("EDITOR", editor)
# Fake editor
def fake_call(cmd):
temp = Path(cmd[-1])
if edited_content is None:
@@ -115,13 +111,11 @@ def simulate_main(
monkeypatch.setattr(subprocess, "call", fake_call)
# Access override if provided
if override_access:
monkeypatch.setattr(os, "access", override_access)
else:
monkeypatch.setattr(os, "access", lambda p, m: True)
# Set up file as needed
target = Path(args[-1]).expanduser().resolve()
if file_exists:
target.parent.mkdir(parents=True, exist_ok=True)
@@ -135,7 +129,7 @@ def simulate_main(
# ============================================================
# main: missing file argument
# main: missing positional file
# ============================================================
@@ -143,24 +137,23 @@ def test_main_missing_argument(capsys):
with patch("sys.argv", ["mirro"]):
with pytest.raises(SystemExit):
mirro.main()
assert (
"the following arguments are required: file" in capsys.readouterr().err
)
# ============================================================
# main: unchanged file (line 137)
# main: unchanged file
# ============================================================
def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
target = tmp_path / "file.txt"
target.write_text("hello\n", encoding="utf-8")
target.write_text("hello\n")
def fake_call(cmd):
temp = Path(cmd[-1])
temp.write_text("hello\n", encoding="utf-8")
temp.write_text("hello\n")
monkeypatch.setenv("EDITOR", "nano")
monkeypatch.setattr(subprocess, "call", fake_call)
@@ -169,8 +162,7 @@ def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
with patch("sys.argv", ["mirro", str(target)]):
mirro.main()
out = capsys.readouterr().out
assert "file hasn't changed" in out
assert "file hasn't changed" in capsys.readouterr().out
# ============================================================
@@ -179,7 +171,7 @@ def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
def test_main_existing_changed(tmp_path, monkeypatch, capsys):
target = tmp_path / "file2.txt"
target = tmp_path / "f2.txt"
result, out = simulate_main(
monkeypatch,
@@ -191,7 +183,7 @@ def test_main_existing_changed(tmp_path, monkeypatch, capsys):
)
assert "file changed; original backed up at" in out
assert target.read_text(encoding="utf-8") == "new\n"
assert target.read_text() == "new\n"
# ============================================================
@@ -233,68 +225,57 @@ def test_main_new_file_changed(tmp_path, monkeypatch, capsys):
)
assert "file changed; original backed up at" in out
assert new.read_text(encoding="utf-8") == "XYZ\n"
assert new.read_text() == "XYZ\n"
# ============================================================
# main: permission denied for existing file (line 78)
# Permission denied branches
# ============================================================
def test_main_permission_denied_existing(tmp_path, monkeypatch, capsys):
target = tmp_path / "blocked.txt"
target.write_text("hello", encoding="utf-8")
tgt = tmp_path / "blocked.txt"
tgt.write_text("hi")
monkeypatch.setenv("EDITOR", "nano")
monkeypatch.setattr(os, "access", lambda p, m: False)
with patch("sys.argv", ["mirro", str(target)]):
with patch("sys.argv", ["mirro", str(tgt)]):
result = mirro.main()
out = capsys.readouterr().out
assert "Need elevated privileges to open" in out
assert result == 1
# ============================================================
# main: permission denied creating file (line 84)
# ============================================================
assert "Need elevated privileges to open" in capsys.readouterr().out
def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
newfile = tmp_path / "subdir" / "nofile.txt"
parent = newfile.parent
parent.mkdir(parents=True, exist_ok=True)
new = tmp_path / "sub/xx.txt"
new.parent.mkdir(parents=True)
# Directory is not writable
def fake_access(path, mode):
if path == parent:
return False
return True
return False if path == new.parent else True
monkeypatch.setattr(os, "access", fake_access)
monkeypatch.setenv("EDITOR", "nano")
with patch("sys.argv", ["mirro", str(newfile)]):
with patch("sys.argv", ["mirro", str(new)]):
result = mirro.main()
out = capsys.readouterr().out
assert "Need elevated privileges to create" in out
assert result == 1
assert "Need elevated privileges to create" in capsys.readouterr().out
# ============================================================
# main: non-nano editor (ordering branch)
# Editor ordering: non-nano branch
# ============================================================
def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
target = tmp_path / "vim.txt"
target.write_text("old\n", encoding="utf-8")
target.write_text("old\n")
def fake_call(cmd):
temp = Path(cmd[1]) # in non-nano mode
temp.write_text("edited\n", encoding="utf-8")
temp = Path(cmd[1])
temp.write_text("edited\n")
monkeypatch.setenv("EDITOR", "vim")
monkeypatch.setattr(subprocess, "call", fake_call)
@@ -303,4 +284,182 @@ def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
with patch("sys.argv", ["mirro", str(target)]):
mirro.main()
assert target.read_text(encoding="utf-8") == "edited\n"
assert target.read_text() == "edited\n"
# ============================================================
# --list
# ============================================================
def test_main_list_no_dir(tmp_path, capsys):
with patch(
"sys.argv", ["mirro", "--list", "--backup-dir", str(tmp_path / "none")]
):
mirro.main()
assert "No backups found." in capsys.readouterr().out
def test_main_list_entries(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
(d / "a.txt.orig.1").write_text("x")
(d / "b.txt.orig.2").write_text("y")
with patch("sys.argv", ["mirro", "--list", "--backup-dir", str(d)]):
mirro.main()
out = capsys.readouterr().out
assert "a.txt.orig.1" in out
assert "b.txt.orig.2" in out
# ============================================================
# --restore-last
# ============================================================
def test_restore_last_no_dir(tmp_path, capsys):
d = tmp_path / "none"
target = tmp_path / "x.txt"
with patch(
"sys.argv",
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
):
result = mirro.main()
assert result == 1
assert "No backup directory found." in capsys.readouterr().out
def test_restore_last_no_backups(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
target = tmp_path / "t.txt"
with patch(
"sys.argv",
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
):
result = mirro.main()
assert result == 1
assert "No backups found" in capsys.readouterr().out
def test_restore_last_success(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
target = tmp_path / "t.txt"
b1 = d / "t.txt.orig.2020"
b2 = d / "t.txt.orig.2021"
b1.write_text("# header\n\nold1")
b2.write_text("# header\n\nold2")
# ensure newest
os.utime(b2, (time.time(), time.time()))
with patch(
"sys.argv",
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
):
mirro.main()
assert target.read_text() == "old2"
assert "Restored" in capsys.readouterr().out
# ============================================================
# --prune-backups
# ============================================================
def test_prune_all(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
(d / "a").write_text("x")
(d / "b").write_text("y")
with patch(
"sys.argv", ["mirro", "--prune-backups=all", "--backup-dir", str(d)]
):
mirro.main()
out = capsys.readouterr().out
assert "Removed ALL backups" in out
assert not any(d.iterdir())
def test_prune_numeric(tmp_path, capsys, monkeypatch):
d = tmp_path / "bk"
d.mkdir()
old = d / "old"
new = d / "new"
old.write_text("x")
new.write_text("y")
one_day_seconds = 86400
os.utime(
old,
(
time.time() - one_day_seconds * 10,
time.time() - one_day_seconds * 10,
),
)
os.utime(new, None)
with patch(
"sys.argv", ["mirro", "--prune-backups=5", "--backup-dir", str(d)]
):
mirro.main()
out = capsys.readouterr().out
assert "Removed 1 backup" in out
assert new.exists()
assert not old.exists()
def test_prune_default_env(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("MIRRO_BACKUPS_LIFE", "1")
d = tmp_path / "bk"
d.mkdir()
f = d / "x"
f.write_text("hi")
os.utime(f, (time.time() - 86400 * 2, time.time() - 86400 * 2))
with patch(
"sys.argv", ["mirro", "--prune-backups", "--backup-dir", str(d)]
):
mirro.main()
assert "Removed 1" in capsys.readouterr().out
def test_prune_invalid_env(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("MIRRO_BACKUPS_LIFE", "nope")
d = tmp_path / "bk"
d.mkdir()
with patch(
"sys.argv", ["mirro", "--prune-backups", "--backup-dir", str(d)]
):
mirro.main()
out = capsys.readouterr().out
assert "Invalid MIRRO_BACKUPS_LIFE value" in out
def test_prune_invalid_arg(tmp_path, capsys):
with patch("sys.argv", ["mirro", "--prune-backups=zzz"]):
result = mirro.main()
assert result == 1
assert "Invalid value for --prune-backups" in capsys.readouterr().out