first commit
Commit
bbc3a3035c
@ -0,0 +1,14 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "rich-pixels"
|
||||||
|
versions: ["3.0.0"]
|
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1,30 @@
|
|||||||
|
name: build
|
||||||
|
run-name: Software Building Workflow
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
cache: 'pip'
|
||||||
|
- name: Install ddrescue-tui
|
||||||
|
run: pip install . && pip install setuptools build
|
||||||
|
- name: Build binary package
|
||||||
|
run: python3 -m build
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
files: dist/*
|
@ -0,0 +1,43 @@
|
|||||||
|
name: software-qa
|
||||||
|
run-name: Software QA Workflow
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unittests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: [ "3.10", "3.11", "3.12" ]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: 'pip'
|
||||||
|
- name: Install ddrescue-tui
|
||||||
|
run: pip install .
|
||||||
|
- name: Test with unittest
|
||||||
|
run: |
|
||||||
|
python -m unittest discover -s tests -p "*_test.py"
|
||||||
|
qodana:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
cache: 'pip'
|
||||||
|
- name: Install ddrescue-tui
|
||||||
|
run: pip install .
|
||||||
|
- name: Qodana Scan
|
||||||
|
uses: JetBrains/qodana-action@v2023.3
|
||||||
|
env:
|
||||||
|
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: --baseline,.github/qodana.sarif.json
|
@ -0,0 +1,6 @@
|
|||||||
|
.idea
|
||||||
|
__pycache__
|
||||||
|
*.img
|
||||||
|
/dist
|
||||||
|
report/auxil
|
||||||
|
report/out
|
@ -0,0 +1,7 @@
|
|||||||
|
Copyright 2024 Simon Moser
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@ -0,0 +1,98 @@
|
|||||||
|
# ddrescue-tui
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://github.com/freiburg-missing-semester-course/project-MrMcX/actions/workflows/qa.yml)
|
||||||
|
## Report
|
||||||
|
The report can be found in `/report/`.
|
||||||
|
The directory contains both the PDF file as well as the LaTex sources.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
### Requirements:
|
||||||
|
The following packages required:
|
||||||
|
- python3.10 or higher
|
||||||
|
- pip modules:
|
||||||
|
- matplotlib
|
||||||
|
- numpy
|
||||||
|
- textual
|
||||||
|
- rich-pixels == 2.2.0
|
||||||
|
- gddrescue
|
||||||
|
|
||||||
|
#### Optionally for development:
|
||||||
|
- pip:
|
||||||
|
- build
|
||||||
|
- setuptools
|
||||||
|
- textual-dev
|
||||||
|
|
||||||
|
### Installing
|
||||||
|
Installing so it can be used from everywhere with `ddrescue-tui` can be done with `./utilctl install` or manually with `pipx install .` inside of the project directory.
|
||||||
|
`utilctl` also installs all necessary requirements
|
||||||
|
I recommend using pipx to avoid possible conflicts
|
||||||
|
|
||||||
|
Alternatively, a .whl file can be downloaded from the releases tab and installed with pip.
|
||||||
|
|
||||||
|
**For running ddrescue directly, the package has to be run as sudo and therefore also installed with sudo.**
|
||||||
|
|
||||||
|
|
||||||
|
### Building a binary package
|
||||||
|
Building a package for binary distribution can be done with: `./utilctl build` or manually with `pipx run build`.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
If the command results in a blank terminal, the Python IO encoding might be not set to UTF-8. You can fix this with
|
||||||
|
`export PYTHONIOENCODING=utf-8` once per terminal session or by prepending the command like this: `PYTHONIOENCODING=utf-8 ddrescue-tui <ARGS>`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```
|
||||||
|
$ ddrescue-tui -h
|
||||||
|
usage: ddrescue-tui [-h] [-i I] {open,run} ...
|
||||||
|
|
||||||
|
TUI for ddrescue, visualizes the progress using the mapfile
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
{open,run}
|
||||||
|
open open mapfile
|
||||||
|
run run ddrescue on startup (sudo required)
|
||||||
|
|
||||||
|
options:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-i I, --interval I Initial reload interval (default=32s)
|
||||||
|
|
||||||
|
$ ddrescue-tui open -h
|
||||||
|
usage: ddrescue-tui open [-h] [-n] mapfile
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
mapfile mapfile to read from
|
||||||
|
|
||||||
|
options:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-n, --noninteractive Run simple non-interactive mode
|
||||||
|
|
||||||
|
$ ddrescue-tui run -h
|
||||||
|
usage: ddrescue-tui run [-h] [-o [OUT]] [-a ARGS] INPUT OUTPUT
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
INPUT input file or device
|
||||||
|
OUTPUT output file or folder
|
||||||
|
|
||||||
|
options:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-o [OUT], --output_name [OUT]
|
||||||
|
output filename
|
||||||
|
-a ARGS, --arguments ARGS
|
||||||
|
Custom arguments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Example mapfiles can be found in `./mapfiles`.
|
||||||
|
|
||||||
|
### Non-interactive
|
||||||
|
```bash
|
||||||
|
$ ddrescue-tui open -n mapfiles/2024-02-10_virt.map
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ddrescue-tui open mapfiles/2024-01-18_HDD.map
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
@ -0,0 +1,9 @@
|
|||||||
|
# Mapfile. Created by GNU ddrescue version 1.27
|
||||||
|
# Command line: ddrescue /dev/sda 2023-12-14_USB.img 2023-12-14_USB.map
|
||||||
|
# Start time: 2023-12-14 15:36:04
|
||||||
|
# Current time: 2023-12-14 16:01:49
|
||||||
|
# Finished
|
||||||
|
# current_pos current_status current_pass
|
||||||
|
0x1CA3FF0000 + 1
|
||||||
|
# pos size status
|
||||||
|
0x00000000 0x1CA4000000 +
|
@ -0,0 +1,12 @@
|
|||||||
|
# Mapfile. Created by GNU ddrescue version 1.27
|
||||||
|
# Command line: ddrescue -R /dev/sdb 2024-01-16_SSD.img 2024-01-16_SSD.map
|
||||||
|
# Start time: 2024-01-16 15:44:49
|
||||||
|
# Current time: 2024-01-16 15:44:56
|
||||||
|
# Scraping failed blocks... (backwards)
|
||||||
|
# current_pos current_status current_pass
|
||||||
|
0x748CB7200 / 1
|
||||||
|
# pos size status
|
||||||
|
0x00000000 0x734140000 +
|
||||||
|
0x734140000 0x14B77200 -
|
||||||
|
0x748CB7200 0x6FF359EC00 /
|
||||||
|
0x773C255E00 0x00000200 -
|
@ -0,0 +1,35 @@
|
|||||||
|
# Mapfile. Created by GNU ddrescue version 1.27
|
||||||
|
# Command line: ddrescue -d /dev/sdb 2024-01-18_HDD.img 2024-01-18_HDD.map
|
||||||
|
# Start time: 2024-01-18 11:44:21
|
||||||
|
# Current time: 2024-01-18 12:02:45
|
||||||
|
# Scraping failed blocks... (forwards)
|
||||||
|
# current_pos current_status current_pass
|
||||||
|
0x58164EC00 / 1
|
||||||
|
# pos size status
|
||||||
|
0x00000000 0x5B239A00 +
|
||||||
|
0x5B239A00 0x44A22A00 -
|
||||||
|
0x9FC5C400 0x32F767C00 +
|
||||||
|
0x3CF3C4000 0x00000800 -
|
||||||
|
0x3CF3C4800 0xC2019600 +
|
||||||
|
0x4913DDE00 0x34330400 -
|
||||||
|
0x4C570E200 0x685DD000 +
|
||||||
|
0x52DCEB200 0x00000800 -
|
||||||
|
0x52DCEBA00 0x1658A400 +
|
||||||
|
0x544275E00 0x053DDC00 -
|
||||||
|
0x549653A00 0x173E2400 +
|
||||||
|
0x560A35E00 0x20C18E00 -
|
||||||
|
0x58164EC00 0x7A154A00 /
|
||||||
|
0x5FB7A3600 0x00000800 -
|
||||||
|
0x5FB7A3E00 0x1CE552000 +
|
||||||
|
0x7C9CF5E00 0x00000200 -
|
||||||
|
0x7C9CF6000 0x5D1846E00 /
|
||||||
|
0xD9B53CE00 0x00000800 -
|
||||||
|
0xD9B53D600 0x2794E3800 +
|
||||||
|
0x1014A20E00 0x00000200 -
|
||||||
|
0x1014A21000 0x4A88EB000 /
|
||||||
|
0x14BD30C000 0x00000800 -
|
||||||
|
0x14BD30C800 0x1EF07800 +
|
||||||
|
0x14DC214000 0x00000200 -
|
||||||
|
0x14DC214200 0x54C2BCB600 /
|
||||||
|
0x699EDDF800 0x00000800 -
|
||||||
|
0x699EDE0000 0xAD1E26000 +
|
@ -0,0 +1,11 @@
|
|||||||
|
# Mapfile. Created by GNU ddrescue version 1.27
|
||||||
|
# Command line: ddrescue /dev/sdb 2024-02-08_virt.img 2024-02-08_virt.map
|
||||||
|
# Start time: 2024-02-08 06:56:57
|
||||||
|
# Current time: 2024-02-08 06:56:59
|
||||||
|
# Copying non-tried blocks... Pass 1 (forwards)
|
||||||
|
# current_pos current_status current_pass
|
||||||
|
0x00970000 ? 1
|
||||||
|
# pos size status
|
||||||
|
0x00000000 0x02200000 +
|
||||||
|
0x02200000 0x00000001 -
|
||||||
|
0x02200001 0x3F690000 ?
|
@ -0,0 +1,10 @@
|
|||||||
|
# Mapfile. Created by GNU ddrescue version 1.27
|
||||||
|
# Command line: ddrescue -R -Z 10000000 /dev/sdb 2024-02-10_virt.img 2024-02-10_virt.map --mapfile-interval 2
|
||||||
|
# Start time: 2024-02-10 22:42:40
|
||||||
|
# Current time: 2024-02-10 22:43:13
|
||||||
|
# Copying non-tried blocks... Pass 1 (backwards)
|
||||||
|
# current_pos current_status current_pass
|
||||||
|
0x6C470000 ? 1
|
||||||
|
# pos size status
|
||||||
|
0x00000000 0x6C470000 ?
|
||||||
|
0x6C470000 0x13B91000 +
|
@ -0,0 +1,8 @@
|
|||||||
|
# Mapfile. Created by GNU ddrescue version 1.27
|
||||||
|
# Command line: ddrescue -R -Z 10000000 /dev/sdb running.img running.map
|
||||||
|
# Start time: 2024-02-12 00:18:26
|
||||||
|
# Current time: 2024-02-12 00:18:26
|
||||||
|
# current_pos current_status current_pass
|
||||||
|
0x00000000 ? 1
|
||||||
|
# pos size status
|
||||||
|
0x00000000 0x80001000 ?
|
@ -0,0 +1,36 @@
|
|||||||
|
[project]
|
||||||
|
dependencies = [
|
||||||
|
"matplotlib",
|
||||||
|
"numpy",
|
||||||
|
"rich-pixels == 2.2.0",
|
||||||
|
"textual",
|
||||||
|
"pillow",
|
||||||
|
"rich"
|
||||||
|
]
|
||||||
|
name = "ddrescue-tui"
|
||||||
|
version = "0.3.0"
|
||||||
|
authors = [
|
||||||
|
{ name="Simon Moser", email="info@smoser.eu" },
|
||||||
|
]
|
||||||
|
description = "A terminal UI for ddrescue"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["textual-dev"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
ddrescue-tui = "ddrescue_tui:run_tui"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/freiburg-missing-semester-course/project-MrMcX"
|
||||||
|
Issues = "https://github.com/freiburg-missing-semester-course/project-MrMcX/issues"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
@ -0,0 +1,17 @@
|
|||||||
|
#-------------------------------------------------------------------------------#
|
||||||
|
# Qodana analysis is configured by qodana.yaml file #
|
||||||
|
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||||
|
#-------------------------------------------------------------------------------#
|
||||||
|
version: "1.0"
|
||||||
|
|
||||||
|
#Specify inspection profile for code analysis
|
||||||
|
profile:
|
||||||
|
name: qodana.starter
|
||||||
|
|
||||||
|
#Enable inspections
|
||||||
|
include:
|
||||||
|
- name: VulnerableLibrariesGlobal
|
||||||
|
- name: CheckDependencyLicenses
|
||||||
|
|
||||||
|
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||||
|
linter: jetbrains/qodana-python:latest
|
Binäre Datei nicht angezeigt.
Nachher Breite: | Höhe: | Größe: 26 KiB |
Binäre Datei nicht angezeigt.
Nachher Breite: | Höhe: | Größe: 42 KiB |
Binäre Datei nicht angezeigt.
Nachher Breite: | Höhe: | Größe: 14 KiB |
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1,73 @@
|
|||||||
|
@Manual{ddrescue,
|
||||||
|
title = {GNU ddrescue Manual},
|
||||||
|
author = {Antonio Diaz Diaz},
|
||||||
|
organization = {GNU},
|
||||||
|
year = {2024},
|
||||||
|
url = {https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html},
|
||||||
|
note = {Version 1.28}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inbook{tatort2017,
|
||||||
|
author = {Dirk Pawlaszczyk},
|
||||||
|
title = {Digitaler Tatort, Sicherung und Verfolgung digitaler Spuren},
|
||||||
|
booktitle = {Forensik in der digitalen Welt},
|
||||||
|
publisher = {Springer},
|
||||||
|
year = {2017},
|
||||||
|
pages = {113-166},
|
||||||
|
}
|
||||||
|
|
||||||
|
@book{actions2021,
|
||||||
|
title = {Hands-on GitHub Actions: Implement CI/CD with GitHub Action Workflows for Your Applications},
|
||||||
|
author = {Chaminda Chandrasekara,Pushpa Herath (auth.)},
|
||||||
|
publisher = {Apress},
|
||||||
|
year = {2021}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Article{numpy,
|
||||||
|
title = {Array programming with {NumPy}},
|
||||||
|
author = {Charles R. Harris and K. Jarrod Millman and St{'{e}}fan J.
|
||||||
|
van der Walt and Ralf Gommers and Pauli Virtanen and David
|
||||||
|
Cournapeau and Eric Wieser and Julian Taylor and Sebastian
|
||||||
|
Berg and Nathaniel J. Smith and Robert Kern and Matti Picus
|
||||||
|
and Stephan Hoyer and Marten H. van Kerkwijk and Matthew
|
||||||
|
Brett and Allan Haldane and Jaime Fern{'{a}}ndez del
|
||||||
|
R{'{\i}}o and Mark Wiebe and Pearu Peterson and Pierre
|
||||||
|
G{'{e}}rard-Marchant and Kevin Sheppard and Tyler Reddy and
|
||||||
|
Warren Weckesser and Hameer Abbasi and Christoph Gohlke and
|
||||||
|
Travis E. Oliphant},
|
||||||
|
year = {2020},
|
||||||
|
journal = {Nature},
|
||||||
|
volume = {585},
|
||||||
|
number = {7825},
|
||||||
|
pages = {357--362},
|
||||||
|
doi = {10.1038/s41586-020-2649-2},
|
||||||
|
publisher = {Springer Science and Business Media {LLC}}
|
||||||
|
}
|
||||||
|
|
||||||
|
@book{pil,
|
||||||
|
title = {Python Imaging Library Handbook},
|
||||||
|
author = {Jeffrey A. Clark},
|
||||||
|
publisher = {readthedocs.io},
|
||||||
|
year = {2024},
|
||||||
|
url = {https://pillow.readthedocs.io/en/stable/handbook/}
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{matplotlib,
|
||||||
|
title={Matplotlib: A 2D graphics environment},
|
||||||
|
author={Hunter, John D},
|
||||||
|
journal={Computing in science \& engineering},
|
||||||
|
volume={9},
|
||||||
|
number={3},
|
||||||
|
pages={90--95},
|
||||||
|
year={2007},
|
||||||
|
publisher={IEEE}
|
||||||
|
}
|
||||||
|
|
||||||
|
@book{dependabot,
|
||||||
|
title = {Keeping your supply chain secure with Dependabot
|
||||||
|
},
|
||||||
|
year = {2024},
|
||||||
|
author = {GitHub, Inc.},
|
||||||
|
url = {https://docs.github.com/en/code-security/dependabot},
|
||||||
|
|
||||||
|
}
|
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
@ -0,0 +1,133 @@
|
|||||||
|
\documentclass[runningheads]{llncs}
|
||||||
|
%
|
||||||
|
\usepackage[T1]{fontenc}
|
||||||
|
\usepackage{graphicx}
|
||||||
|
\usepackage[hyphens]{url}
|
||||||
|
\usepackage[unicode=true,bookmarks=true,bookmarksnumbered=true,
|
||||||
|
bookmarksopen=true,bookmarksopenlevel=1,breaklinks=false,
|
||||||
|
pdfborder={0 0 0},backref=false,colorlinks=false]{hyperref}
|
||||||
|
\usepackage{orcidlink}
|
||||||
|
\usepackage{varioref}
|
||||||
|
\usepackage{cleveref}
|
||||||
|
\usepackage{xcolor} % more colors
|
||||||
|
\usepackage[listings,breakable,skins]{tcolorbox}
|
||||||
|
\definecolor{darkgreen}{rgb}{0.0, 0.5, 0.0}
|
||||||
|
\definecolor{darkred}{rgb}{0.5, 0, 0.0}
|
||||||
|
% Colors of the Albert Ludwigs University as in
|
||||||
|
% https://cd.uni-freiburg.de/farben/
|
||||||
|
\definecolor{UniBlau}{RGB}{52, 74, 154}
|
||||||
|
\definecolor{UniGrün}{RGB}{0, 153, 125}
|
||||||
|
\definecolor{UniBraun}{RGB}{143, 107, 48}
|
||||||
|
\definecolor{UniGelb}{RGB}{255, 232, 99}
|
||||||
|
\definecolor{UniRosa}{RGB}{245, 194, 237}
|
||||||
|
\definecolor{UniRosaStrong}{RGB}{255, 151, 171}
|
||||||
|
\definecolor{UniSand}{RGB}{246, 241, 227}
|
||||||
|
|
||||||
|
\lstset{
|
||||||
|
basicstyle=\footnotesize\ttfamily,
|
||||||
|
commentstyle=\footnotesize\ttfamily,
|
||||||
|
numbers=left,
|
||||||
|
numberstyle=\ttfamily\color{gray}\footnotesize,
|
||||||
|
stepnumber=1,
|
||||||
|
numbersep=7pt,
|
||||||
|
showspaces=false,
|
||||||
|
showstringspaces=false,
|
||||||
|
showtabs=false,
|
||||||
|
tabsize=2,
|
||||||
|
captionpos=b,
|
||||||
|
breaklines=true,
|
||||||
|
breakatwhitespace=false,
|
||||||
|
keepspaces=true,
|
||||||
|
showspaces=false,,
|
||||||
|
showstringspaces=false,
|
||||||
|
showtabs=false,
|
||||||
|
title=\lstname,
|
||||||
|
}
|
||||||
|
\newtcblisting[auto counter, list inside=codes,crefname={lst.}{lst.s},Crefname={Listing}{Listings}]{tcbcode}[2][]{%
|
||||||
|
skin=enhanced,
|
||||||
|
title=Listing~\thetcbcounter:~#2,
|
||||||
|
list entry=\thetcbcounter\qquad#2,
|
||||||
|
listing only,
|
||||||
|
size=small, left=6mm,
|
||||||
|
code={\setstretch{1.125}},
|
||||||
|
colback=UniSand!50!white,
|
||||||
|
colbacktitle=UniBlau,
|
||||||
|
colframe=UniBlau,
|
||||||
|
breakable,
|
||||||
|
lines before break=3,
|
||||||
|
#1
|
||||||
|
}
|
||||||
|
|
||||||
|
\graphicspath{ {./img/} }
|
||||||
|
\newcommand{\smimage}[3][width=1\textwidth]{ %
|
||||||
|
\begin{figure}[h] %
|
||||||
|
\centering %
|
||||||
|
\includegraphics[#1]{#2} %
|
||||||
|
\caption{#3} %
|
||||||
|
\label{fig:#2} %
|
||||||
|
\end{figure} %
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
%
|
||||||
|
\title{ddrescue-tui -- A Terminal UI for ddrescue}
|
||||||
|
%\titlerunning{ddrescue-tui}
|
||||||
|
\author{Simon Moser \orcidlink{0009-0003-4916-9317}}
|
||||||
|
\authorrunning{S. Moser}
|
||||||
|
\institute{University of Freiburg, Freiburg, Germany}
|
||||||
|
\maketitle
|
||||||
|
|
||||||
|
\begin{abstract}
|
||||||
|
This paper presents ddrescue-tui, a terminal user interface for the data recovery utility ddrescue.
|
||||||
|
The tool allows users to visualize the status of disk blocks during the rescue process,
|
||||||
|
as well as to start and control ddrescue from the terminal.
|
||||||
|
The tool parses the map files generated by ddrescue and plots them as heat maps using matplotlib.
|
||||||
|
It also uses the textual framework to create an interactive and user-friendly interface.
|
||||||
|
The paper describes the implementation details, the functionalities, and the project management aspects of the tool.
|
||||||
|
It concludes with some limitations and future directions for the tool.
|
||||||
|
\keywords{ddrescue \and terminal \and parsing \and visualization}
|
||||||
|
\end{abstract}
|
||||||
|
|
||||||
|
\section{Introduction}\label{sec:introduction}
|
||||||
|
\input{sections/introduction}
|
||||||
|
|
||||||
|
\section{Parsing mapfiles}\label{sec:parsing-mapfiles}
|
||||||
|
\input{sections/parsing-mapfiles}
|
||||||
|
|
||||||
|
\section{Plotting parsed block information}\label{sec:plotting-parsed-block-information}
|
||||||
|
\input{sections/plotting}
|
||||||
|
|
||||||
|
\section{Terminal User Interface (TUI)}\label{sec:terminal-user-interface-(tui)}
|
||||||
|
\input{sections/terminal-ui}
|
||||||
|
|
||||||
|
\section{Project management with CI/CD}\label{sec:project-management-with-ci/cd}
|
||||||
|
\input{sections/project-management}
|
||||||
|
|
||||||
|
\section{Conclusion}\label{sec:conclusion}
|
||||||
|
\input{sections/conclusion}
|
||||||
|
|
||||||
|
\bibliographystyle{splncs04}
|
||||||
|
\bibliography{report}
|
||||||
|
\nocite{ddrescue}
|
||||||
|
|
||||||
|
\newpage
|
||||||
|
\appendix
|
||||||
|
\begin{tabular}{l|p{10cm}}
|
||||||
|
\textbf{Topics} & \textbf{Implementation} \\
|
||||||
|
\hline
|
||||||
|
Linux & The tool is developed for Linux, but apart from the peculiarities of different terminal emulators,
|
||||||
|
there were no special problems that were dealt with \\
|
||||||
|
Text editor & Text editors and IDEs were used to develop the tool, but not in any special way. \\
|
||||||
|
Git & During development, the various possibilities of CI/CD with GitHub were explored, such as GitHub Actions. \\
|
||||||
|
Docker & Apart from GitHub Actions, containerization was not used. \\
|
||||||
|
Automation & The whole application is written in Python, especially the parsing of mapfiles
|
||||||
|
which is described in \vref{sec:parsing-mapfiles}.\\
|
||||||
|
matplotlib & Utilized for the generation of diagrams showing the rescuing status of ddrescue,
|
||||||
|
as described in \vref{sec:plotting-parsed-block-information}.\\
|
||||||
|
\latex & The report was composed in \latex, but there are no specific details to mention. \\
|
||||||
|
LLM & LLMs were used for selected individual purposes in the preparation of the report,
|
||||||
|
see \vref{subsec:writing-process} for details.\\
|
||||||
|
\end{tabular}
|
||||||
|
|
||||||
|
\end{document}
|
@ -0,0 +1,19 @@
|
|||||||
|
The project achieves the main goal of visualising \texttt{ddrescue} mapfiles on both interactive and non-interactive terminals.
|
||||||
|
In addition, it provides further functionalities on interactive terminals,
|
||||||
|
such as adjusting the update interval at runtime or starting ddrescue both as a command line parameter and via the UI\@.
|
||||||
|
|
||||||
|
This is also where most of the opportunities for improvement lie: \texttt{ddrescue} is executed in parallel to the TUI,
|
||||||
|
which causes some difficulties with the live display of the stdout output in the TUI that have yet to be addressed.
|
||||||
|
|
||||||
|
Additionally, an option to load a (different) mapfile from inside of the UI would improve the usability
|
||||||
|
|
||||||
|
Furthermore, it would be preferable if the entire programme did not have to run
|
||||||
|
with root permissions to execute \texttt{ddrescue}, but only this where it is necessary.
|
||||||
|
However, a way to implement this still needs to be found.
|
||||||
|
|
||||||
|
Last but not least, there is also room for improvement in terms of user-friendliness,
|
||||||
|
such as giving the option of installing via the Python Package Index with pip, offering more functionality
|
||||||
|
as execution piped from a \texttt{ddrescue} run or colorless output or also just improving the documentation.
|
||||||
|
|
||||||
|
The plan here is to transfer the project from the course repository to a personal repository
|
||||||
|
in order to make it publicly accessible and ensure further development.
|
@ -0,0 +1,36 @@
|
|||||||
|
\texttt{GNU ddrescue}\footnote{GNU ddrescue: \url{https://www.gnu.org/software/ddrescue/}, hereinafter referred to as \texttt{ddrescue}, not to be confused with the older \texttt{dd\_rescue}} is a data recovery utility
|
||||||
|
that allows the transfer of data from one file or block device (such as hard disks, CD-ROMs, etc.) to another.
|
||||||
|
Unlike the eponymous tool \texttt{dd}, it focuses on the retrieval of undamaged parts in the event of read errors.
|
||||||
|
The basic operation of ddrescue is fully automated, eliminating the need for manual error handling or program restart.
|
||||||
|
|
||||||
|
The \textbf{mapfile}, a unique feature of \texttt{ddrescue}, is a core feature for its efficiency.
|
||||||
|
The mapfile allows data to be retrieved efficiently by reading only the necessary blocks.
|
||||||
|
It also allows to interrupt the rescue process at any point and resume later at the same point.
|
||||||
|
|
||||||
|
\texttt{ddrescueview}\footnote{ddrescueview: \url{https://sourceforge.net/projects/ddrescueview/}} is a graphical interface for \texttt{ddrescue} mapfiles.
|
||||||
|
It visualizes \texttt{ddrescue}'s mapfiles via a user-friendly GUI\@.
|
||||||
|
The main view shows a grid, with the colour of each cell indicating the rescue status of the blocks it represents.
|
||||||
|
|
||||||
|
Both tools are used for data recovery, both in private and professional environments, for example in IT forensics\cite[p. 130]{tatort2017}.
|
||||||
|
|
||||||
|
However, these tools are not always executed on a system with a graphical user interface.
|
||||||
|
In forensic applications, for example, it is common to work mainly under Windows,
|
||||||
|
as professional software is sometimes only supported there.
|
||||||
|
However, as \texttt{ddrescue} requires a unix-like operating system,
|
||||||
|
a small separate Linux is often used for this purpose,
|
||||||
|
which in some cases does not offer a graphical user interface.
|
||||||
|
|
||||||
|
\texttt{ddrescue} itself is a command line application, so there is mainly a need for another application
|
||||||
|
that visualises mapfiles on the terminal and thus provides more precise information on the progress of a backup.
|
||||||
|
The implementation of this other application is described in the following sections,
|
||||||
|
starting with the parsing of the mapfiles.
|
||||||
|
|
||||||
|
\subsection{Writing process}\label{subsec:writing-process}
|
||||||
|
In the writing process of this report, artificial neural networks were used for selected and individual purposes.
|
||||||
|
|
||||||
|
The online translation service DeepL, which according to the company is based on machine-learning methods
|
||||||
|
such as Transformers and Convolutional Neural Networks, was used as a formulation aid.
|
||||||
|
|
||||||
|
LLMs, specifically ChatGPT with GPT-4, were inter alia used to simplify the manual creation of latex code
|
||||||
|
such as tables and bibliography entries and for generating the abstract,
|
||||||
|
however everything was still corrected and adjusted by hand.
|
@ -0,0 +1,14 @@
|
|||||||
|
In the next step, the parsed 2D-array is plotted in \texttt{ddrescue\_tui\_plotter.py}.
|
||||||
|
This code uses the module \texttt{matplotlib}\cite{matplotlib} to generate a plot
|
||||||
|
and \texttt{PIL}\cite{pil} to load the generated plot into the memory.
|
||||||
|
|
||||||
|
The plot is generated as a heat map with \texttt{matplotlib.pylot.imshow}.
|
||||||
|
Each numerical value of the 2D array representing the rescueing status from the parsing step
|
||||||
|
is assigned a colour value via the \texttt{COLORS} constant.
|
||||||
|
The plot is set up without any decorators such as labels or ticks
|
||||||
|
and then drawn in memory using the figure's \texttt{canvas.draw} function.
|
||||||
|
|
||||||
|
Using the figure's \texttt{canvas.get\_renderer().buffer\_rgba},
|
||||||
|
a binary representation of the plot is copied from memory and loaded using PIL's \texttt{Image.frombytes}.
|
||||||
|
Finally, the remnants of the plot are cleaned up and the PIL image is returned for display.
|
||||||
|
The following section described how the display was implemented in a terminal application.
|
@ -0,0 +1,21 @@
|
|||||||
|
For the management of the project, two GitHub Actions workflows\cite[p. 8]{actions2021} are set up, \texttt{qa} and \texttt{build}.
|
||||||
|
Every workflow runs on a container specified in the respective YAML file and defines one or more jobs that each
|
||||||
|
consist out of steps that are either predefined (e.g. \texttt{actions/checkout@v4}\footnote{actions/checkout: \url{https://github.com/actions/checkout}} for checking out the repository) or
|
||||||
|
just terminal commands (e.g. \texttt{pip install .} to install requirements).
|
||||||
|
|
||||||
|
The workflow \texttt{qa} is defined in \texttt{.github/workflows/qa.yml}.
|
||||||
|
It is executed on every push event and starts two different jobs.
|
||||||
|
The job \texttt{unittests} runs the unit tests stored in the directory \texttt{test} using different supported Python versions.
|
||||||
|
The unit tests are written manually to assure that certain code units function as intended.
|
||||||
|
The job \texttt{qodana} on the other hand triggers a run of the external static code analysis platform Qodana\footnote{Qodana: \url{https://www.jetbrains.com/qodana/}}.
|
||||||
|
Qodana is checking the code for readability, maintainability and security issues.
|
||||||
|
Additionally, checks for vulnerable libraries and the compatibility of the open source licences of the libraries used are activated.
|
||||||
|
A badge in the README shows the status of the last workflow execution.
|
||||||
|
|
||||||
|
The workflow \texttt{build}, defined at \texttt{.github/workflows/build.yml}, is executed when a tag is pushed.
|
||||||
|
It consists of a single job that installs all requirements and uses the module \texttt{build} to create a binary wheel.
|
||||||
|
After successful build, a release is automatically created with the binary wheel and the source code attached.
|
||||||
|
|
||||||
|
Another GitHub feature that was tried out for the project was dependabot\cite{dependabot} which was configured in \texttt{/.github/dependabot.yml}.
|
||||||
|
It checks for vulnerable libraries as well, creates issues for them and if possible provides pull requests with the update.
|
||||||
|
The configuration includes the packaging ecosystem, the checking interval and ignored packages.
|
@ -0,0 +1,55 @@
|
|||||||
|
The tool was developed as a terminal UI (TUI) as opposed to a graphical UI (GUI).
|
||||||
|
In order to function well in any type of console, two different modes have been developed.
|
||||||
|
The file \texttt{ddrescue\_tui.py} offers the CLI entry point and defines its arguments using \texttt{argparse}.
|
||||||
|
It decides which mode described in the following subsections is called,
|
||||||
|
either from the command line parameter \texttt{noninteractive} or by checking whether stdin is a tty.
|
||||||
|
|
||||||
|
\subsection{Non-interactive mode}\label{subsec:non-interactive-mode}
|
||||||
|
The non-interactive mode (see \vref{fig:tui_non-interactive}) only offers the function to visualise a map file.
|
||||||
|
|
||||||
|
The previously described functions for parsing and plotting are used to retrieve and plot image object.
|
||||||
|
|
||||||
|
This object is then printed to the terminal using the module \texttt{rich\_pixels}.
|
||||||
|
There are two methods in this module to print images, one from an image file and one from an image object.
|
||||||
|
However, the method to print from an image object does not implement the possibility to resize the image,
|
||||||
|
so it was necessary to use the protected method \texttt{Pixels.\_segments\_from\_image} which can handle the resizing.
|
||||||
|
|
||||||
|
The image is printed together with general status information and re-displayed at an interval
|
||||||
|
specified in command line arguments using an infinite loop and \texttt{time.sleep}.
|
||||||
|
|
||||||
|
\smimage[htbp,width=1\textwidth]{tui_non-interactive}{Non-interactive output of a mapfile}
|
||||||
|
|
||||||
|
\subsection{Interactive-mode}\label{subsec:interactive-mode}
|
||||||
|
The interactive mode offers a range of additional functions.
|
||||||
|
When called, you can specify whether a map file should be visualised (and at what initial interval) or
|
||||||
|
whether \texttt{ddrescue} should be started (including all associated parameters, root permissions are required here).
|
||||||
|
In addition, \texttt{ddrescue-tui} can also be started without further specifications.
|
||||||
|
|
||||||
|
The first tab of the interactive TUI (see \vref{fig:tui_interactive_1}) visualises a map file,
|
||||||
|
shows some additional status information from it and allows you to change the reload interval during runtime.
|
||||||
|
|
||||||
|
\smimage[htbp,width=1\textwidth]{tui_interactive_1}{Interactive output of a mapfile}
|
||||||
|
|
||||||
|
The second tab (see \vref{fig:tui_interactive_2}) is only available if the programme was executed with root privileges.
|
||||||
|
Here, \texttt{ddrescue} can be started with a form or the command of a running ddrescue is displayed here.
|
||||||
|
|
||||||
|
\smimage[htbp,width=1\textwidth]{tui_interactive_2}{Form to run ddrescue from the TUI}
|
||||||
|
|
||||||
|
Last but not least, the TUI offers both a light and a dark mode, which can be switched at runtime.
|
||||||
|
|
||||||
|
The interactive TUI code is split in two additional files.
|
||||||
|
|
||||||
|
First of all, \texttt{ddrescue\_tui\_app.py} is called.
|
||||||
|
It defines the main TUI application using the \texttt{textual} framework\footnote{textual: \url{https://textual.textualize.io/}} and
|
||||||
|
implements all top-level functions that need access to different parts of the application, like e.g.\ listeners for buttons.
|
||||||
|
Also, the start of \texttt{ddrescue} is done here in the function \texttt{run\_ddrescue}.
|
||||||
|
The modules \texttt{queue} and \texttt{threading} are used to provide a non-blocking IO
|
||||||
|
reading from stdout of the \texttt{ddrescue} process started with the modules \texttt{subprocess}.
|
||||||
|
|
||||||
|
Last but not least, \texttt{ddrescue\_tui\_widgets.py} contains customized widgets of the \texttt{textual} framework.
|
||||||
|
The \texttt{StatusWidget} utilizes the frameworks reactive attributes,
|
||||||
|
that trigger a redraw of a widget when the attribute changes,
|
||||||
|
to display and update status information.
|
||||||
|
The \texttt{PixelWidget} allows to display a simplified image on the console using the module \texttt{rich-pixels}\footnote{rich-pixels: \url{https://github.com/darrenburns/rich-pixels}}
|
||||||
|
which is also developed by the \texttt{textual} developers.
|
||||||
|
It uses the same mathod as described in \vref{subsec:non-interactive-mode} to regularly draw the plot into the widget.
|
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1 @@
|
|||||||
|
from ddrescue_tui.ddrescue_tui import run_tui
|
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Standard modules
|
||||||
|
import logging
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from sys import __stdin__
|
||||||
|
from os import environ, getuid
|
||||||
|
|
||||||
|
# Pip modules
|
||||||
|
from textual.logging import TextualHandler
|
||||||
|
|
||||||
|
# Custom modules
|
||||||
|
from .ddrescue_tui_noninteractive import run_noninteractive
|
||||||
|
from .ddrescue_tui_app import TUIddrescue
|
||||||
|
|
||||||
|
|
||||||
|
def run_tui():
|
||||||
|
"""Entry point"""
|
||||||
|
p = ArgumentParser(description="TUI for ddrescue, visualizes the progress using the mapfile")
|
||||||
|
p.set_defaults(action="")
|
||||||
|
p.set_defaults(sudo=False)
|
||||||
|
p.add_argument("-i", "--interval", metavar="I", type=int, default=32,
|
||||||
|
help="Initial reload interval (default=32s)")
|
||||||
|
sp = p.add_subparsers()
|
||||||
|
mp = sp.add_parser("open", help="open mapfile")
|
||||||
|
mp.set_defaults(action="open")
|
||||||
|
mp.add_argument("mapfile", help="mapfile to read from", type=str)
|
||||||
|
mp.add_argument("-n", "--noninteractive", action="store_true", help="Run simple non-interactive mode")
|
||||||
|
dp = sp.add_parser("run", help="run ddrescue on startup (sudo required)")
|
||||||
|
dp.set_defaults(action="run")
|
||||||
|
dp.add_argument("input", metavar="INPUT", help="input file or device", type=str)
|
||||||
|
dp.add_argument("output", metavar="OUTPUT", help="output file or folder", type=str)
|
||||||
|
dp.add_argument("-o", "--output_name", metavar="OUT", nargs="?", help="output filename", type=str)
|
||||||
|
dp.add_argument("-a", "--arguments", metavar="ARGS", help="Custom arguments", type=str)
|
||||||
|
args = p.parse_args()
|
||||||
|
environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
|
||||||
|
if not (args.action == "open" and args.noninteractive) and __stdin__.isatty():
|
||||||
|
# Use Textual dev logging
|
||||||
|
logging.basicConfig(level="NOTSET", handlers=[TextualHandler()])
|
||||||
|
if getuid() == 0:
|
||||||
|
args.sudo = True
|
||||||
|
elif args.action == "run":
|
||||||
|
exit("Subcommand run requires sudo privileges")
|
||||||
|
TUIddrescue(args).run()
|
||||||
|
else:
|
||||||
|
if args.action == "open":
|
||||||
|
try:
|
||||||
|
run_noninteractive(args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
exit()
|
||||||
|
exit("Only subcommand open can be called from a non-interactive terminal")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_tui()
|
@ -0,0 +1,44 @@
|
|||||||
|
PixelWidget {
|
||||||
|
align: left top;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusWidget {
|
||||||
|
align: left bottom;
|
||||||
|
background: $background;
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map Label {
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabbedContent {
|
||||||
|
height: 99%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command_container Horizontal {
|
||||||
|
max-height: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command_container Label {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
TUIddrescue.running #command_container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command_log {
|
||||||
|
overflow: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
TUIddrescue.running #command_log {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsibleTitle {
|
||||||
|
background: black 0%;
|
||||||
|
}
|
@ -0,0 +1,202 @@
|
|||||||
|
# Standard modules
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
from argparse import Namespace
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from queue import Queue, Empty
|
||||||
|
|
||||||
|
# Pip modules
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Header, Footer, TabbedContent, TabPane, Label, Input, Button, Collapsible, DirectoryTree, Log
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container, Horizontal
|
||||||
|
|
||||||
|
# Custom modules
|
||||||
|
from .ddrescue_tui_helper import LEGEND
|
||||||
|
from .ddrescue_tui_widgets import PixelWidget, StatusWidget
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_output(out, queue):
|
||||||
|
for line in iter(out.readline, b''):
|
||||||
|
queue.put(line)
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TUIddrescue(App):
|
||||||
|
CSS_PATH = "ddrescue_tui.tcss"
|
||||||
|
BINDINGS = [
|
||||||
|
Binding(key="q", action="exit", description="Exit"),
|
||||||
|
Binding(key="+", action="interval(2)", description="Change Interval", key_display="+/-"),
|
||||||
|
Binding(key="-", action="interval(0.5)", show=False),
|
||||||
|
Binding(key="d", action="toggle_dark", description="Toggle dark mode"),
|
||||||
|
Binding(key="c", action="cancel", description="Cancel ddrescue", show=False)
|
||||||
|
]
|
||||||
|
selected_input: Path | None = None
|
||||||
|
selected_output: Path | None = None
|
||||||
|
output_name: str | None = None
|
||||||
|
custom_args: str | None = None
|
||||||
|
ddrescue_process = None
|
||||||
|
|
||||||
|
def __init__(self, args: Namespace):
|
||||||
|
self.args = args
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Create child widgets for the application."""
|
||||||
|
yield Header()
|
||||||
|
with TabbedContent(initial="map"):
|
||||||
|
with TabPane("Map", id="map"):
|
||||||
|
yield PixelWidget(self.args)
|
||||||
|
yield Label(LEGEND)
|
||||||
|
with TabPane("ddrescue", id="output"):
|
||||||
|
yield Container(
|
||||||
|
Horizontal(
|
||||||
|
Container(
|
||||||
|
Label("Select input:"),
|
||||||
|
DirectoryTree("/", id="input_path"),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
Label("Select output file or folder:"),
|
||||||
|
DirectoryTree("/", id="output_path")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Collapsible(
|
||||||
|
Label("If an output name is specified, "
|
||||||
|
"the output file will be created in the selected directory or,"
|
||||||
|
" in case of a selected file, in the same directory. "
|
||||||
|
"A mapfile is always created as '<output name>.map'.", ),
|
||||||
|
title="Info",
|
||||||
|
collapsed_symbol="ⓘ >", expanded_symbol="ⓘ v"
|
||||||
|
),
|
||||||
|
Input(placeholder="Output name (optional)", id="output_name"),
|
||||||
|
Input(placeholder="Custom arguments (optional)", id="custom_args"),
|
||||||
|
Button("Run ddrescue", id="submit"),
|
||||||
|
id="command_container"
|
||||||
|
)
|
||||||
|
yield Log(id="command_log", highlight=True)
|
||||||
|
yield StatusWidget()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Titles for the header bar"""
|
||||||
|
self.title = "ddrescue-tui"
|
||||||
|
self.sub_title = "a pretty terminal user interface for ddrescue"
|
||||||
|
if not self.args.sudo:
|
||||||
|
self.query_one(TabbedContent).hide_tab("output")
|
||||||
|
if self.args.action == "run":
|
||||||
|
self.call_after_refresh(
|
||||||
|
self.run_ddrescue,
|
||||||
|
Path(self.args.input),
|
||||||
|
Path(self.args.output),
|
||||||
|
self.args.output_name,
|
||||||
|
self.args.arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_toggle_dark(self) -> None:
|
||||||
|
"""An action to toggle dark mode."""
|
||||||
|
self.dark = not self.dark
|
||||||
|
|
||||||
|
def action_exit(self) -> None:
|
||||||
|
"""Exit the application."""
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
def action_interval(self, factor: float) -> None:
|
||||||
|
"""Increase the interval of the image reload"""
|
||||||
|
pw = self.query_one(PixelWidget)
|
||||||
|
pw.reload_timer(pw.interval * factor)
|
||||||
|
logging.debug(f"interval: {pw.interval}")
|
||||||
|
self.query_one(StatusWidget).interval = pw.interval
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Cancel the current ddrescue"""
|
||||||
|
self.remove_class("running")
|
||||||
|
self.bind("c", "cancel", description="Cancel ddrescue", show=False)
|
||||||
|
self.query_one(Footer).refresh()
|
||||||
|
|
||||||
|
def on_pixel_widget_info(self, msg: PixelWidget.Info) -> None:
|
||||||
|
"""Update the status bar information on "Info" message"""
|
||||||
|
status = self.query_one(StatusWidget)
|
||||||
|
status.version = msg.info.get("version") or ""
|
||||||
|
status.action = msg.info.get("action") or "Running..."
|
||||||
|
self.query_one(Log).clear()
|
||||||
|
self.query_one(Log).write(msg.info.get("command"))
|
||||||
|
|
||||||
|
def on_directory_tree_directory_selected(self, evt: DirectoryTree.DirectorySelected | DirectoryTree.FileSelected):
|
||||||
|
"""Tree item selected in command container"""
|
||||||
|
match evt.control.id:
|
||||||
|
case "input_path":
|
||||||
|
self.selected_input = evt.path
|
||||||
|
case "output_path":
|
||||||
|
self.selected_output = evt.path
|
||||||
|
|
||||||
|
def on_directory_tree_file_selected(self, evt: DirectoryTree.FileSelected):
|
||||||
|
"""Directory tree item selected in command container -> forward to general method"""
|
||||||
|
self.on_directory_tree_directory_selected(evt)
|
||||||
|
|
||||||
|
def on_input_changed(self, evt: Input.Changed) -> None:
|
||||||
|
"""Input changed in command_container for output_name or custom_args -> Update variable"""
|
||||||
|
match evt.control.id:
|
||||||
|
case "output_name":
|
||||||
|
self.output_name = evt.value
|
||||||
|
case "custom_args":
|
||||||
|
self.custom_args = evt.value
|
||||||
|
|
||||||
|
def on_button_pressed(self, evt: Button.Pressed) -> None:
|
||||||
|
"""Try to run ddrescue"""
|
||||||
|
if not self.selected_input or not self.selected_output:
|
||||||
|
# No input or output selected
|
||||||
|
self.notify("Input and output are required", title="Could not run ddrescue", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.run_ddrescue(self.selected_input, self.selected_output, self.output_name or '', self.custom_args or '')
|
||||||
|
|
||||||
|
async def run_ddrescue(self, input_path: Path, output_path: Path, output_name: str, custom_args: str) -> None:
|
||||||
|
"""Run ddrescue either from command_container or directly from cli arguments"""
|
||||||
|
# Combine arguments
|
||||||
|
if output_name:
|
||||||
|
if output_path.is_dir():
|
||||||
|
output_path = output_path / output_name
|
||||||
|
else:
|
||||||
|
output_path = output_path.parent / output_name
|
||||||
|
elif output_path.is_dir():
|
||||||
|
output_path = output_path / f"{input_path.name}.img"
|
||||||
|
map_path = output_path.parent / f"{output_path.stem}.map"
|
||||||
|
cmd = f"ddrescue --mapfile-interval 1 {custom_args or ''} {input_path} {output_path} {map_path}"
|
||||||
|
|
||||||
|
# UI changes
|
||||||
|
self.notify(f"Command: {cmd}", title="Starting ddrescue", severity="information")
|
||||||
|
self.add_class("running", update=True)
|
||||||
|
self.bind("c", "cancel", description="Cancel ddrescue", show=True)
|
||||||
|
self.query_one(Footer).refresh()
|
||||||
|
log = self.query_one(Log)
|
||||||
|
log.clear()
|
||||||
|
log.write_line(f"$ {cmd}")
|
||||||
|
pixel = self.query_one(PixelWidget)
|
||||||
|
pixel.mapfile = map_path
|
||||||
|
pixel.reload_timer(pixel.interval)
|
||||||
|
|
||||||
|
# Run combined command
|
||||||
|
# asyncio.create_task(self.start_ddrescue_async(shlex.split(cmd), log, map_path))
|
||||||
|
proc = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, close_fds=True)
|
||||||
|
q = Queue()
|
||||||
|
t = Thread(target=enqueue_output, args=(proc.stdout, q), daemon=True)
|
||||||
|
t.start()
|
||||||
|
while proc.poll() is not None:
|
||||||
|
try:
|
||||||
|
if line := q.get_nowait():
|
||||||
|
# Count and remove ANSI line up codes
|
||||||
|
line_clean, up_count = re.subn(r"\x1b\[A", "", line.decode())
|
||||||
|
if up_count:
|
||||||
|
old_lines = list(log.lines[:-up_count])
|
||||||
|
old_lines.append(line_clean)
|
||||||
|
log.clear()
|
||||||
|
log.write_lines(old_lines)
|
||||||
|
else:
|
||||||
|
log.write_line(line_clean)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
except Empty:
|
||||||
|
continue
|
@ -0,0 +1,6 @@
|
|||||||
|
LEGEND = ("[grey78]█[/grey78] non-tried "
|
||||||
|
"[green1]█[/green1] rescued "
|
||||||
|
"[yellow1]█[/yellow1] non-trimmed "
|
||||||
|
"[blue1]█[/blue1] non-scraped "
|
||||||
|
"[red1]█[/red1] bad-sectors "
|
||||||
|
"[grey0]█[/grey0] current sector")
|
@ -0,0 +1,36 @@
|
|||||||
|
# Standard modules
|
||||||
|
from time import sleep
|
||||||
|
from pathlib import Path
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
# pip modules
|
||||||
|
from rich_pixels import Pixels
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
# Custom modules
|
||||||
|
from .ddrescue_tui_parser import parse_mapfile
|
||||||
|
from .ddrescue_tui_plotter import heatmap_image
|
||||||
|
from .ddrescue_tui_helper import LEGEND
|
||||||
|
|
||||||
|
|
||||||
|
def run_noninteractive(args: Namespace) -> None:
|
||||||
|
"""Run the application in case the calling terminal is not interactive"""
|
||||||
|
c = Console(force_terminal=True, force_interactive=False)
|
||||||
|
c.clear()
|
||||||
|
|
||||||
|
# Parse mapfile
|
||||||
|
data_arr, data_general = parse_mapfile(Path(args.mapfile), c.width, 10)
|
||||||
|
|
||||||
|
# Mapfile invalid or inexistant
|
||||||
|
if data_arr is None:
|
||||||
|
exit("Mapfile invalid/inexistant")
|
||||||
|
|
||||||
|
# Create and display plot
|
||||||
|
image = heatmap_image(data_arr, c.width, 10)
|
||||||
|
|
||||||
|
c.print(Pixels.from_segments(Pixels._segments_from_image(image, (c.width, 10))))
|
||||||
|
c.print(LEGEND)
|
||||||
|
c.print(f"{data_general.get('version')} | Reload interval: {args.interval}s | {data_general.get('action') or 'Running...'}")
|
||||||
|
c.print("-" * c.width)
|
||||||
|
sleep(args.interval)
|
||||||
|
run_noninteractive(args)
|
@ -0,0 +1,128 @@
|
|||||||
|
# Standard modules
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Pip modules
|
||||||
|
from numpy import array, ndarray
|
||||||
|
|
||||||
|
|
||||||
|
BLOCK_STATUS = [
|
||||||
|
"?", # 0 -> non_tried
|
||||||
|
"+", # 1 -> rescued
|
||||||
|
"*", # 2 -> non_trimmed
|
||||||
|
"/", # 3 -> non_scraped
|
||||||
|
"-" # 4 -> bad_sectors
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
RUN_STATUS = {
|
||||||
|
"?": "copying non-tried blocks",
|
||||||
|
"*": "trimming non-trimmed blocks",
|
||||||
|
"/": "scraping non-scraped blocks",
|
||||||
|
"-": "retrying bad sectors",
|
||||||
|
"F": "filling the blocks specified",
|
||||||
|
"G": "generating approximate mapfile",
|
||||||
|
"+": "finished"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MapEntry:
|
||||||
|
def __init__(self, line_list):
|
||||||
|
self.position = int(line_list[0], 0)
|
||||||
|
self.size = int(line_list[1], 0)
|
||||||
|
self.status = BLOCK_STATUS.index(line_list[2])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mapfile(file_path: Path, res_x: int, res_y: int) -> tuple[ndarray | None, dict]:
|
||||||
|
# Not a (valid) file
|
||||||
|
if not file_path or not isinstance(file_path, Path) or not file_path.exists():
|
||||||
|
return None, dict({"msg": f"Invalid file path {file_path}"})
|
||||||
|
# Array must be 2x2 or larger
|
||||||
|
if not isinstance(res_x, int) or res_x < 2 or not isinstance(res_y, int) or res_y < 2:
|
||||||
|
return None, dict({"msg": f"Invalid resolution {res_x}x{res_y}"})
|
||||||
|
|
||||||
|
map_full_size = []
|
||||||
|
general = dict.fromkeys(["version", "command", "direction", "action"], "")
|
||||||
|
|
||||||
|
with open(file_path) as mapfile:
|
||||||
|
is_first_line = True
|
||||||
|
is_first_dataline = True
|
||||||
|
for line in mapfile:
|
||||||
|
line = line.strip()
|
||||||
|
if is_first_line:
|
||||||
|
is_first_line = False
|
||||||
|
|
||||||
|
# Not a mapfile
|
||||||
|
if not line.startswith("# Mapfile. Created by"):
|
||||||
|
return None, dict({"msg": f"Not a mapfile: {file_path}"})
|
||||||
|
|
||||||
|
if line.startswith("# "):
|
||||||
|
# heading comments
|
||||||
|
if "forwards" in line:
|
||||||
|
general["direction"] = "forwards"
|
||||||
|
if "backwards" in line:
|
||||||
|
general["direction"] = "backwards"
|
||||||
|
if "Created by" in line:
|
||||||
|
general["version"] = line.split("Created by ")[1]
|
||||||
|
if "Command line" in line:
|
||||||
|
general["command"] = line.split("Command line: ")[1]
|
||||||
|
else:
|
||||||
|
line = re.sub(' +', ' ', line).split(" ")
|
||||||
|
if is_first_dataline:
|
||||||
|
is_first_dataline = False
|
||||||
|
general.update({
|
||||||
|
"cur_pos": int(line[0], 0),
|
||||||
|
"cur_status": RUN_STATUS[line[1]],
|
||||||
|
"cur_pass": int(line[2])
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
map_full_size.append(MapEntry(line))
|
||||||
|
|
||||||
|
# Action line
|
||||||
|
general["action"] = (f"{general['cur_status']}... Pass {general['cur_pass']}" +
|
||||||
|
(f" ({general['direction']})" if general["direction"] else ""))
|
||||||
|
|
||||||
|
# After all lines are read, take last block and calculate divisor to reach targeted size
|
||||||
|
map_full_size.sort(key=lambda x: x.position)
|
||||||
|
res_len = (res_x * res_y)
|
||||||
|
divisor = int((map_full_size[-1].position + map_full_size[-1].size) / res_len)
|
||||||
|
|
||||||
|
map_plot_size = []
|
||||||
|
size_0 = []
|
||||||
|
for res_entry in map_full_size:
|
||||||
|
size = int(res_entry.size / divisor)
|
||||||
|
status = res_entry.status
|
||||||
|
|
||||||
|
# Merge all size 0 to one entry
|
||||||
|
if not size:
|
||||||
|
size_0.append(status)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add combined entry
|
||||||
|
if size_0:
|
||||||
|
size_0.append(status)
|
||||||
|
map_plot_size.extend([max(size_0)] * len(size_0))
|
||||||
|
size_0 = []
|
||||||
|
size -= 1
|
||||||
|
if not size:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add normal entry
|
||||||
|
map_plot_size.extend([status] * size)
|
||||||
|
|
||||||
|
# if current position exists, mark it with black
|
||||||
|
try:
|
||||||
|
map_plot_size[int(general["cur_pos"] / divisor)] = 5
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If block are missing or too many due to rounding errors, fill up with transparent
|
||||||
|
while len(map_plot_size) < res_len:
|
||||||
|
map_plot_size.append(6)
|
||||||
|
while len(map_plot_size) > res_len:
|
||||||
|
map_plot_size.pop()
|
||||||
|
|
||||||
|
# Reshape 1d-array to 2d-array
|
||||||
|
np_array_2d = array(map_plot_size).reshape((res_y, res_x))
|
||||||
|
|
||||||
|
return np_array_2d, general
|
@ -0,0 +1,45 @@
|
|||||||
|
from matplotlib import pyplot
|
||||||
|
from numpy.core import multiarray
|
||||||
|
from matplotlib.colors import ListedColormap
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# distinct heatmap colors
|
||||||
|
COLORS = [
|
||||||
|
(0.8, 0.8, 0.8, 1.0), # 0, grey, non-tried
|
||||||
|
(0.0, 1.0, 0.0, 1.0), # 1, green, rescued
|
||||||
|
(1.0, 1.0, 0.0, 1.0), # 2, yellow, non-trimmed
|
||||||
|
(0.0, 0.0, 1.0, 1.0), # 3, blue, non-scraped
|
||||||
|
(1.0, 0.0, 0.0, 1.0), # 4, red, bad-sectors
|
||||||
|
(0.0, 0.0, 0.0, 1.0), # 5, black, current-sector
|
||||||
|
(0.0, 0.0, 0.0, 0.0) # 6, no black, placeholder
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def heatmap_image(data: multiarray, x=120, y=30):
|
||||||
|
# figsize is inch, x/y are characters; so resizing is necessary anyway
|
||||||
|
fig, ax = pyplot.subplots(figsize=(int(x/10), int(y/10)))
|
||||||
|
# No axes/border
|
||||||
|
ax.axis('off')
|
||||||
|
# Heatmap
|
||||||
|
pyplot.imshow(data, aspect='auto', origin='upper', cmap=ListedColormap(COLORS), vmin=-0.5, vmax=6.5)
|
||||||
|
# No ticks
|
||||||
|
pyplot.xticks([])
|
||||||
|
pyplot.yticks([])
|
||||||
|
pyplot.tick_params(left=False, bottom=False)
|
||||||
|
# No padding around plot
|
||||||
|
fig.tight_layout(pad=0)
|
||||||
|
# Transparent background
|
||||||
|
fig.set_facecolor((1, 1, 1, 0))
|
||||||
|
# Draw so the plot is in memory
|
||||||
|
fig.canvas.draw()
|
||||||
|
|
||||||
|
# Convert memory to PIL image (see https://stackoverflow.com/questions/74150693/matplotlib-convert-canvas-to-rgba)
|
||||||
|
img = Image.frombytes(
|
||||||
|
'RGBA',
|
||||||
|
fig.canvas.get_width_height(),
|
||||||
|
bytes(fig.canvas.get_renderer().buffer_rgba())
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close so the memory does not fill up
|
||||||
|
pyplot.close()
|
||||||
|
return img
|
@ -0,0 +1,96 @@
|
|||||||
|
# Standard modules
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
# Pip modules
|
||||||
|
from textual import events, widget, reactive, message
|
||||||
|
from textual.geometry import Size
|
||||||
|
from textual.widgets import Static
|
||||||
|
from rich_pixels import Pixels
|
||||||
|
|
||||||
|
# Custom modules
|
||||||
|
from .ddrescue_tui_parser import parse_mapfile
|
||||||
|
from .ddrescue_tui_plotter import heatmap_image
|
||||||
|
|
||||||
|
|
||||||
|
class StatusWidget(widget.Widget):
|
||||||
|
"""This widget shows the status bar at the bottom of the screen."""
|
||||||
|
interval: reactive.reactive[float] = reactive.reactive(32)
|
||||||
|
version: reactive.reactive[str] = reactive.reactive("")
|
||||||
|
action: reactive.reactive[str] = reactive.reactive("")
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
return f"{self.version} | Reload interval: {self.interval}s | {self.action}"
|
||||||
|
|
||||||
|
|
||||||
|
class PixelWidget(Static):
|
||||||
|
"""This widget displays the parsed and plotted mapfile"""
|
||||||
|
class Info(message.Message):
|
||||||
|
"""This message is sent with basic information for every mapfile update"""
|
||||||
|
def __init__(self, info: dict) -> None:
|
||||||
|
self.info = info
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
class NoMapfile(message.Message):
|
||||||
|
"""This message is sent when no (valid) mapfile is set"""
|
||||||
|
def __init__(self, last_mapfile: Path) -> None:
|
||||||
|
self.last_mapfile = last_mapfile
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
interval: float = None
|
||||||
|
timer: events.Timer = None
|
||||||
|
mapfile: Path | None
|
||||||
|
width, height = 90, 30
|
||||||
|
|
||||||
|
def __init__(self, args: Namespace) -> None:
|
||||||
|
self.args = args
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def _on_mount(self, _) -> None:
|
||||||
|
"""Initial setup mapfile, image and timer"""
|
||||||
|
self.mapfile = Path(self.args.mapfile) if hasattr(self.args, "mapfile") and self.args.mapfile else None
|
||||||
|
self.loading = True
|
||||||
|
self.load_image()
|
||||||
|
# Wait a little bit until parent container is initialized
|
||||||
|
self.set_timer(0.1, lambda: self.reload_timer(self.args.interval))
|
||||||
|
|
||||||
|
def get_content_width(self, container: Size, viewport: Size) -> int:
|
||||||
|
"""Force custom size from the beginning"""
|
||||||
|
return self.width
|
||||||
|
|
||||||
|
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
||||||
|
"""Force custom size from the beginning"""
|
||||||
|
return self.height
|
||||||
|
|
||||||
|
def reload_timer(self, new_interval: float) -> None:
|
||||||
|
"""(Re-)Sets the timer to a new interval"""
|
||||||
|
if self.timer:
|
||||||
|
self.timer.stop()
|
||||||
|
self.load_image()
|
||||||
|
self.interval = new_interval
|
||||||
|
self.timer = self.set_interval(self.interval, self.load_image)
|
||||||
|
|
||||||
|
def load_image(self) -> None:
|
||||||
|
"""Tries to parse the mapfile to update the image"""
|
||||||
|
# Get size from parent container if already initialized
|
||||||
|
if self.container_size.width != 0 and self.container_size.height != 0:
|
||||||
|
logging.debug(f"x {self.container_size.width}/y {self.container_size.height}")
|
||||||
|
self.width = max(int(self.container_size.width / 2), 2)
|
||||||
|
self.height = max(int(self.container_size.height), 2)
|
||||||
|
|
||||||
|
# Parse mapfile
|
||||||
|
data_arr, data_general = parse_mapfile(self.mapfile, self.width, self.height)
|
||||||
|
|
||||||
|
# Mapfile invalid or inexistant
|
||||||
|
if data_arr is None:
|
||||||
|
old_mapfile = self.mapfile
|
||||||
|
self.notify(f"Mapfile {self.mapfile} invalid")
|
||||||
|
self.post_message(self.NoMapfile(old_mapfile))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create and display plot and send message about general data
|
||||||
|
self.post_message(self.Info(data_general))
|
||||||
|
image = heatmap_image(data_arr, self.width, self.height)
|
||||||
|
self.update(Pixels.from_segments(Pixels._segments_from_image(image, (self.width, self.height))))
|
||||||
|
self.loading = False
|
@ -0,0 +1 @@
|
|||||||
|
import ddrescue_parser_test
|
@ -0,0 +1,72 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from numpy import ndarray
|
||||||
|
from src.ddrescue_tui.ddrescue_tui_parser import parse_mapfile
|
||||||
|
|
||||||
|
|
||||||
|
# ######### #
|
||||||
|
# UNIT TEST #
|
||||||
|
# ######### #
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
class TestParseMapfile(unittest.TestCase):
|
||||||
|
MAP_A = Path("./mapfiles/2024-02-08_virt.map")
|
||||||
|
X_A, Y_A = 90, 30
|
||||||
|
MAP_B = Path("./mapfiles/2024-02-10_virt.map")
|
||||||
|
X_B, Y_B = 45, 30
|
||||||
|
MAP_C = Path("./mapfiles/running.map")
|
||||||
|
X_C, Y_C = 92, 42
|
||||||
|
MAP_INEXISTANT = Path("./mapfiles/inexistant.map")
|
||||||
|
MAP_INVALID = Path("./LICENSE")
|
||||||
|
|
||||||
|
def test_inexistant_mapfile(self):
|
||||||
|
"""This file does not exist"""
|
||||||
|
self.assertEqual(parse_mapfile(self.MAP_INEXISTANT, 90, 30)[0], None)
|
||||||
|
|
||||||
|
def test_invalid_argument(self):
|
||||||
|
"""Handle bad arguments"""
|
||||||
|
self.assertEqual(parse_mapfile("./mapfiles/2024-02-08_virt.map", 90, 30)[0], None)
|
||||||
|
self.assertEqual(parse_mapfile(self.MAP_A, True, False)[0], None)
|
||||||
|
self.assertEqual(parse_mapfile(None, None, None)[0], None)
|
||||||
|
|
||||||
|
def test_invalid_mapfile(self):
|
||||||
|
"""This file is not a mapfile"""
|
||||||
|
self.assertEqual(parse_mapfile(self.MAP_INVALID, 90, 30)[0], None)
|
||||||
|
|
||||||
|
def test_invalid_arraysize(self):
|
||||||
|
"""Arrays cannot be smaller than 2x2 or numpy throws an error"""
|
||||||
|
self.assertEqual(parse_mapfile(self.MAP_A, 1, 1)[0], None)
|
||||||
|
|
||||||
|
def test_mapfile_A(self):
|
||||||
|
arr, general = parse_mapfile(self.MAP_A, self.X_A, self.Y_A)
|
||||||
|
self.assertIsInstance(general, dict)
|
||||||
|
self.assertEqual(general["command"], 'ddrescue /dev/sdb 2024-02-08_virt.img 2024-02-08_virt.map')
|
||||||
|
self.assertEqual(general["version"], "GNU ddrescue version 1.27")
|
||||||
|
self.assertEqual(general["cur_pass"], 1)
|
||||||
|
self.assertEqual(general["direction"], 'forwards')
|
||||||
|
self.assertIsInstance(arr, ndarray)
|
||||||
|
self.assertEqual(arr.max(), 5)
|
||||||
|
self.assertEqual(arr.size, self.X_A*self.Y_A)
|
||||||
|
self.assertEqual(arr.shape, (self.Y_A, self.X_A))
|
||||||
|
|
||||||
|
def test_mapfile_B(self):
|
||||||
|
arr, general = parse_mapfile(self.MAP_B, self.X_B, self.Y_B)
|
||||||
|
self.assertIsInstance(general, dict)
|
||||||
|
self.assertIn("2024-02-10_virt.img 2024-02-10_virt.map", general["command"])
|
||||||
|
self.assertIsInstance(arr, ndarray)
|
||||||
|
self.assertEqual(arr.max(), 6)
|
||||||
|
self.assertEqual(arr.size, self.X_B*self.Y_B)
|
||||||
|
self.assertEqual(arr.shape, (self.Y_B, self.X_B))
|
||||||
|
|
||||||
|
def test_mapfile_C(self):
|
||||||
|
"""Currently running map"""
|
||||||
|
arr, general = parse_mapfile(self.MAP_C, self.X_C, self.Y_C)
|
||||||
|
self.assertIsInstance(general, dict)
|
||||||
|
self.assertEqual(general["direction"], "")
|
||||||
|
self.assertIn("running.img running.map", general["command"])
|
||||||
|
self.assertIsInstance(arr, ndarray)
|
||||||
|
self.assertEqual(arr.size, self.X_C*self.Y_C)
|
||||||
|
self.assertEqual(arr.shape, (self.Y_C, self.X_C))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Check for subcommand
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <build|install>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
build)
|
||||||
|
python3.10 -m pipx run build
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
sudo apt install python3.10 pipx gddrescue
|
||||||
|
python3.10 -m pipx install .
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Invalid subcommand. Usage: $0 <build|install>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
Laden…
In neuem Issue referenzieren