Compare commits
41 Commits
0.3.0
..
d12ffbf251
| Author | SHA1 | Date | |
|---|---|---|---|
| d12ffbf251 | |||
|
b674b64daf
|
|||
| d27bc37653 | |||
|
47567e40e9
|
|||
|
bc94ac14bf
|
|||
|
909c4eb6f8
|
|||
|
95a989b770
|
|||
|
5ee910f3ad
|
|||
|
6c166c53c8
|
|||
|
f15738006e
|
|||
|
7a680eaab2
|
|||
|
12d6f5fd2f
|
|||
| 07d31d6f83 | |||
|
65d2cd7fee
|
|||
|
46e83e3e48
|
|||
| 3e1b2e5488 | |||
|
9adbb74602
|
|||
|
537397ac36
|
|||
|
0dc9b827f9
|
|||
|
bf0dfac0c4
|
|||
|
74db589391
|
|||
| cc764403e3 | |||
|
e66b5d95e9
|
|||
| b23ca5573e | |||
|
b930b4239e
|
|||
| 4f681851ac | |||
|
e48adddd4c
|
|||
|
d2fff45db7
|
|||
| b2af78e643 | |||
|
ea7c1384a0
|
|||
| e88a248463 | |||
| 755b69ce99 | |||
| 9fa7fc1c52 | |||
| d982509acb | |||
| e66bdc9a8f | |||
| 598a71dcc8 | |||
| 385d721155 | |||
| 1cb0bca865 | |||
| 6dbadc476a | |||
| 5f9d8a81d4 | |||
| 60ff38e207 |
@@ -1,7 +1,7 @@
|
|||||||
name: CI
|
name: Lint & Security
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
precommit-and-security:
|
precommit-and-security:
|
||||||
@@ -20,10 +20,17 @@ jobs:
|
|||||||
run: pip install pre-commit
|
run: pip install pre-commit
|
||||||
|
|
||||||
- name: Run pre-commit hooks
|
- name: Run pre-commit hooks
|
||||||
uses: pre-commit/action@v3.0.1
|
run: pre-commit run --all-files --color always
|
||||||
|
|
||||||
|
- name: Install Poetry
|
||||||
|
run: |
|
||||||
|
pip install poetry
|
||||||
|
poetry self add poetry-plugin-export
|
||||||
|
|
||||||
- name: Install pip-audit
|
- name: Install pip-audit
|
||||||
run: pip install pip-audit
|
run: pip install pip-audit
|
||||||
|
|
||||||
- name: Run pip-audit
|
- name: Audit dependencies (Poetry lockfile)
|
||||||
run: pip-audit
|
run: |
|
||||||
|
poetry export -f requirements.txt --without-hashes \
|
||||||
|
| pip-audit -r /dev/stdin
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: 27 8 * * *
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security-scan:
|
||||||
|
runs-on: running-man
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_DIR: .
|
||||||
|
COSIGN_VERSION: v3.0.5
|
||||||
|
SYFT_VERSION: v1.42.3
|
||||||
|
GRYPE_VERSION: v0.110.0
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Cosign (bootstrap)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FILE="cosign-linux-amd64"
|
||||||
|
|
||||||
|
curl -fLO https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${FILE}
|
||||||
|
|
||||||
|
chmod +x ${FILE}
|
||||||
|
mv ${FILE} /usr/local/bin/cosign
|
||||||
|
|
||||||
|
cosign version
|
||||||
|
|
||||||
|
- name: Install Syft (verified)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_NO_V="${SYFT_VERSION#v}"
|
||||||
|
FILE="syft_${VERSION_NO_V}_linux_amd64.tar.gz"
|
||||||
|
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
|
||||||
|
|
||||||
|
curl -fLO ${BASE_URL}/${FILE}
|
||||||
|
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt
|
||||||
|
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.sig
|
||||||
|
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.pem
|
||||||
|
|
||||||
|
cosign verify-blob \
|
||||||
|
--signature syft_${VERSION_NO_V}_checksums.txt.sig \
|
||||||
|
--certificate syft_${VERSION_NO_V}_checksums.txt.pem \
|
||||||
|
--certificate-identity-regexp "https://github.com/anchore/syft" \
|
||||||
|
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||||
|
syft_${VERSION_NO_V}_checksums.txt
|
||||||
|
|
||||||
|
CHECKSUM_LINE=$(grep " ${FILE}$" syft_${VERSION_NO_V}_checksums.txt)
|
||||||
|
if [ -z "$CHECKSUM_LINE" ]; then
|
||||||
|
echo "Missing checksum entry for ${FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$CHECKSUM_LINE" | sha256sum -c -
|
||||||
|
|
||||||
|
tar -xzf ${FILE}
|
||||||
|
mv syft /usr/local/bin/
|
||||||
|
|
||||||
|
syft version
|
||||||
|
|
||||||
|
- name: Install Grype (verified)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_NO_V="${GRYPE_VERSION#v}"
|
||||||
|
FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz"
|
||||||
|
BASE_URL="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}"
|
||||||
|
|
||||||
|
curl -fLO ${BASE_URL}/${FILE}
|
||||||
|
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt
|
||||||
|
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.sig
|
||||||
|
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.pem
|
||||||
|
|
||||||
|
cosign verify-blob \
|
||||||
|
--signature grype_${VERSION_NO_V}_checksums.txt.sig \
|
||||||
|
--certificate grype_${VERSION_NO_V}_checksums.txt.pem \
|
||||||
|
--certificate-identity-regexp "https://github.com/anchore/grype" \
|
||||||
|
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||||
|
grype_${VERSION_NO_V}_checksums.txt
|
||||||
|
|
||||||
|
CHECKSUM_LINE=$(grep " ${FILE}$" grype_${VERSION_NO_V}_checksums.txt)
|
||||||
|
if [ -z "$CHECKSUM_LINE" ]; then
|
||||||
|
echo "Missing checksum entry for ${FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$CHECKSUM_LINE" | sha256sum -c -
|
||||||
|
|
||||||
|
tar -xzf ${FILE}
|
||||||
|
mv grype /usr/local/bin/
|
||||||
|
|
||||||
|
grype version
|
||||||
|
|
||||||
|
- name: Generate SBOM
|
||||||
|
working-directory: ${{ env.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
syft dir:. -o json > sbom.json
|
||||||
|
|
||||||
|
- name: Show SBOM contents
|
||||||
|
working-directory: ${{ env.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
echo "Packages discovered by Syft:"
|
||||||
|
jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort
|
||||||
|
|
||||||
|
- name: Run Grype scan (JSON)
|
||||||
|
id: audit
|
||||||
|
continue-on-error: true
|
||||||
|
working-directory: ${{ env.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
grype sbom:sbom.json -o json > grype.json
|
||||||
|
|
||||||
|
echo "Vulnerabilities (fixable only):"
|
||||||
|
jq -r '
|
||||||
|
.matches[]
|
||||||
|
| select((.vulnerability.fix.versions | length) > 0)
|
||||||
|
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
|
||||||
|
' grype.json
|
||||||
|
|
||||||
|
# Fail only on fixable MEDIUM/HIGH/CRITICAL
|
||||||
|
jq -e '
|
||||||
|
[
|
||||||
|
.matches[]?
|
||||||
|
| select(
|
||||||
|
(
|
||||||
|
.vulnerability.severity == "Medium" or
|
||||||
|
.vulnerability.severity == "High" or
|
||||||
|
.vulnerability.severity == "Critical"
|
||||||
|
)
|
||||||
|
and
|
||||||
|
(
|
||||||
|
(.vulnerability.fix.versions | length) > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
| length == 0
|
||||||
|
' grype.json
|
||||||
|
|
||||||
|
- name: Show full Grype table
|
||||||
|
working-directory: ${{ env.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
echo "Full Grype report:"
|
||||||
|
grype sbom:sbom.json -o table
|
||||||
|
|
||||||
|
- name: Notify Node-RED on vulnerabilities
|
||||||
|
if: steps.audit.outcome == 'failure'
|
||||||
|
working-directory: ${{ env.TARGET_DIR }}
|
||||||
|
run: |
|
||||||
|
jq '
|
||||||
|
{
|
||||||
|
repo: "guardutils/mirro",
|
||||||
|
summary: (
|
||||||
|
"Total: " +
|
||||||
|
(
|
||||||
|
[
|
||||||
|
.matches[]
|
||||||
|
| select((.vulnerability.fix.versions | length) > 0)
|
||||||
|
] | length | tostring
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vulnerabilities: [
|
||||||
|
.matches[]
|
||||||
|
| select((.vulnerability.fix.versions | length) > 0)
|
||||||
|
| {
|
||||||
|
library: .artifact.name,
|
||||||
|
cve: .vulnerability.id,
|
||||||
|
severity: .vulnerability.severity,
|
||||||
|
installed: .artifact.version,
|
||||||
|
fixed: (.vulnerability.fix.versions[0]),
|
||||||
|
title: .vulnerability.description,
|
||||||
|
url: .vulnerability.dataSource
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
' grype.json \
|
||||||
|
| curl -s -X POST https://nodered.sysmd.uk/vulns-alert \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data-binary @-
|
||||||
|
|
||||||
|
- name: Fail workflow if vulnerabilities found
|
||||||
|
if: steps.audit.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/PyCQA/bandit
|
- repo: https://github.com/PyCQA/bandit
|
||||||
rev: 1.7.9
|
rev: 1.9.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: bandit
|
- id: bandit
|
||||||
files: ^src/mirro/
|
files: ^src/mirro/
|
||||||
args: ["-lll", "-iii", "-s", "B110,B112"]
|
args: ["-lll", "-iii", "-s", "B110,B112"]
|
||||||
|
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
rev: 25.11.0
|
rev: 26.3.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3.13
|
language_version: python3.13
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.4.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
[](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
|
||||||
|
[](https://git.sysmd.uk/guardutils/mirro/releases)
|
||||||
|
[](https://git.sysmd.uk/guardutils/mirro/src/branch/main/.pre-commit-config.yaml)
|
||||||
|
|
||||||
# mirro
|
# mirro
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="mirro.png" alt="mirro logo" width="256" />
|
||||||
|
</div>
|
||||||
|
|
||||||
**mirro** is a tiny safety-first editing wrapper for text files.
|
**mirro** is a tiny safety-first editing wrapper for text files.
|
||||||
You edit a temporary file, **mirro** detects whether anything changed, and if it did, it saves a backup of the original before writing your changes.
|
You edit a temporary file, **mirro** detects whether anything changed, and if it did, it saves a backup of the original before writing your changes.
|
||||||
|
|
||||||
@@ -82,12 +90,14 @@ filename.ext.orig.20251110T174400
|
|||||||
## Functionalities
|
## Functionalities
|
||||||
|
|
||||||
### List all backup files stored in your backup directory.
|
### List all backup files stored in your backup directory.
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --list
|
mirro --list
|
||||||
```
|
```
|
||||||
Output includes permissions, owner/group, timestamps, and backup filenames.
|
Output includes permissions, owner/group, timestamps, and backup filenames.
|
||||||
|
|
||||||
### Restore the most recent backup for a given file.
|
### Restore the most recent backup for a given file.
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --restore-last ~/.config/myapp/config.ini
|
mirro --restore-last ~/.config/myapp/config.ini
|
||||||
```
|
```
|
||||||
@@ -98,25 +108,36 @@ This:
|
|||||||
|
|
||||||
3. and overwrites the target file with its original contents.
|
3. and overwrites the target file with its original contents.
|
||||||
|
|
||||||
|
### Restore ANY backup
|
||||||
|
|
||||||
|
```
|
||||||
|
mirro --restore filename.ext.orig.20251110T174400
|
||||||
|
Restored /path/to/filename.ext from backup filename.ext.orig.20251110T174400
|
||||||
|
```
|
||||||
|
|
||||||
### Remove old backup files.
|
### Remove old backup files.
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --prune-backups
|
mirro --prune-backups
|
||||||
```
|
```
|
||||||
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
|
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
|
||||||
|
|
||||||
### Remove backups older than _N_ days
|
### Remove backups older than _N_ days
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --prune-backups=14
|
mirro --prune-backups=14
|
||||||
```
|
```
|
||||||
This keeps the last 14 days of backups and removes everything older.
|
This keeps the last 14 days of backups and removes everything older.
|
||||||
|
|
||||||
### Remove all backups
|
### Remove all backups
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --prune-backups=all
|
mirro --prune-backups=all
|
||||||
```
|
```
|
||||||
This deletes every backup in the backup directory.
|
This deletes every backup in the backup directory.
|
||||||
|
|
||||||
### Environment Variable
|
### Environment Variable
|
||||||
|
|
||||||
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
|
`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.
|
Its default value is **30** if not set otherwise.
|
||||||
```
|
```
|
||||||
@@ -128,31 +149,119 @@ Invalid or non-numeric values fall back to 30 days.
|
|||||||
|
|
||||||
**Note:** _a value of 0 is **invalid**_.
|
**Note:** _a value of 0 is **invalid**_.
|
||||||
|
|
||||||
|
### Built-in diff
|
||||||
|
|
||||||
|
This shows a _git-like_ diff of the current file version and any of that file backups.
|
||||||
|
```
|
||||||
|
mirro --diff file file.orig.20251121T163121
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shows current directory's history
|
||||||
|
|
||||||
|
Shows which files in the current directory have _**edit history**_ recorded by mirro.
|
||||||
|
For each file, it prints how many revisions exist and when the latest one was saved.
|
||||||
|
```
|
||||||
|
mirro --status
|
||||||
|
|
||||||
|
Files with history in /foo/bar:
|
||||||
|
baz.conf (3 revisions, latest: 2025-01-12 14:03 UTC)
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### From GuardUtils package repo
|
||||||
|
|
||||||
|
This is the preferred method of installation.
|
||||||
|
|
||||||
|
### Debian/Ubuntu
|
||||||
|
|
||||||
|
#### 1) Import the GPG key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /usr/share/keyrings
|
||||||
|
curl -fsSL https://repo.sysmd.uk/guardutils/guardutils.gpg | sudo gpg --dearmor -o /usr/share/keyrings/guardutils.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
|
||||||
|
|
||||||
|
#### 2) Add the APT source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/guardutils.gpg] https://repo.sysmd.uk/guardutils/debian stable main" | sudo tee /etc/apt/sources.list.d/guardutils.list
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3) Update and install
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install mirro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora/RHEL
|
||||||
|
|
||||||
|
#### 1) Import the GPG key
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2) Add the repository configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF'
|
||||||
|
[guardutils]
|
||||||
|
name=GuardUtils Repository
|
||||||
|
baseurl=https://repo.sysmd.uk/guardutils/rpm/$basearch
|
||||||
|
enabled=1
|
||||||
|
gpgcheck=1
|
||||||
|
repo_gpgcheck=1
|
||||||
|
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4) Update and install
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo dnf upgrade --refresh
|
||||||
|
sudo dnf install mirro
|
||||||
|
```
|
||||||
|
|
||||||
|
### From PyPI
|
||||||
|
|
||||||
**NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\
|
**NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\
|
||||||
Either:
|
Either:
|
||||||
|
|
||||||
* install `mirro` as `root` (_preferred_), or
|
* install `mirro` as `root`, or
|
||||||
* add the path to `mirro` to the `secure_path` parameter in `/etc/sudoers`. For example, where `/home/user/.local/bin` is where `mirro` is:
|
* add the path to `mirro` to the `secure_path` parameter in `/etc/sudoers`. For example, where `/home/user/.local/bin` is where `mirro` is:
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin"
|
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin"
|
||||||
```
|
```
|
||||||
|
|
||||||
Install via PyPI (preferred):
|
Install with:
|
||||||
```
|
```
|
||||||
pip install mirro
|
pip install mirro
|
||||||
```
|
```
|
||||||
|
|
||||||
Or clone the repo and install locally:
|
### From this repository
|
||||||
```
|
```
|
||||||
git clone https://github.com/mdaleo404/mirro.git
|
git clone https://git.sysmd.uk/guardutils/mirro.git
|
||||||
cd mirro/
|
cd mirro/
|
||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## TAB completion
|
||||||
|
|
||||||
|
Add this to your `.bashrc`
|
||||||
|
```
|
||||||
|
eval "$(register-python-argcomplete mirro)"
|
||||||
|
```
|
||||||
|
|
||||||
|
And then
|
||||||
|
```
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
## How to run the tests
|
## How to run the tests
|
||||||
|
|
||||||
- Clone this repository
|
- Clone this repository
|
||||||
|
|||||||
Generated
+58
-19
@@ -1,4 +1,19 @@
|
|||||||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argcomplete"
|
||||||
|
version = "3.6.3"
|
||||||
|
description = "Bash tab completion for argparse"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
|
||||||
|
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfgv"
|
name = "cfgv"
|
||||||
@@ -6,6 +21,7 @@ version = "3.4.0"
|
|||||||
description = "Validate configuration and produce human readable error messages."
|
description = "Validate configuration and produce human readable error messages."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
||||||
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||||
@@ -17,6 +33,8 @@ version = "0.4.6"
|
|||||||
description = "Cross-platform colored terminal text."
|
description = "Cross-platform colored terminal text."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
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 = [
|
files = [
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
@@ -28,6 +46,7 @@ version = "7.11.3"
|
|||||||
description = "Code coverage measurement for Python"
|
description = "Code coverage measurement for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5"},
|
{file = "coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5"},
|
||||||
{file = "coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7"},
|
{file = "coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7"},
|
||||||
@@ -127,7 +146,7 @@ files = [
|
|||||||
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
|
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
toml = ["tomli"]
|
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
@@ -135,6 +154,7 @@ version = "0.4.0"
|
|||||||
description = "Distribution utilities"
|
description = "Distribution utilities"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
|
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
|
||||||
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
||||||
@@ -146,6 +166,8 @@ version = "1.3.0"
|
|||||||
description = "Backport of PEP 654 (exception groups)"
|
description = "Backport of PEP 654 (exception groups)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "python_version == \"3.10\""
|
||||||
files = [
|
files = [
|
||||||
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
|
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
|
||||||
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
||||||
@@ -159,13 +181,14 @@ test = ["pytest (>=6)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.20.0"
|
version = "3.20.3"
|
||||||
description = "A platform independent file lock."
|
description = "A platform independent file lock."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"},
|
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
|
||||||
{file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"},
|
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -174,6 +197,7 @@ version = "2.6.15"
|
|||||||
description = "File identification library for Python"
|
description = "File identification library for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
|
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
|
||||||
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
|
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
|
||||||
@@ -188,6 +212,7 @@ version = "2.3.0"
|
|||||||
description = "brain-dead simple config-ini parsing"
|
description = "brain-dead simple config-ini parsing"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||||
@@ -199,6 +224,7 @@ version = "1.9.1"
|
|||||||
description = "Node.js virtual environment builder"
|
description = "Node.js virtual environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||||
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||||
@@ -210,6 +236,7 @@ version = "25.0"
|
|||||||
description = "Core utilities for Python packages"
|
description = "Core utilities for Python packages"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||||
@@ -221,6 +248,7 @@ version = "4.5.0"
|
|||||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
|
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
|
||||||
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
|
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
|
||||||
@@ -237,6 +265,7 @@ version = "1.6.0"
|
|||||||
description = "plugin and hook calling mechanisms for python"
|
description = "plugin and hook calling mechanisms for python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||||
@@ -252,6 +281,7 @@ version = "3.8.0"
|
|||||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
|
{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"},
|
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
|
||||||
@@ -266,13 +296,14 @@ virtualenv = ">=20.10.0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.20.0"
|
||||||
description = "Pygments is a syntax highlighting package written in Python."
|
description = "Pygments is a syntax highlighting package written in Python."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
|
||||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
@@ -280,13 +311,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.1"
|
version = "9.0.3"
|
||||||
description = "pytest: simple powerful testing with Python"
|
description = "pytest: simple powerful testing with Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"},
|
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
|
||||||
{file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"},
|
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@@ -307,6 +339,7 @@ version = "7.0.0"
|
|||||||
description = "Pytest plugin for measuring coverage."
|
description = "Pytest plugin for measuring coverage."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
|
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
|
||||||
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
|
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
|
||||||
@@ -326,6 +359,7 @@ version = "6.0.3"
|
|||||||
description = "YAML parser and emitter for Python"
|
description = "YAML parser and emitter for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
{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_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||||
@@ -408,6 +442,8 @@ version = "2.3.0"
|
|||||||
description = "A lil' TOML parser"
|
description = "A lil' TOML parser"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "python_full_version <= \"3.11.0a6\""
|
||||||
files = [
|
files = [
|
||||||
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
|
{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-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
|
||||||
@@ -459,6 +495,8 @@ version = "4.15.0"
|
|||||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "python_version == \"3.10\""
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
@@ -466,26 +504,27 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.35.4"
|
version = "20.36.1"
|
||||||
description = "Virtual Python Environment builder"
|
description = "Virtual Python Environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
|
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
|
||||||
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
|
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
distlib = ">=0.3.7,<1"
|
distlib = ">=0.3.7,<1"
|
||||||
filelock = ">=3.12.2,<4"
|
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
|
||||||
platformdirs = ">=3.9.1,<5"
|
platformdirs = ">=3.9.1,<5"
|
||||||
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
|
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[package.extras]
|
[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)"]
|
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)"]
|
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) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.10,<4.0"
|
python-versions = ">=3.10,<4.0"
|
||||||
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107"
|
content-hash = "b90604ce6169c72e35550fd278fab34b383e08f72e6db019f3a8ae611c2eae0a"
|
||||||
|
|||||||
+5
-4
@@ -1,21 +1,22 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "mirro"
|
name = "mirro"
|
||||||
version = "0.3.0"
|
version = "0.6.2"
|
||||||
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
|
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
|
||||||
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"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
homepage = "https://github.com/mdaleo404/mirro"
|
homepage = "https://git.sysmd.uk/guardutils/mirro"
|
||||||
repository = "https://github.com/mdaleo404/mirro"
|
repository = "https://git.sysmd.uk/guardutils/mirro"
|
||||||
packages = [{include = "mirro", from = "src"}]
|
packages = [{include = "mirro", from = "src"}]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.10,<4.0"
|
python = ">=3.10,<4.0"
|
||||||
|
argcomplete = ">=2"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
mirro = "mirro.main:main"
|
mirro = "mirro.main:main"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^9.0.1"
|
pytest = "^9.0.1"
|
||||||
pytest-cov = "^7.0.0"
|
pytest-cov = "^7.0.0"
|
||||||
pre-commit = "^3.8"
|
pre-commit = "^3.8"
|
||||||
|
|||||||
+218
-21
@@ -1,9 +1,11 @@
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import argparse
|
import argparse
|
||||||
|
import argcomplete
|
||||||
import tempfile
|
import tempfile
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import difflib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -49,6 +51,44 @@ def backup_original(
|
|||||||
return backup_path
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
def strip_mirro_header(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Strip only mirro's backup header (if present).
|
||||||
|
Never removes shebangs or anything else.
|
||||||
|
"""
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
|
||||||
|
# If there's no mirro header, return the text unchanged
|
||||||
|
if not lines or not lines[0].startswith(
|
||||||
|
"# ---------------------------------------------------"
|
||||||
|
):
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Otherwise skip all header lines until the first blank line
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
if lines[i].strip() == "":
|
||||||
|
i += 1 # skip the blank separator line
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# 'i' now points to the first real line of the original file
|
||||||
|
return "".join(lines[i:])
|
||||||
|
|
||||||
|
|
||||||
|
def extract_original_path(backup_text: str) -> Path | None:
|
||||||
|
"""
|
||||||
|
Extract the original file path from a mirro backup header.
|
||||||
|
"""
|
||||||
|
for line in backup_text.splitlines():
|
||||||
|
if line.startswith("# Original file:"):
|
||||||
|
path = line.split(":", 1)[1].strip()
|
||||||
|
return Path(path).expanduser()
|
||||||
|
if line.strip() == "":
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Safely edit a file with automatic original backup if changed."
|
description="Safely edit a file with automatic original backup if changed."
|
||||||
@@ -80,6 +120,13 @@ def main():
|
|||||||
help="Restore the last backup of the given file and exit",
|
help="Restore the last backup of the given file and exit",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--restore",
|
||||||
|
metavar="BACKUP",
|
||||||
|
type=str,
|
||||||
|
help="Restore the given backup file and exit",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--prune-backups",
|
"--prune-backups",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
@@ -87,9 +134,93 @@ def main():
|
|||||||
help="Prune backups older than MIRRO_BACKUPS_LIFE days, or 'all' to delete all backups",
|
help="Prune backups older than MIRRO_BACKUPS_LIFE days, or 'all' to delete all backups",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--diff",
|
||||||
|
nargs=2,
|
||||||
|
metavar=("FILE", "BACKUP"),
|
||||||
|
help="Show a unified diff between FILE and BACKUP and exit",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--status",
|
||||||
|
action="store_true",
|
||||||
|
help="Show which files in the current directory have 'revisions'",
|
||||||
|
)
|
||||||
|
|
||||||
|
argcomplete.autocomplete(parser)
|
||||||
|
|
||||||
# Parse only options. Leave everything else untouched.
|
# Parse only options. Leave everything else untouched.
|
||||||
args, positional = parser.parse_known_args()
|
args, positional = parser.parse_known_args()
|
||||||
|
|
||||||
|
if args.diff:
|
||||||
|
file_arg, backup_arg = args.diff
|
||||||
|
|
||||||
|
file_path = Path(file_arg).expanduser().resolve()
|
||||||
|
|
||||||
|
# Resolve backup: if it’s not absolute or ~, treat it as a filename in the backup dir
|
||||||
|
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
|
||||||
|
backup_path = Path(backup_arg).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
backup_path = backup_dir / backup_arg
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
print(f"File not found: {file_path}")
|
||||||
|
return 1
|
||||||
|
if not backup_path.exists():
|
||||||
|
print(f"Backup not found: {backup_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Enforce same base filename while diffing
|
||||||
|
target_name = file_path.name
|
||||||
|
backup_name = backup_path.name
|
||||||
|
|
||||||
|
if not backup_name.startswith(target_name + ".orig."):
|
||||||
|
print(
|
||||||
|
f"Error: Backup '{backup_name}' does not match the file being diffed.\n"
|
||||||
|
f"Expected backup file starting with: {target_name}.orig."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
original = file_path.read_text(
|
||||||
|
encoding="utf-8", errors="replace"
|
||||||
|
).splitlines()
|
||||||
|
backup_raw = backup_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
backup_stripped = strip_mirro_header(backup_raw)
|
||||||
|
backup = backup_stripped.splitlines()
|
||||||
|
|
||||||
|
# Generate a clean diff (no trailing line noise)
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
backup,
|
||||||
|
original,
|
||||||
|
fromfile=f"a/{file_path.name}",
|
||||||
|
tofile=f"b/{file_path.name}",
|
||||||
|
lineterm="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED = "\033[31m"
|
||||||
|
GREEN = "\033[32m"
|
||||||
|
CYAN = "\033[36m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
for line in diff:
|
||||||
|
if (
|
||||||
|
line.startswith("---")
|
||||||
|
or line.startswith("+++")
|
||||||
|
or line.startswith("@@")
|
||||||
|
):
|
||||||
|
print(f"{CYAN}{line}{RESET}")
|
||||||
|
elif line.startswith("+"):
|
||||||
|
print(f"{GREEN}{line}{RESET}")
|
||||||
|
elif line.startswith("-"):
|
||||||
|
print(f"{RED}{line}{RESET}")
|
||||||
|
else:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
if args.list:
|
if args.list:
|
||||||
import pwd, grp
|
import pwd, grp
|
||||||
|
|
||||||
@@ -147,6 +278,52 @@ def main():
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
cwd = Path.cwd()
|
||||||
|
|
||||||
|
if not backup_dir.exists():
|
||||||
|
print(f"No mirro backups found in {cwd}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Build map: filename -> list of backups
|
||||||
|
backup_map = {}
|
||||||
|
for b in backup_dir.iterdir():
|
||||||
|
name = b.name
|
||||||
|
if ".orig." not in name:
|
||||||
|
continue
|
||||||
|
filename, _, _ = name.partition(".orig.")
|
||||||
|
backup_map.setdefault(filename, []).append(b)
|
||||||
|
|
||||||
|
# Find files in current dir that have backups
|
||||||
|
entries = []
|
||||||
|
for file in cwd.iterdir():
|
||||||
|
if file.is_file() and file.name in backup_map:
|
||||||
|
backups = backup_map[file.name]
|
||||||
|
backups_sorted = sorted(
|
||||||
|
backups, key=lambda x: x.stat().st_mtime, reverse=True
|
||||||
|
)
|
||||||
|
latest = backups_sorted[0]
|
||||||
|
|
||||||
|
latest_mtime = time.strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S UTC",
|
||||||
|
time.gmtime(latest.stat().st_mtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.append((file.name, len(backups), latest_mtime))
|
||||||
|
|
||||||
|
# Nothing found?
|
||||||
|
if not entries:
|
||||||
|
print(f"No mirro backups found in {cwd}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Otherwise print nice report
|
||||||
|
print(f"Files with history in {cwd}:")
|
||||||
|
for name, count, latest in entries:
|
||||||
|
print(f" {name:16} ({count} revision(s), latest: {latest})")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.restore_last:
|
if args.restore_last:
|
||||||
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
target = Path(args.restore_last).expanduser().resolve()
|
target = Path(args.restore_last).expanduser().resolve()
|
||||||
@@ -163,7 +340,7 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
if not backups:
|
if not backups:
|
||||||
print(f"No backups found for {target}")
|
print(f"No history found for {target}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# newest backup
|
# newest backup
|
||||||
@@ -171,31 +348,51 @@ def main():
|
|||||||
|
|
||||||
# read and strip header
|
# read and strip header
|
||||||
raw = last.read_text(encoding="utf-8", errors="replace")
|
raw = last.read_text(encoding="utf-8", errors="replace")
|
||||||
restored = []
|
restored_text = strip_mirro_header(raw)
|
||||||
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")
|
target.write_text(restored_text, encoding="utf-8")
|
||||||
|
|
||||||
print(f"Restored {target} from backup {last.name}")
|
print(f"Restored {target} from backup {last.name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.restore:
|
||||||
|
backup_arg = args.restore
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
|
||||||
|
# Resolve backup path
|
||||||
|
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
|
||||||
|
backup_path = Path(backup_arg).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
backup_path = backup_dir / backup_arg
|
||||||
|
|
||||||
|
if not backup_path.exists():
|
||||||
|
print(f"Backup not found: {backup_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
raw = backup_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
target = extract_original_path(raw)
|
||||||
|
if not target:
|
||||||
|
print(
|
||||||
|
"Could not determine original file location from backup header."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
restored_text = strip_mirro_header(raw)
|
||||||
|
|
||||||
|
# Permission checks
|
||||||
|
if target.exists() and not os.access(target, os.W_OK):
|
||||||
|
print(f"Need elevated privileges to restore {target}")
|
||||||
|
return 1
|
||||||
|
if not target.exists() and not os.access(target.parent, os.W_OK):
|
||||||
|
print(f"Need elevated privileges to create {target}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(restored_text, encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"Restored {target} from backup {backup_path.name}")
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.prune_backups is not None:
|
if args.prune_backups is not None:
|
||||||
mode = args.prune_backups
|
mode = args.prune_backups
|
||||||
|
|
||||||
|
|||||||
+161
-100
@@ -8,7 +8,6 @@ import pytest
|
|||||||
|
|
||||||
import mirro.main as mirro
|
import mirro.main as mirro
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# get_version
|
# get_version
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -48,6 +47,39 @@ def test_write_file(tmp_path):
|
|||||||
assert p.read_text(encoding="utf-8") == "data"
|
assert p.read_text(encoding="utf-8") == "data"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# strip_mirro_header
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_header_removes_header():
|
||||||
|
header_text = (
|
||||||
|
"# ---------------------------------------------------\n"
|
||||||
|
"# mirro backup\n"
|
||||||
|
"# something\n"
|
||||||
|
"# ---------------------------------------------------\n"
|
||||||
|
"\n"
|
||||||
|
"#!/bin/bash\n"
|
||||||
|
"echo hi\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
out = mirro.strip_mirro_header(header_text)
|
||||||
|
assert out.startswith("#!/bin/bash")
|
||||||
|
assert "mirro backup" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_header_preserves_shebang():
|
||||||
|
text = "#!/usr/bin/env python3\nprint('hi')\n"
|
||||||
|
out = mirro.strip_mirro_header(text)
|
||||||
|
assert out == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_header_non_header_file():
|
||||||
|
text = "# just a comment\nvalue\n"
|
||||||
|
out = mirro.strip_mirro_header(text)
|
||||||
|
assert out == text
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# backup_original
|
# backup_original
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -264,29 +296,6 @@ def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
|
|||||||
assert "Need elevated privileges to create" in capsys.readouterr().out
|
assert "Need elevated privileges to create" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
def fake_call(cmd):
|
|
||||||
temp = Path(cmd[1])
|
|
||||||
temp.write_text("edited\n")
|
|
||||||
|
|
||||||
monkeypatch.setenv("EDITOR", "vim")
|
|
||||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
|
||||||
monkeypatch.setattr(os, "access", lambda p, m: True)
|
|
||||||
|
|
||||||
with patch("sys.argv", ["mirro", str(target)]):
|
|
||||||
mirro.main()
|
|
||||||
|
|
||||||
assert target.read_text() == "edited\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# --list
|
# --list
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -343,8 +352,10 @@ def test_restore_last_no_backups(tmp_path, capsys):
|
|||||||
):
|
):
|
||||||
result = mirro.main()
|
result = mirro.main()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
assert result == 1
|
assert result == 1
|
||||||
assert "No backups found" in capsys.readouterr().out
|
assert "No history found for" in out
|
||||||
|
assert str(target) in out
|
||||||
|
|
||||||
|
|
||||||
def test_restore_last_success(tmp_path, capsys):
|
def test_restore_last_success(tmp_path, capsys):
|
||||||
@@ -352,13 +363,22 @@ def test_restore_last_success(tmp_path, capsys):
|
|||||||
d.mkdir()
|
d.mkdir()
|
||||||
target = tmp_path / "t.txt"
|
target = tmp_path / "t.txt"
|
||||||
|
|
||||||
|
mirro_header = (
|
||||||
|
"# ---------------------------------------------------\n"
|
||||||
|
"# mirro backup\n"
|
||||||
|
"# Original file: x\n"
|
||||||
|
"# Timestamp: test\n"
|
||||||
|
"# Delete this header if you want to restore the file\n"
|
||||||
|
"# ---------------------------------------------------\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
b1 = d / "t.txt.orig.2020"
|
b1 = d / "t.txt.orig.2020"
|
||||||
b2 = d / "t.txt.orig.2021"
|
b2 = d / "t.txt.orig.2021"
|
||||||
|
|
||||||
b1.write_text("# header\n\nold1")
|
b1.write_text(mirro_header + "old1")
|
||||||
b2.write_text("# header\n\nold2")
|
b2.write_text(mirro_header + "old2")
|
||||||
|
|
||||||
# ensure newest
|
|
||||||
os.utime(b2, (time.time(), time.time()))
|
os.utime(b2, (time.time(), time.time()))
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
@@ -372,94 +392,135 @@ def test_restore_last_success(tmp_path, capsys):
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# --prune-backups
|
# --restore (new)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def test_prune_all(tmp_path, capsys):
|
def test_restore_backup_to_original_location(tmp_path, capsys):
|
||||||
d = tmp_path / "bk"
|
backup_dir = tmp_path / "bk"
|
||||||
d.mkdir()
|
backup_dir.mkdir()
|
||||||
(d / "a").write_text("x")
|
|
||||||
(d / "b").write_text("y")
|
|
||||||
|
|
||||||
with patch(
|
original_dir = tmp_path / "orig"
|
||||||
"sys.argv", ["mirro", "--prune-backups=all", "--backup-dir", str(d)]
|
original_dir.mkdir()
|
||||||
):
|
|
||||||
mirro.main()
|
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
target = original_dir / "file.txt"
|
||||||
assert "Removed ALL backups" in out
|
|
||||||
assert not any(d.iterdir())
|
|
||||||
|
|
||||||
|
mirro_header = (
|
||||||
def test_prune_numeric(tmp_path, capsys, monkeypatch):
|
"# ---------------------------------------------------\n"
|
||||||
d = tmp_path / "bk"
|
"# mirro backup\n"
|
||||||
d.mkdir()
|
f"# Original file: {target}\n"
|
||||||
|
"# Timestamp: test\n"
|
||||||
old = d / "old"
|
"# Delete this header if you want to restore the file\n"
|
||||||
new = d / "new"
|
"# ---------------------------------------------------\n"
|
||||||
old.write_text("x")
|
"\n"
|
||||||
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)
|
|
||||||
|
backup = backup_dir / "file.txt.orig.20250101T010203"
|
||||||
|
backup.write_text(mirro_header + "restored content\n")
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"sys.argv", ["mirro", "--prune-backups=5", "--backup-dir", str(d)]
|
"sys.argv",
|
||||||
|
["mirro", "--restore", backup.name, "--backup-dir", str(backup_dir)],
|
||||||
):
|
):
|
||||||
mirro.main()
|
result = mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
assert result == 0
|
||||||
assert "Removed 1 backup" in out
|
assert target.exists()
|
||||||
assert new.exists()
|
assert target.read_text() == "restored content\n"
|
||||||
assert not old.exists()
|
assert "Restored" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_prune_default_env(tmp_path, monkeypatch, capsys):
|
def test_restore_missing_backup(tmp_path, capsys):
|
||||||
monkeypatch.setenv("MIRRO_BACKUPS_LIFE", "1")
|
backup_dir = tmp_path / "bk"
|
||||||
|
backup_dir.mkdir()
|
||||||
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(
|
with patch(
|
||||||
"sys.argv", ["mirro", "--prune-backups", "--backup-dir", str(d)]
|
"sys.argv",
|
||||||
|
[
|
||||||
|
"mirro",
|
||||||
|
"--restore",
|
||||||
|
"nope.orig.123",
|
||||||
|
"--backup-dir",
|
||||||
|
str(backup_dir),
|
||||||
|
],
|
||||||
):
|
):
|
||||||
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()
|
result = mirro.main()
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
assert "Invalid value for --prune-backups" in capsys.readouterr().out
|
assert "Backup not found" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_missing_original_path_in_header(tmp_path, capsys):
|
||||||
|
backup_dir = tmp_path / "bk"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
|
||||||
|
bad_backup = backup_dir / "x.txt.orig.123"
|
||||||
|
bad_backup.write_text(
|
||||||
|
"# ---------------------------------------------------\n"
|
||||||
|
"# mirro backup\n"
|
||||||
|
"# Timestamp: test\n"
|
||||||
|
"\n"
|
||||||
|
"data\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv",
|
||||||
|
[
|
||||||
|
"mirro",
|
||||||
|
"--restore",
|
||||||
|
bad_backup.name,
|
||||||
|
"--backup-dir",
|
||||||
|
str(backup_dir),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
assert (
|
||||||
|
"Could not determine original file location" in capsys.readouterr().out
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# --status
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_no_backups(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
backup_dir = tmp_path / "bk"
|
||||||
|
with patch(
|
||||||
|
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert result == 0
|
||||||
|
assert f"No mirro backups found in {tmp_path}" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_backups_found(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
cwd = tmp_path
|
||||||
|
backup_dir = tmp_path / "bk"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
|
||||||
|
(tmp_path / "a.txt").write_text("data1")
|
||||||
|
(tmp_path / "b.txt").write_text("data2")
|
||||||
|
|
||||||
|
(backup_dir / "a.txt.orig.1").write_text("x")
|
||||||
|
(backup_dir / "a.txt.orig.2").write_text("y")
|
||||||
|
(backup_dir / "b.txt.orig.3").write_text("z")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert result == 0
|
||||||
|
assert f"Files with history in {cwd}:" in out
|
||||||
|
assert "a.txt" in out
|
||||||
|
assert "b.txt" in out
|
||||||
|
|||||||
Reference in New Issue
Block a user