Compare commits
25 Commits
4f681851ac
..
0.6.2
| 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
|
@@ -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,11 +1,13 @@
|
|||||||
[](LICENCE)
|
[](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
|
||||||
[](https://github.com/guardutils/mirro/)
|
[](https://git.sysmd.uk/guardutils/mirro/releases)
|
||||||
[](https://github.com/guardutils/mirro/releases)
|
[](https://git.sysmd.uk/guardutils/mirro/src/branch/main/.pre-commit-config.yaml)
|
||||||
[](https://pypi.org/project/mirro/#history)
|
|
||||||
[](https://pypi.org/project/mirro/)
|
|
||||||
|
|
||||||
# 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.
|
||||||
|
|
||||||
@@ -106,6 +108,13 @@ 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.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -160,20 +169,60 @@ Files with history in /foo/bar:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### From package manager
|
### From GuardUtils package repo
|
||||||
|
|
||||||
This is the preferred method of installation.
|
This is the preferred method of installation.
|
||||||
|
|
||||||
**Ubuntu 22.04 and 24.04**
|
### 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 add-apt-repository ppa:mdaleo/mirro
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install mirro
|
sudo apt install mirro
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fedora 41, 42, 43**
|
### Fedora/RHEL
|
||||||
|
|
||||||
|
#### 1) Import the GPG key
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo dnf copr enable mdaleo/mirro
|
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
|
sudo dnf install mirro
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -196,7 +245,7 @@ pip install mirro
|
|||||||
|
|
||||||
### From this repository
|
### From this repository
|
||||||
```
|
```
|
||||||
git clone https://github.com/guardutils/mirro.git
|
git clone https://git.sysmd.uk/guardutils/mirro.git
|
||||||
cd mirro/
|
cd mirro/
|
||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+44
-19
@@ -1,4 +1,4 @@
|
|||||||
# 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]]
|
[[package]]
|
||||||
name = "argcomplete"
|
name = "argcomplete"
|
||||||
@@ -6,6 +6,7 @@ version = "3.6.3"
|
|||||||
description = "Bash tab completion for argparse"
|
description = "Bash tab completion for argparse"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
|
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
|
||||||
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
|
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
|
||||||
@@ -20,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"},
|
||||||
@@ -31,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"},
|
||||||
@@ -42,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"},
|
||||||
@@ -141,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"
|
||||||
@@ -149,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"},
|
||||||
@@ -160,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"},
|
||||||
@@ -173,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]]
|
||||||
@@ -188,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"},
|
||||||
@@ -202,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"},
|
||||||
@@ -213,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"},
|
||||||
@@ -224,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"},
|
||||||
@@ -235,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"},
|
||||||
@@ -251,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"},
|
||||||
@@ -266,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"},
|
||||||
@@ -280,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]
|
||||||
@@ -294,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]
|
||||||
@@ -321,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"},
|
||||||
@@ -340,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"},
|
||||||
@@ -422,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"},
|
||||||
@@ -473,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"},
|
||||||
@@ -480,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 = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91"
|
content-hash = "b90604ce6169c72e35550fd278fab34b383e08f72e6db019f3a8ae611c2eae0a"
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "mirro"
|
name = "mirro"
|
||||||
version = "0.5.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/guardutils/mirro"
|
homepage = "https://git.sysmd.uk/guardutils/mirro"
|
||||||
repository = "https://github.com/guardutils/mirro"
|
repository = "https://git.sysmd.uk/guardutils/mirro"
|
||||||
packages = [{include = "mirro", from = "src"}]
|
packages = [{include = "mirro", from = "src"}]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
@@ -16,7 +16,7 @@ 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"
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ def strip_mirro_header(text: str) -> str:
|
|||||||
return "".join(lines[i:])
|
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."
|
||||||
@@ -107,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="?",
|
||||||
@@ -334,6 +354,45 @@ def main():
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
+81
-184
@@ -8,7 +8,6 @@ import pytest
|
|||||||
|
|
||||||
import mirro.main as mirro
|
import mirro.main as mirro
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# get_version
|
# get_version
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -72,7 +71,7 @@ def test_strip_header_removes_header():
|
|||||||
def test_strip_header_preserves_shebang():
|
def test_strip_header_preserves_shebang():
|
||||||
text = "#!/usr/bin/env python3\nprint('hi')\n"
|
text = "#!/usr/bin/env python3\nprint('hi')\n"
|
||||||
out = mirro.strip_mirro_header(text)
|
out = mirro.strip_mirro_header(text)
|
||||||
assert out == text # unchanged
|
assert out == text
|
||||||
|
|
||||||
|
|
||||||
def test_strip_header_non_header_file():
|
def test_strip_header_non_header_file():
|
||||||
@@ -297,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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -376,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):
|
||||||
@@ -401,7 +379,6 @@ def test_restore_last_success(tmp_path, capsys):
|
|||||||
b1.write_text(mirro_header + "old1")
|
b1.write_text(mirro_header + "old1")
|
||||||
b2.write_text(mirro_header + "old2")
|
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(
|
||||||
@@ -415,156 +392,93 @@ 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):
|
|
||||||
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" 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
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# --diff tests
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_basic(tmp_path, capsys):
|
|
||||||
d = tmp_path / "bk"
|
|
||||||
d.mkdir()
|
|
||||||
|
|
||||||
file = tmp_path / "t.txt"
|
|
||||||
file.write_text("line1\nline2\n")
|
|
||||||
|
|
||||||
backup = d / "t.txt.orig.20250101T010203"
|
|
||||||
backup.write_text(
|
|
||||||
"# ---------------------------------------------------\n"
|
"# ---------------------------------------------------\n"
|
||||||
"# mirro backup\n"
|
"# mirro backup\n"
|
||||||
"# whatever\n"
|
f"# Original file: {target}\n"
|
||||||
|
"# Timestamp: test\n"
|
||||||
|
"# Delete this header if you want to restore the file\n"
|
||||||
|
"# ---------------------------------------------------\n"
|
||||||
"\n"
|
"\n"
|
||||||
"line1\nold\n"
|
)
|
||||||
|
|
||||||
|
backup = backup_dir / "file.txt.orig.20250101T010203"
|
||||||
|
backup.write_text(mirro_header + "restored content\n")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv",
|
||||||
|
["mirro", "--restore", backup.name, "--backup-dir", str(backup_dir)],
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
assert target.exists()
|
||||||
|
assert target.read_text() == "restored content\n"
|
||||||
|
assert "Restored" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_missing_backup(tmp_path, capsys):
|
||||||
|
backup_dir = tmp_path / "bk"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv",
|
||||||
|
[
|
||||||
|
"mirro",
|
||||||
|
"--restore",
|
||||||
|
"nope.orig.123",
|
||||||
|
"--backup-dir",
|
||||||
|
str(backup_dir),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
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(
|
with patch(
|
||||||
"sys.argv",
|
"sys.argv",
|
||||||
["mirro", "--diff", str(file), backup.name, "--backup-dir", str(d)],
|
[
|
||||||
):
|
"mirro",
|
||||||
mirro.main()
|
"--restore",
|
||||||
|
bad_backup.name,
|
||||||
out = capsys.readouterr().out
|
"--backup-dir",
|
||||||
|
str(backup_dir),
|
||||||
assert "--- a/t.txt" in out
|
],
|
||||||
assert "+++ b/t.txt" in out
|
|
||||||
assert "@@" in out
|
|
||||||
assert "-old" in out
|
|
||||||
assert "+line2" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_diff_wrong_backup_name_rejected(tmp_path, capsys):
|
|
||||||
d = tmp_path / "bk"
|
|
||||||
d.mkdir()
|
|
||||||
|
|
||||||
file = tmp_path / "foo.txt"
|
|
||||||
file.write_text("hello\n")
|
|
||||||
|
|
||||||
bad = d / "bar.txt.orig.20250101T010203"
|
|
||||||
bad.write_text("stuff\n")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"sys.argv",
|
|
||||||
["mirro", "--diff", str(file), bad.name, "--backup-dir", str(d)],
|
|
||||||
):
|
):
|
||||||
result = mirro.main()
|
result = mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
assert "does not match the file being diffed" in out
|
assert (
|
||||||
assert "foo.txt.orig." in out
|
"Could not determine original file location" in capsys.readouterr().out
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -575,7 +489,7 @@ def test_diff_wrong_backup_name_rejected(tmp_path, capsys):
|
|||||||
def test_status_no_backups(tmp_path, monkeypatch, capsys):
|
def test_status_no_backups(tmp_path, monkeypatch, capsys):
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
backup_dir = tmp_path / "bk" # no backups inside
|
backup_dir = tmp_path / "bk"
|
||||||
with patch(
|
with patch(
|
||||||
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
||||||
):
|
):
|
||||||
@@ -593,25 +507,12 @@ def test_status_backups_found(tmp_path, monkeypatch, capsys):
|
|||||||
backup_dir = tmp_path / "bk"
|
backup_dir = tmp_path / "bk"
|
||||||
backup_dir.mkdir()
|
backup_dir.mkdir()
|
||||||
|
|
||||||
# Files in current directory
|
(tmp_path / "a.txt").write_text("data1")
|
||||||
f1 = tmp_path / "a.txt"
|
(tmp_path / "b.txt").write_text("data2")
|
||||||
f2 = tmp_path / "b.txt"
|
|
||||||
f1.write_text("data1")
|
|
||||||
f2.write_text("data2")
|
|
||||||
|
|
||||||
# Backups
|
(backup_dir / "a.txt.orig.1").write_text("x")
|
||||||
(backup_dir / "a.txt.orig.20200101T000000").write_text("backup1")
|
(backup_dir / "a.txt.orig.2").write_text("y")
|
||||||
(backup_dir / "a.txt.orig.20200101T010000").write_text("backup2")
|
(backup_dir / "b.txt.orig.3").write_text("z")
|
||||||
(backup_dir / "b.txt.orig.20200202T020000").write_text("backup3")
|
|
||||||
|
|
||||||
# mtimes
|
|
||||||
t1 = time.time() - 200
|
|
||||||
t2 = time.time() - 100
|
|
||||||
t3 = time.time() - 50
|
|
||||||
|
|
||||||
os.utime(backup_dir / "a.txt.orig.20200101T000000", (t1, t1))
|
|
||||||
os.utime(backup_dir / "a.txt.orig.20200101T010000", (t2, t2))
|
|
||||||
os.utime(backup_dir / "b.txt.orig.20200202T020000", (t3, t3))
|
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
|
||||||
@@ -619,11 +520,7 @@ def test_status_backups_found(tmp_path, monkeypatch, capsys):
|
|||||||
result = mirro.main()
|
result = mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
assert f"Backed-up files in {cwd}" in out
|
assert f"Files with history in {cwd}:" in out
|
||||||
assert "a.txt" in out
|
assert "a.txt" in out
|
||||||
assert "b.txt" in out
|
assert "b.txt" in out
|
||||||
assert "(2 backup(s)," in out
|
|
||||||
assert "(1 backup(s)," in out
|
|
||||||
assert "UTC" in out
|
|
||||||
|
|||||||
Reference in New Issue
Block a user