main
Simon Moser vor 3 Wochen
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

6
.gitignore vendored

@ -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
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![software-qa](https://github.com/freiburg-missing-semester-course/project-MrMcX/actions/workflows/qa.yml/badge.svg)](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
```
![grafik](https://github.com/freiburg-missing-semester-course/project-MrMcX/assets/4057839/64295dfa-4825-4667-a19d-98d6bb1cb6e1)
```bash
# ddrescue-tui open mapfiles/2024-01-18_HDD.map
```
![grafik](https://github.com/freiburg-missing-semester-course/project-MrMcX/assets/4057839/c450ef8f-e594-4bf4-969b-2ea7e5b2b54b)
![grafik](https://github.com/freiburg-missing-semester-course/project-MrMcX/assets/4057839/c02b5dec-7eb0-46b1-a6bd-61ece2f03333)

@ -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,64 @@
\subsection{Mapfile structure}\label{subsec:mapfile-structure}
Mapfiles created by \texttt{ddrescue} use a compact text format to store meta information
about the data rescuing process and the information about the rescuing status of blocks\cite{ddrescue}.
A simple exemplary mapfile is shown below.
\begin{tcbcode}{Example mapfile}
# Mapfile. Created by GNU ddrescue version 1.27
# Command line: ddrescue /dev/sdb sdb.img sdb.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 ?
\end{tcbcode}
The mapfile consists of three main parts:
The \textbf{heading comments} comments provide metadata about the mapfile, as the version of ddrescue or ddrescuelog that created it,
the command-line parameters used during the operation and the start time of the program.
If it was created by ddrescue, it also includes the current save time and a copy of the status message from the screen (e.g., copying, trimming, finished).
The first non-comment line, the \textbf{status line}, contains the position being tried in the input file,
a status character indicating the type of operation (e.g., copying, trimming, scraping)
and a positive integer denoting the current pass in the current phase.
The status line helps efficient resumption of copying or retrying phases.
The other non-comment lines each describe a \textbf{data block}, containing the starting position of the block in the input file,
the size of the block in bytes and a status character indicating the blocks state (e.g., copied, trimmed, scraped).
\subsection{Parsing in Python}\label{subsec:parsing-in-python}
The Python function \texttt{parse\_mapfile} implemented in \texttt{ddrescue\_tui\_parser.py} is designed to parse such mapfiles.
It takes as input the path to the mapfile and the desired resolution for the output array.
The function performs several steps of validation and parsing, using the module \texttt{numpy}\cite{numpy}:
\begin{itemize}
\item It checks the validity of the input parameters.
If the file path is not valid or the resolution parameters are not integers greater than 1, it returns with an error message.
\item It opens the mapfile and reads it line by line, distinguishing between comment lines and data lines.
The first line is used to check whether the opened file is actually a mapfile, otherwise the function returns an error message.
\item It parses the comment lines to extract general information about the process, such as the version of ddrescue that created the mapfile,
the command-line parameters used and the direction of the reading process (forwards or backwards).
\item It parses the data lines to create custom \texttt{MapEntry} objects, each representing a block of data.
The \texttt{MapEntry} object has a position, size, and status, which are extracted from the data line.
The status converted to a numerical value, which can later be used for plotting the information.
\item After parsing all lines of the mapfile, the function sorts the list of \texttt{MapEntry} objects by position
to retrieve information about the total size from the last block of data.
It then transforms this list into a 1D numpy array with the length of the desired plot resolution,
with each element representing the status of multiple disk blocks.
As the resolution of the plot is usually several orders of magnitude smaller than the number of blocks on the disk,
some information is lost when the status is compressed in this way.
The highest numerical status value determines which status a compressed block receives;
the values were assigned in such a way that the most interesting information is retained in the author's opinion.
\item Finally, the function reshapes the 1D numpy array into a 2D numpy array based on the provided resolution.
It also updates the current position in the array based on the current position given in the mapfile.
\end{itemize}
The function returns the 2D numpy array and a dictionary containing the general information.
This information will be used to visualize the status of different blocks of data in a disk imaging process
and provide context about the process, as described in the following sections.

@ -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…
Abbrechen
Speichern