Compare commits

..

No commits in common. 'master' and 'v0.3' have entirely different histories.
master ... v0.3

@ -1 +0,0 @@
ko_fi: deadc0de6

@ -1,7 +0,0 @@
coverage:
status:
project:
default:
target: 90%
threshold: 1%
patch: off

@ -1,26 +0,0 @@
name: Release to PyPI
on:
release:
types: [created]
jobs:
pypi_publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Install Tools
run: |
sudo apt update
python -m pip install --upgrade pip
pip install setuptools wheel twine
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Build and Publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*

@ -1,29 +0,0 @@
name: tests
on: [push, pull_request, workflow_dispatch]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r tests-requirements.txt
pip install -r requirements.txt
sudo apt-get -y install shellcheck jq
- name: Run tests
run: |
./tests.sh
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
with:
files: coverage.xml
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

13
.gitignore vendored

@ -1,15 +1,2 @@
*.pyc *.pyc
.coverage .coverage
.coverage*
coverages/
coverage.xml
dist/
build/
*.egg-info/
*.catalog
.vscode/
.mypy_cache
.pytest_cache
__pycache__
.pyre
.pytype

@ -1,5 +0,0 @@
[mypy]
strict = true
disable_error_code = import-untyped,import-not-found
ignore_missing_imports = True
warn_unused_ignores = False

@ -0,0 +1,17 @@
language: python
python:
- "3.3"
- "3.4"
- "3.5"
- "3.6"
- "nightly"
install:
- "pip install pycodestyle"
- "pip install nose"
- "pip install coverage"
- "pip install coveralls"
- "pip install -r requirements.txt"
script:
./tests.sh
after_success:
coveralls

@ -1,26 +1,18 @@
# CATCLI # CATCLI
[![Tests Status](https://github.com/deadc0de6/catcli/workflows/tests/badge.svg?branch=master)](https://github.com/deadc0de6/catcli/actions) [![Build Status](https://travis-ci.org/deadc0de6/catcli.svg?branch=master)](https://travis-ci.org/deadc0de6/catcli)
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](http://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](http://www.gnu.org/licenses/gpl-3.0)
[![Coverage](https://codecov.io/gh/deadc0de6/catcli/graph/badge.svg?token=t5dF7UL7K1)](https://codecov.io/gh/deadc0de6/catcli) [![Coverage Status](https://coveralls.io/repos/github/deadc0de6/catcli/badge.svg?branch=master)](https://coveralls.io/github/deadc0de6/catcli?branch=master)
[![PyPI version](https://badge.fury.io/py/catcli.svg)](https://badge.fury.io/py/catcli) [![PyPI version](https://badge.fury.io/py/catcli.svg)](https://badge.fury.io/py/catcli)
[![AUR](https://img.shields.io/aur/version/catcli-git.svg)](https://aur.archlinux.org/packages/catcli-git)
[![Python](https://img.shields.io/pypi/pyversions/catcli.svg)](https://pypi.python.org/pypi/catcli) [![Python](https://img.shields.io/pypi/pyversions/catcli.svg)](https://pypi.python.org/pypi/catcli)
[![Donate](https://img.shields.io/badge/donate-KoFi-blue.svg)](https://ko-fi.com/deadc0de6)
*The command line catalog tool for your offline data* *The command line catalog tool for your offline data*
> [!WARNING]
> catcli has been superseded by [gocatcli](https://github.com/deadc0de6/gocatcli/)
> which provides all features of catcli and more...
Did you ever wanted to find back that specific file that should be on one of your Did you ever wanted to find back that specific file that should be on one of your
backup DVDs or one of your external hard drives? You usually go through all backup DVDs or one of your external hard drives ? You usually go through all
of them hoping to find the right one on the first try? of them hoping to find the right one on the first try ?
[Catcli](https://github.com/deadc0de6/catcli) indexes external media Well [catcli](https://github.com/deadc0de6/catcli) indexes external media
in a catalog file and allows to quickly find specific files or even navigate in the in a catalog and allows to quickly find specific files or even navigate in the
catalog of indexed files while these are not connected to your host. catalog of indexed files while these are not connected to your host.
Features: Features:
@ -28,16 +20,11 @@ Features:
* Index any directories in a catalog * Index any directories in a catalog
* Ability to search for files by name in the catalog * Ability to search for files by name in the catalog
* Ability to navigate through indexed data à la `ls` * Ability to navigate through indexed data à la `ls`
* Support for fuse to mount the indexed data as a virtual filesystem
* Handle archive files (zip, tar, ...) and index their content
* Save catalog to json for easy versioning with git * Save catalog to json for easy versioning with git
* Command line interface FTW * Command line interface FTW
* Store files and directories sizes * Store files and folders sizes
* Store md5 hash of files * Store md5 hash of files
* Ability to update the catalog
* Support for `fzf` for finding files
* Tag your different storages with additional information
* Export catalog to CSV
<a href="https://asciinema.org/a/hRE22qbVtBGxOM1yxw2y4fBy8"><img src="https://asciinema.org/a/hRE22qbVtBGxOM1yxw2y4fBy8.png" width="50%" height="50%"></a> <a href="https://asciinema.org/a/hRE22qbVtBGxOM1yxw2y4fBy8"><img src="https://asciinema.org/a/hRE22qbVtBGxOM1yxw2y4fBy8.png" width="50%" height="50%"></a>
@ -45,29 +32,27 @@ Quick start:
```bash ```bash
# install catcli with pip # install catcli with pip
pip3 install catcli --user sudo pip3 install catcli
# index a directory in the catalog # index a directory in the catalog
catcli index --meta='some description' log /var/log catcli index -u --meta='some description' log /var/log
# display the content # display the content
catcli ls -r catcli tree
# navigate # navigate
catcli ls log catcli ls log
# find files/directories named '*log*' # find files/folders named '*log*'
catcli find log catcli find log
# show directories sizes
catcli du log
``` ```
see [usage](#usage) for specific info see [usage](#usage) for specific info
## Why catcli? ## Why catcli ?
[Catcli](https://github.com/deadc0de6/catcli) gives the ability to navigate, [Catcli](https://github.com/deadc0de6/catcli) gives the ability to navigate,
explore and find your files that are stored on external media explore and find your files that are stored on external media
(DVDs, hard drives, USB sticks, etc) when those are not connected. (DVDs, hard drives, USB sticks, etc) when those are not connected.
Catcli can just as easily index any arbitrary directories. Catcli can just as easily index any arbitrary directories.
See the [examples](#examples) for an overview of the available features. See the [example](#example) for an overview of the available features.
--- ---
@ -77,26 +62,19 @@ See the [examples](#examples) for an overview of the available features.
* [Usage](#usage) * [Usage](#usage)
* [Index data](#index-data) * [Index data](#index-data)
* [Index archive files](#index-archive-files)
* [Walk indexed files with ls](#walk-indexed-files-with-ls) * [Walk indexed files with ls](#walk-indexed-files-with-ls)
* [Find files](#find-files) * [Find files](#find-files)
* [Mount catalog](#mount-catalog) * [Display entire tree](#display-entire-tree)
* [Display entire hierarchy](#display-entire-hierarchy)
* [Disk usage](#disk-usage)
* [Catalog graph](#catalog-graph) * [Catalog graph](#catalog-graph)
* [Edit storage](#edit-storage)
* [Update catalog](#update-catalog)
* [CSV format](#csv-format)
* [Examples](#examples) * [Example](#example)
* [Contribution](#contribution) * [Contribution](#contribution)
* [Thank you](#thank-you)
# Installation # Installation
Install from Pypi To install run:
```bash ```bash
$ pip3 install catcli --user $ sudo pip3 install catcli
``` ```
Or from github directly Or from github directly
@ -109,7 +87,7 @@ $ catcli --help
To work with catcli without installing it, you can do the following To work with catcli without installing it, you can do the following
```bash ```bash
$ cd /tmp; git clone https://github.com/deadc0de6/catcli && cd catcli $ cd /tmp; git clone https://github.com/deadc0de6/catcli && cd catcli
$ pip3 install -r requirements.txt --user $ sudo pip3 install -r requirements.txt
$ python3 -m catcli.catcli --help $ python3 -m catcli.catcli --help
``` ```
@ -122,28 +100,18 @@ $ python setup.py install
$ catcli --help $ catcli --help
``` ```
Catcli is also available on aur: https://aur.archlinux.org/packages/catcli-git/
# Usage # Usage
Each indexed directory is stored in the catalog. Multiple directories can be indexed Each indexed directory is stored in the catalog. Multiple directories can be indexed
and they are all available through the command line interface of catcli. and they are all available through the command line interface of catcli.
Five different types of entry are present in a catalog: Four different types of entry are present in a catalog:
* **top node**: this is the root of the hierarchy
* **storage node**: this represents an indexed storage (a DVD, an external
hard drive, an USB drive, some arbitrary directory, etc).
* **dir node**: this is a directory
* **file node**: this is a file
* **archive node**: this is a file contained in an archive (tar, zip, etc)
Following environment variables are supported: * *top node*: this is the root of the tree
* *storage node*: this represents some indexed storage (a DVD, an external
* `CATCLI_CATALOG_PATH`: define the catalog path (`--catalog=<path>`) hard drive, an USB drive, some arbitrary directory, ...)
* `CATCLI_NO_BANNER`: disable the banner (`--no-banner`) * *dir node*: this is a directory
* `CATCLI_VERBOSE`: enable verbose mode (`--verbose`) * *file node*: this is a file
* `CATCLI_FORMAT`: define the output format (`-F --format=<fmt>`)
## Index data ## Index data
@ -153,7 +121,7 @@ will index the entire directory `/media/mnt`
and store that in your catalog under the name `<short-name>`. and store that in your catalog under the name `<short-name>`.
```bash ```bash
$ catcli index --meta=<some-description> <short-name> /media/mnt $ catcli index --meta=<some-description> -u <short-name> /media/mnt
``` ```
If not specified otherwise (with the switch `--catalog`), the catalog is saved in the current If not specified otherwise (with the switch `--catalog`), the catalog is saved in the current
@ -161,75 +129,33 @@ directory under `catcli.catalog`.
The `--meta` switch allows to add any additional information to store along in The `--meta` switch allows to add any additional information to store along in
the catalog like for example `the blue disk in my office`. the catalog like for example `the blue disk in my office`.
The `-u` switch tells catcli to also store (and calculate) the total size
Using the `-a --archive` switch allows to also index archive files as explained of each directory.
[below](#index-archive-files).
## Index archive files
Catcli is able to index and explore the content of archive files.
Following archive formats are supported: *tar*, *tar.gz*, *tar.xz*, *lzma*, *tar.bz2*, *zip*.
Catcli is also able to find files within indexed archive files.
See the [archive example](#archive-example) for more.
## Walk indexed files with ls ## Walk indexed files with ls
A catalog can be walked using the command `ls` as if the media A catalog can be walked using the command `ls` as if the media
is mounted (File/directories separator is `/`). was mounted.
File/folder separator is `/`
```bash ```bash
$ catcli ls tmp/a/b/c $ catcli ls tmp/a/b/c
``` ```
Resulting files can be sorted by size using `-S --sortsize`. See the [example](#example) for more.
See the [examples](#examples) for more.
## Find files ## Find files
Files and directories can be found based on their names Files and directories can be found based on their names
using the `find` command. using the `find` command.
`Find` support two formats that allow to use `fzf` for See the [example](#example) for more.
searching:
* `--format=fzf-native`: display the result in native format
* `--format=fzf-csv`: display the result in csv
See the [examples](#examples) for more. ## Display entire tree
## Mount catalog The entire catalog can be shown using the `tree` command.
The catalog can be mounted with [fuse](https://www.kernel.org/doc/html/next/filesystems/fuse.html) See the [example](#example) for more.
and navigate like any filesystem.
```bash
$ mkdir /tmp/mnt
$ catcli index -c github .github
$ catcli mount /tmp/mnt
$ ls -laR /tmp/mnt
drwxrwxrwx - user 8 Mar 22:08 github
mnt/github:
.rwxrwxrwx 17 user 19 Oct 2022 FUNDING.yml
drwxrwxrwx - user 2 Mar 10:15 workflows
mnt/github/workflows:
.rwxrwxrwx 691 user 19 Oct 2022 pypi-release.yml
.rwxrwxrwx 635 user 8 Mar 21:08 testing.yml
```
## Display entire hierarchy
The entire catalog can be shown using the `ls -r` command.
Resulting files can be sorted by size using the `-S --sortsize` switch.
See the [examples](#examples) for more.
## Disk usage
You can get the disk usage with the `du` command.
Resulting files can be sorted by size using the `-S --sortsize` switch.
## Catalog graph ## Catalog graph
@ -243,199 +169,86 @@ create graph with "dot /tmp/catcli.dot -T png -o /tmp/tree.png" (you need graphv
$ dot /tmp/catcli.dot -T png -o /tmp/tree.png $ dot /tmp/catcli.dot -T png -o /tmp/tree.png
``` ```
## Edit storage # Example
Storage entry can be edited with following catcli commands:
* `rename` - rename the storage
* `edit` - edit storage metadata
## Update catalog
The catalog can be updated with the `update` command.
Updates are based on the access time of each of the files and on the
hash checksum if present (catalog was indexed with `-c --hash` and
`update` is called with the switch `-c --hash`).
## CSV format
Results can be printed to CSV using `--format=csv`.
Fields are separated by a comma (`,`) and are quoted with double quotes (`"`).
Each line contains the following fields:
* **name**: the entry name
* **type**: the entry type (file, directory, storage, etc)
* **path**: the entry path
* **size**: the entry size
* **indexed_at**: when this entry was indexed
* **maccess**: the entry modification date/time
* **md5**: the entry checksum (if any)
* **nbfiles**: the number of children (empty for nodes that are not storage or directory)
* **free_space**: free space (empty for not storage nodes)
* **total_space**: total space (empty for not storage nodes)
* **meta**: meta information (empty for not storage nodes)
# Examples
## Simple example
Let's first create some files and directories: Let's first create some files and directories:
```bash ```bash
$ mkdir -p /tmp/test/{a,b,c} $ mkdir -p /tmp/test/{a,b,c}
$ echo 'something in files in a' > /tmp/test/a/{1,2,3} $ touch /tmp/test/a/{1,2,3}
$ echo 'something else in files in b' > /tmp/test/b/{4,5,6} $ touch /tmp/test/b/{4,5,6}
$ echo 'some bytes' > /tmp/test/c/{7,8,9} $ touch /tmp/test/c/{7,8,9}
$ tree /tmp/test $ ls -R /tmp/test
/tmp/test /tmp/test:
├── a a b c
│   ├── 1
│   ├── 2 /tmp/test/a:
│   └── 3 1 2 3
├── b
│   ├── 4 /tmp/test/b:
│   ├── 5 4 5 6
│   └── 6
└── c /tmp/test/c:
├── 7 7 8 9
├── 8
└── 9
3 directories, 9 files
``` ```
First this directory is indexed with `catcli` as if it was some kind of First this directory is indexed by catcli as if it was some kind of
external storage: external storage:
```bash ```bash
$ catcli index --meta='my test directory' tmptest /tmp/test $ catcli index --meta='my test directory' -u tmptest /tmp/test
``` ```
Catcli creates its catalog file in the current directory as `catcli.catalog`. Catcli has created its catalog file in the current directory as `catcli.catalog`.
Printing the entire catalog as a tree is done with the command `ls -r`
Printing the entire catalog as a tree is done with the command `tree`
``` ```
$ catcli ls -r $ catcli tree
top top
└── storage: tmptest (my test directory) (nbfiles:3, free:3.7G/3.7G, date:2019-01-26 19:59:47) └── storage: tmptest (free:183.7G, total:200.0G) (my test directory)
├── a [nbfiles:3, totsize:72] ├── b [nbfiles:3]
│ ├── 1 [size:24] │ ├── 4 [size:0]
│ ├── 2 [size:24] │ ├── 5 [size:0]
│ └── 3 [size:24] │ └── 6 [size:0]
├── b [nbfiles:3, totsize:87] ├── a [nbfiles:3]
│ ├── 4 [size:29] │ ├── 1 [size:0]
│ ├── 5 [size:29] │ ├── 3 [size:0]
│ └── 6 [size:29] │ └── 2 [size:0]
└── c [nbfiles:3, totsize:33] └── c [nbfiles:3]
├── 7 [size:11] ├── 7 [size:0]
├── 8 [size:11] ├── 8 [size:0]
└── 9 [size:11] └── 9 [size:0]
``` ```
The catalog can be walked with `ls` as if it was a normal directory The catalog can be walked with `ls` as if it was a normal directory
``` ```
$ catcli ls $ catcli ls
top top
- storage: tmptest (my test directory) (nbfiles:3, free:3.7G/3.7G, date:2019-01-26 19:59:47) - storage: tmptest (free:2.6G, total:2.6G) (my test directory)
$ catcli ls tmptest $ catcli ls tmptest
storage: tmptest (my test directory) (nbfiles:3, free:3.7G/3.7G, date:2019-01-26 19:59:47) storage: tmptest (free:3.7G, total:3.7G) (my test directory)
- a [nbfiles:3, totsize:72] - a [nbfiles:3]
- b [nbfiles:3, totsize:87] - b [nbfiles:3]
- c [nbfiles:3, totsize:33] - c [nbfiles:3]
$ catcli ls tmptest/b $ catcli ls tmptest/b
b [nbfiles:3, totsize:87] b [nbfiles:3]
- 4 [size:29] - 4 [size:0]
- 5 [size:29] - 5 [size:0]
- 6 [size:29] - 6 [size:0]
``` ```
And files can be found using the command `find` And files can be found using the command `find`
```bash ```bash
$ catcli find 9 $ catcli find 9
test/c/9 [size:0]
c/9 [size:11, storage:tmptest]
``` ```
When using the `-b --script` switch, a one-liner is generated When using the `--script` switch, a one-liner is generated
that allows to handle the found file(s) that allows to handle the found file(s)
```
$ catcli find 9 --script
c/9 [size:11, storage:tmptest]
op=file; source=/media/mnt; $op ${source}/c/9
```
## Archive example
Let's consider a directory containing archive files:
```bash
$ ls -1 /tmp/catcli
catcli-0.3.1
v0.3.1.tar.gz
v0.3.1.zip
```
To enable the indexing of archive contents use
the `-a --archive` switch
```bash ```bash
$ catcli index -au some-name /tmp/catcli $ catcli find 9 --script
``` test/c/9 [size:0]
op=file; source=/media/mnt; $op ${source}/test/c/9
Then any command can be used to explore the catalog as for normal
files but, by providing the `-a --archive` switch, archive content are displayed.
```bash
$ catcli ls some-name
storage: some-name (free:800G, total:1T)
- catcli-0.3.1 [nbfiles:11, totsize:80.5K]
- v0.3.1.tar.gz [size:24.2K]
- v0.3.1.zip [size:31.2K]
$ catcli ls -r some-name/v0.3.1.zip
v0.3.1.zip [size:31.2K]
$ catcli ls -ar some-name/v0.3.1.zip
v0.3.1.zip [size:31.2K]
├── catcli-0.3.1 [archive:v0.3.1.zip]
│ ├── catcli [archive:v0.3.1.zip]
│ │ ├── __init__.py [archive:v0.3.1.zip]
│ │ ├── catalog.py [archive:v0.3.1.zip]
│ │ ├── catcli.py [archive:v0.3.1.zip]
│ │ ├── logger.py [archive:v0.3.1.zip]
│ │ ├── noder.py [archive:v0.3.1.zip]
│ │ ├── utils.py [archive:v0.3.1.zip]
│ │ └── walker.py [archive:v0.3.1.zip]
│ ├── .gitignore [archive:v0.3.1.zip]
│ ├── LICENSE [archive:v0.3.1.zip]
│ ├── MANIFEST.in [archive:v0.3.1.zip]
│ ├── README.md [archive:v0.3.1.zip]
│ ├── requirements.txt [archive:v0.3.1.zip]
│ ├── setup.cfg [archive:v0.3.1.zip]
│ ├── setup.py [archive:v0.3.1.zip]
│ ├── tests [archive:v0.3.1.zip]
│ │ ├── __init__.py [archive:v0.3.1.zip]
│ │ ├── helpers.py [archive:v0.3.1.zip]
│ │ ├── test_find.py [archive:v0.3.1.zip]
│ │ ├── test_graph.py [archive:v0.3.1.zip]
│ │ ├── test_index.py [archive:v0.3.1.zip]
│ │ ├── test_ls.py [archive:v0.3.1.zip]
│ │ ├── test_rm.py [archive:v0.3.1.zip]
│ │ └── test_tree.py [archive:v0.3.1.zip]
│ ├── tests.sh [archive:v0.3.1.zip]
│ └── .travis.yml [archive:v0.3.1.zip]
└── catcli-0.3.1/ [archive:v0.3.1.zip]
``` ```
# Contribution # Contribution
@ -446,10 +259,6 @@ If you want to contribute, feel free to do a PR (please follow PEP8).
The `tests.sh` script can be run to check the code. The `tests.sh` script can be run to check the code.
# Thank you
If you like catcli, [buy me a coffee](https://ko-fi.com/deadc0de6).
# License # License
This project is licensed under the terms of the GPLv3 license. This project is licensed under the terms of the GPLv3 license.

@ -3,12 +3,12 @@ author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2017, deadc0de6 Copyright (c) 2017, deadc0de6
""" """
# pylint: disable=C0415
import sys import sys
__version__ = '0.3'
def main() -> None:
"""entry point""" def main():
import catcli.catcli import catcli.catcli
if catcli.catcli.main(): if catcli.catcli.main():
sys.exit(0) sys.exit(0)

@ -6,150 +6,89 @@ Class that represents the catcli catalog
""" """
import os import os
from typing import Optional, List, Dict, Tuple, Union, Any import pickle
from anytree.exporter import JsonExporter, DictExporter from anytree.exporter import JsonExporter
from anytree.importer import JsonImporter from anytree.importer import JsonImporter
from anytree import AnyNode
# local imports # local imports
from catcli import nodes import catcli.utils as utils
from catcli.nodes import NodeMeta, NodeTop
from catcli.utils import ask
from catcli.logger import Logger from catcli.logger import Logger
class Catalog: class Catalog:
"""the catalog"""
def __init__(self, path: str, def __init__(self, path, pickle=False, verbose=False, force=False):
debug: bool = False, self.path = path # catalog path
force: bool = False) -> None: self.verbose = verbose # verbosity
""" self.force = force # force overwrite if exists
@path: catalog path self.metanode = None
@usepickle: use pickle # prefer json for git versioning
@debug: debug mode self.pickle = pickle
@force: force overwrite if exists
"""
self.path = os.path.expanduser(path)
self.debug = debug
self.force = force
self.metanode: Optional[NodeMeta] = None
def set_metanode(self, metanode: NodeMeta) -> None: def set_metanode(self, metanode):
"""remove the metanode until tree is re-written""" ''' remove the metanode until tree is re-written '''
self.metanode = metanode self.metanode = metanode
if self.metanode: self.metanode.parent = None
self.metanode.parent = None
def exists(self) -> bool:
"""does catalog exist"""
if not self.path:
return False
if os.path.exists(self.path):
return True
return False
def restore(self) -> Optional[NodeTop]: def restore(self):
"""restore the catalog""" ''' restore the catalog '''
if not self.path: if not self.path:
return None return None
if not os.path.exists(self.path): if not os.path.exists(self.path):
return None return None
with open(self.path, 'r', encoding='UTF-8') as file: if self.pickle:
content = file.read() return self._restore_pickle()
return self._restore_json(content) return self._restore_json(open(self.path, 'r').read())
def save(self, node: NodeTop) -> bool: def save(self, node):
"""save the catalog""" ''' save the catalog '''
if not self.path: if not self.path:
Logger.err('Path not defined') Logger.err('Path not defined')
return False return False
directory = os.path.dirname(self.path) d = os.path.dirname(self.path)
if directory and not os.path.exists(directory): if d and not os.path.exists(d):
os.makedirs(directory) os.makedirs(d)
elif os.path.exists(self.path) and not self.force: elif os.path.exists(self.path) and not self.force:
if not ask(f'Update catalog \"{self.path}\"'): if not utils.ask('Overwrite \"{}\"'.format(self.path)):
Logger.info('Catalog not saved') Logger.info('Catalog not saved')
return False return False
if directory and not os.path.exists(directory): if d and not os.path.exists(d):
Logger.err(f'Cannot write to \"{directory}\"') Logger.err('Cannot write to \"{}\"'.format(d))
return False return False
if self.metanode: if self.metanode:
self.metanode.parent = node self.metanode.parent = node
if self.pickle:
return self._save_pickle(node)
return self._save_json(node) return self._save_json(node)
def _debug(self, text: str) -> None: def _save_pickle(self, node):
if not self.debug: ''' pickle the catalog'''
return pickle.dump(node, open(self.path, 'wb'))
Logger.debug(text) if self.verbose:
Logger.info('Catalog saved to pickle \"{}\"'.format(self.path))
return True
def _save_json(self, top: NodeTop) -> bool: def _save_json(self, node):
"""export the catalog in json""" ''' export the catalog in json '''
self._debug(f'saving {top} to json...') exp = JsonExporter(indent=2, sort_keys=True)
dexporter = DictExporter(attriter=attriter) with open(self.path, 'w') as f:
exp = JsonExporter(dictexporter=dexporter, indent=2, sort_keys=True) exp.write(node, f)
with open(self.path, 'w', encoding='UTF-8') as file: if self.verbose:
exp.write(top, file) Logger.info('Catalog saved to json \"{}\"'.format(self.path))
self._debug(f'Catalog saved to json \"{self.path}\"')
return True return True
def _restore_json(self, string: str) -> Optional[NodeTop]: def _restore_pickle(self):
"""restore the tree from json""" ''' restore the pickled tree '''
imp = JsonImporter(dictimporter=_DictImporter(debug=self.debug)) root = pickle.load(open(self.path, 'rb'))
if self.verbose:
m = 'Catalog imported from pickle \"{}\"'.format(self.path)
Logger.info(m)
return root
def _restore_json(self, string):
''' restore the tree from json '''
imp = JsonImporter()
root = imp.import_(string) root = imp.import_(string)
self._debug(f'Catalog imported from json \"{self.path}\"') if self.verbose:
self._debug(f'root imported: {root}') Logger.info('Catalog imported from json \"{}\"'.format(self.path))
if root.type != nodes.TYPE_TOP: return root
return None
top = NodeTop(root.name, children=root.children)
self._debug(f'top imported: {top.get_name()}')
return top
class _DictImporter():
def __init__(self,
nodecls: AnyNode = AnyNode,
debug: bool = False):
self.nodecls = nodecls
self.debug = debug
def import_(self, data: Dict[str, str]) -> AnyNode:
"""Import tree from `data`."""
return self.__import(data)
def __import(self, data: Union[str, Any],
parent: AnyNode = None) -> AnyNode:
"""overwrite parent imoprt"""
assert isinstance(data, dict)
assert "parent" not in data
attrs = dict(data)
# replace attr
attrs = back_attriter(attrs)
children: Union[str, Any] = attrs.pop("children", [])
node = self.nodecls(parent=parent, **attrs)
for child in children:
self.__import(child, parent=node)
return node
def back_attriter(adict: Dict[str, str]) -> Dict[str, str]:
"""replace attribute on json restore"""
attrs = {}
for k, val in adict.items():
newk = k
if k == 'size':
newk = 'nodesize'
attrs[newk] = val
return attrs
def attriter(attrs: List[Tuple[str, Any]]) -> List[Tuple[str, Any]]:
"""replace attribute on json save"""
newattr = []
for attr in attrs:
k, val = attr
if k == 'nodesize':
k = 'size'
newattr.append((k, val))
return newattr

@ -11,430 +11,181 @@ Catcli command line interface
import sys import sys
import os import os
import datetime import datetime
from typing import Dict, Any, List, \
Tuple
from docopt import docopt from docopt import docopt
import cmd2
# local imports # local imports
from catcli.version import __version__ as VERSION from . import __version__ as VERSION
from catcli.nodes import NodeTop, NodeAny from .logger import Logger
from catcli.logger import Logger from .catalog import Catalog
from catcli.printer_csv import CsvPrinter from .walker import Walker
from catcli.colors import Colors from .noder import Noder
from catcli.catalog import Catalog from .utils import *
from catcli.walker import Walker
from catcli.noder import Noder
from catcli.utils import ask, edit
from catcli.nodes_utils import path_to_search_all
from catcli.exceptions import BadFormatException, CatcliException
NAME = 'catcli' NAME = 'catcli'
CUR = os.path.dirname(os.path.abspath(__file__)) CUR = os.path.dirname(os.path.abspath(__file__))
FORMATS = ['native', 'csv', 'csv-with-header', 'fzf-native', 'fzf-csv'] CATALOGPATH = NAME+'.catalog'
GRAPHPATH = '/tmp/'+NAME+'.dot'
# env variables SEPARATOR = '/'
ENV_CATALOG_PATH = 'CATCLI_CATALOG_PATH' WILD = '*'
ENV_NOBANNER = 'CATCLI_NO_BANNER'
ENV_VERBOSE = 'CATCLI_VERBOSE' BANNER = """ +-+-+-+-+-+-+
ENV_FORMAT = 'CATCLI_FORMAT'
# default paths
DEFAULT_CATALOGPATH = os.getenv(ENV_CATALOG_PATH, default=f'{NAME}.catalog')
DEFAULT_GRAPHPATH = f'/tmp/{NAME}.dot'
DEFAULT_NOBANNER = os.getenv(ENV_NOBANNER) is not None
DEFAULT_VERBOSEMODE = os.getenv(ENV_VERBOSE) is not None
DEFAULT_FORMAT = os.getenv(ENV_FORMAT, default='native')
BANNER = f""" +-+-+-+-+-+-+
|c|a|t|c|l|i| |c|a|t|c|l|i|
+-+-+-+-+-+-+ v{VERSION}""" +-+-+-+-+-+-+ v{}""".format(VERSION)
USAGE = f""" USAGE = """
{BANNER} {0}
Usage: Usage:
{NAME} ls [--catalog=<path>] [--format=<fmt>] [-aBCrVSs] [<path>] {1} index [--catalog=<path>] [--meta=<meta>...] [-fcuV] <name> <path>
{NAME} tree [--catalog=<path>] [-aBCVSs] [<path>] {1} ls [--catalog=<path>] [-rVS] [<path>]
{NAME} find [--catalog=<path>] [--format=<fmt>] {1} find [--catalog=<path>] [-bVS] <term>
[-aBCbdVs] [--path=<path>] [<term>] {1} rm [--catalog=<path>] [-fV] <storage>
{NAME} index [--catalog=<path>] [--meta=<meta>...] {1} tree [--catalog=<path>] [-VS] [<path>]
[-aBCcfV] <name> <path> {1} rename [--catalog=<path>] [-fV] <storage> <name>
{NAME} update [--catalog=<path>] [-aBCcfV] {1} edit [--catalog=<path>] [-fV] <storage>
[--lpath=<path>] <name> <path> {1} graph [--catalog=<path>] [-V] [<path>]
{NAME} mount [--catalog=<path>] [-V] <mountpoint> {1} help
{NAME} du [--catalog=<path>] [-BCVSs] [<path>] {1} --help
{NAME} rm [--catalog=<path>] [-BCfV] <storage> {1} --version
{NAME} rename [--catalog=<path>] [-BCfV] <storage> <name>
{NAME} edit [--catalog=<path>] [-BCfV] <storage>
{NAME} graph [--catalog=<path>] [-BCV] [<path>]
{NAME} [--catalog=<path>]
{NAME} fixsizes [--catalog=<path>]
{NAME} print_supported_formats
{NAME} help
{NAME} --help
{NAME} --version
Options: Options:
--catalog=<path> Path to the catalog [default: {DEFAULT_CATALOGPATH}]. --catalog=<path> Path to the catalog [default: {2}].
--meta=<meta> Additional attribute to store [default: ]. --meta=<meta> Additional attribute to store [default: ].
-a --archive Handle archive file [default: False]. -u --subsize Store size of folders [default: False].
-B --no-banner Do not display the banner [default: {str(DEFAULT_NOBANNER)}]. -f --force Force overwrite [default: False].
-b --script Output script to manage found file(s) [default: False]. -b --script Output script to manage found file(s) [default: False].
-C --no-color Do not output colors [default: False]. -S --sortsize Sort by size, largest first [default: False].
-c --hash Calculate md5 hash [default: False]. -c --hash Calculate md5 hash [default: False].
-d --directory Only directory [default: False].
-F --format=<fmt> see \"print_supported_formats\" [default: {DEFAULT_FORMAT}].
-f --force Do not ask when updating the catalog [default: False].
-l --lpath=<path> Path where changes are logged [default: ]
-p --path=<path> Start path.
-r --recursive Recursive [default: False]. -r --recursive Recursive [default: False].
-s --raw-size Print raw size [default: False]. -V --verbose Be verbose [default: False].
-S --sortsize Sort by size, largest first [default: False].
-V --verbose Be verbose [default: {str(DEFAULT_VERBOSEMODE)}].
-v --version Show version. -v --version Show version.
-h --help Show this screen. -h --help Show this screen.
""" # nopep8 """.format(BANNER, NAME, CATALOGPATH)
def cmd_mount(args: Dict[str, Any],
top: NodeTop,
noder: Noder) -> bool:
"""mount action"""
mountpoint = args['<mountpoint>']
debug = args['--verbose']
try:
from catcli.fuser import Fuser # pylint: disable=C0415
Fuser(mountpoint, top, noder,
debug=debug)
except ModuleNotFoundError:
Logger.err('install fusepy to use mount')
return False
return True
def cmd_index(args: Dict[str, Any], def cmd_index(args, noder, catalog, top):
noder: Noder,
catalog: Catalog,
top: NodeTop) -> None:
"""index action"""
path = args['<path>'] path = args['<path>']
name = args['<name>'] name = args['<name>']
usehash = args['--hash'] nohash = not args['--hash']
debug = args['--verbose'] subsize = args['--subsize']
if not os.path.exists(path): if not os.path.exists(path):
Logger.err(f'\"{path}\" does not exist') Logger.err('\"{}\" does not exist'.format(path))
return return False
if name in noder.get_storage_names(top): if name in noder.get_storage_names(top):
try: Logger.err('storage named \"{}\" already exist'.format(name))
if not ask(f'Overwrite storage \"{name}\"'): return False
Logger.err(f'storage named \"{name}\" already exist')
return
except KeyboardInterrupt:
Logger.err('aborted')
return
node = top.get_storage_node()
if node:
node.parent = None
start = datetime.datetime.now() start = datetime.datetime.now()
if debug: walker = Walker(noder, nohash=nohash)
Logger.debug('debug mode enabled') attr = noder.clean_storage_attr(args['--meta'])
walker = Walker(noder, usehash=usehash, debug=debug) root = noder.storage_node(name, path, parent=top, attr=attr)
attr = args['--meta'] _, cnt = walker.index(path, name, parent=root, parentpath=path)
root = noder.new_storage_node(name, path, top, attr) if subsize:
_, cnt = walker.index(path, root, name) noder.rec_size(root)
root.nodesize = root.get_rec_size()
stop = datetime.datetime.now() stop = datetime.datetime.now()
diff = stop - start Logger.info('Indexed {} file(s) in {}'.format(cnt, stop-start))
Logger.info(f'Indexed {cnt} file(s) in {diff}') catalog.save(top)
if cnt > 0:
catalog.save(top)
def cmd_update(args: Dict[str, Any], def cmd_ls(args, noder, top):
noder: Noder,
catalog: Catalog,
top: NodeTop) -> None:
"""update action"""
path = args['<path>'] path = args['<path>']
name = args['<name>'] if not path:
usehash = args['--hash'] path = SEPARATOR
logpath = args['--lpath'] if not path.startswith(SEPARATOR):
debug = args['--verbose'] path = SEPARATOR + path
if not os.path.exists(path): pre = '{}{}'.format(SEPARATOR, noder.TOPNAME)
Logger.err(f'\"{path}\" does not exist') if not path.startswith(pre):
return path = pre + path
storage = noder.find_storage_node_by_name(top, name) if not path.endswith(SEPARATOR):
if not storage: path += SEPARATOR
Logger.err(f'storage named \"{name}\" does not exist') if not path.endswith(WILD):
return path += WILD
noder.update_storage_path(top, name, path) return noder.walk(top, path, rec=args['--recursive'])
start = datetime.datetime.now()
walker = Walker(noder, usehash=usehash, debug=debug,
logpath=logpath) def cmd_rm(args, noder, catalog, top):
cnt = walker.reindex(path, storage, top) what = args['<storage>']
storage.nodesize = storage.get_rec_size() storages = list(x.name for x in top.children)
stop = datetime.datetime.now() if what in storages:
diff = stop - start node = next(filter(lambda x: x.name == what, top.children))
Logger.info(f'updated {cnt} file(s) in {diff}')
if cnt > 0:
catalog.save(top)
def cmd_du(args: Dict[str, Any],
noder: Noder,
top: NodeTop) -> List[NodeAny]:
"""du action"""
path = path_to_search_all(args['<path>'])
found = noder.diskusage(top,
path,
raw=args['--raw-size'])
if not found:
path = args['<path>']
Logger.err(f'\"{path}\": nothing found')
return found
def cmd_ls(args: Dict[str, Any],
noder: Noder,
top: NodeTop) -> List[NodeAny]:
"""ls action"""
path = path_to_search_all(args['<path>'])
fmt = args['--format']
if fmt.startswith('fzf'):
raise BadFormatException('fzf is not supported in ls, use find')
found = noder.list(top,
path,
fmt=fmt,
rec=args['--recursive'],
raw=args['--raw-size'])
if not found:
path = args['<path>']
Logger.err(f'\"{path}\": nothing found')
return found
def cmd_rm(args: Dict[str, Any],
noder: Noder,
catalog: Catalog,
top: NodeTop) -> NodeTop:
"""rm action"""
name = args['<storage>']
node = noder.find_storage_node_by_name(top, name)
if node:
node.parent = None node.parent = None
if catalog.save(top): if catalog.save(top):
Logger.info(f'Storage \"{name}\" removed') Logger.info('Storage \"{}\" removed'.format(what))
else: else:
Logger.err(f'Storage named \"{name}\" does not exist') Logger.err('Storage named \"{}\" does not exist'.format(what))
return top return top
def cmd_find(args: Dict[str, Any], def cmd_find(args, noder, top):
noder: Noder, return noder.find_name(top, args['<term>'], script=args['--script'])
top: NodeTop) -> List[NodeAny]:
"""find action"""
directory = args['--directory'] def cmd_tree(args, noder, top):
startpath = args['--path']
fmt = args['--format']
raw = args['--raw-size']
script = args['--script']
search_for = args['<term>']
if args['--verbose']:
Logger.debug(f'search for "{search_for}" under "{top.get_name()}"')
found = noder.find(top, search_for,
script=script,
startnode=startpath,
only_dir=directory,
fmt=fmt,
raw=raw)
return found
def cmd_graph(args: Dict[str, Any],
noder: Noder,
top: NodeTop) -> None:
"""graph action"""
path = args['<path>'] path = args['<path>']
if not path: node = top
path = DEFAULT_GRAPHPATH if path:
cmd = noder.to_dot(top, path) node = noder.get_node(top, path)
Logger.info(f'create graph with \"{cmd}\" (you need graphviz)') if node:
noder.print_tree(node)
def cmd_fixsizes(top: NodeTop, def cmd_graph(args, noder, top):
noder: Noder, path = args['<path>']
catalog: Catalog) -> None: if not path:
""" path = GRAPHPATH
fix each node size by re-calculating cmd = noder.to_dot(top, path)
recursively their size Logger.info('create graph with \"{}\" (you need graphviz)'.format(cmd))
"""
noder.fixsizes(top)
catalog.save(top)
def cmd_rename(args: Dict[str, Any], def cmd_rename(args, noder, catalog, top):
catalog: Catalog,
top: NodeTop) -> None:
"""rename action"""
storage = args['<storage>'] storage = args['<storage>']
new = args['<name>'] new = args['<name>']
storages = list(x.get_name() for x in top.children) storages = list(x.name for x in top.children)
if storage in storages: if storage in storages:
node = next(filter(lambda x: x.get_name() == storage, top.children)) node = next(filter(lambda x: x.name == storage, top.children))
node.set_name(new) node.name = new
if catalog.save(top): if catalog.save(top):
msg = f'Storage \"{storage}\" renamed to \"{new}\"' Logger.info('Storage \"{}\" renamed to \"{}\"'.format(storage, new))
Logger.info(msg)
else: else:
Logger.err(f'Storage named \"{storage}\" does not exist') Logger.err('Storage named \"{}\" does not exist'.format(storage))
return top
def cmd_edit(args: Dict[str, Any], def cmd_edit(args, noder, catalog, top):
noder: Noder,
catalog: Catalog,
top: NodeTop) -> None:
"""edit action"""
storage = args['<storage>'] storage = args['<storage>']
storages = list(x.get_name() for x in top.children) storages = list(x.name for x in top.children)
if storage in storages: if storage in storages:
node = next(filter(lambda x: x.get_name() == storage, top.children)) node = next(filter(lambda x: x.name == storage, top.children))
attr = node.attr attr = node.attr
if not attr: if not attr:
attr = '' attr = ''
new = edit(attr) new = edit(attr)
node.attr = noder.attrs_to_string(new) node.attr = noder.clean_storage_attr(new)
if catalog.save(top): if catalog.save(top):
Logger.info(f'Storage \"{storage}\" edited') Logger.info('Storage \"{}\" edited'.format(storage))
else: else:
Logger.err(f'Storage named \"{storage}\" does not exist') Logger.err('Storage named \"{}\" does not exist'.format(storage))
return top
class CatcliRepl(cmd2.Cmd): # type: ignore
"""catcli repl"""
prompt = 'catcli> '
intro = ''
def __init__(self) -> None:
super().__init__()
# remove built-ins
del cmd2.Cmd.do_alias
del cmd2.Cmd.do_edit
del cmd2.Cmd.do_macro
del cmd2.Cmd.do_run_pyscript
del cmd2.Cmd.do_run_script
del cmd2.Cmd.do_set
del cmd2.Cmd.do_shell
del cmd2.Cmd.do_shortcuts
self.hidden_commands.append('EOF')
def cmdloop(self, intro: Any = None) -> Any:
return cmd2.Cmd.cmdloop(self, intro)
@cmd2.with_argument_list # type: ignore
def do_ls(self, arglist: List[str]) -> bool:
"""ls <path>"""
arglist.insert(0, '--no-banner')
arglist.insert(0, 'ls')
args, noder, _, _, top = init(arglist)
cmd_ls(args, noder, top)
return False
@cmd2.with_argument_list # type: ignore
def do_tree(self, arglist: List[str]) -> bool:
"""tree <path>"""
arglist.insert(0, '--no-banner')
arglist.insert(0, 'tree')
args, noder, _, _, top = init(arglist)
cmd_ls(args, noder, top)
return False
@cmd2.with_argument_list # type: ignore
def do_find(self, arglist: List[str]) -> bool:
"""find <term>"""
arglist.insert(0, '--no-banner')
arglist.insert(0, 'find')
args, noder, _, _, top = init(arglist)
cmd_find(args, noder, top)
return False
@cmd2.with_argument_list # type: ignore
def do_du(self, arglist: List[str]) -> bool:
"""du <path>"""
arglist.insert(0, '--no-banner')
arglist.insert(0, 'du')
args, noder, _, _, top = init(arglist)
cmd_du(args, noder, top)
return False
def do_help(self, _: Any) -> bool:
"""help"""
print(USAGE)
return False
# pylint: disable=C0103
def do_EOF(self, _: Any) -> bool:
"""exit repl"""
return True
def banner() -> None:
"""print banner"""
Logger.stderr_nocolor(BANNER)
Logger.stderr_nocolor("")
def print_supported_formats() -> None: def banner():
"""print all supported formats to stdout""" Logger.log(BANNER)
print('"native" : native format') Logger.log("")
print('"csv" : CSV format')
print(f' {CsvPrinter.CSV_HEADER}')
print('"fzf-native" : fzf to native (only valid for find)')
print('"fzf-csv" : fzf to csv (only valid for find)')
def init(argv: List[str]) -> Tuple[Dict[str, Any], def main():
Noder, args = docopt(USAGE, version=VERSION)
Catalog,
str,
NodeTop]:
"""parse catcli arguments"""
args = docopt(USAGE, argv=argv, version=VERSION)
if args['help'] or args['--help']: if args['help']:
print(USAGE) print(USAGE)
sys.exit(0) return True
if args['print_supported_formats']:
print_supported_formats()
sys.exit(0)
fmt = args['--format']
if fmt not in FORMATS:
Logger.err(f'bad format: {fmt}')
print_supported_formats()
sys.exit(0)
if args['--verbose'] or DEFAULT_VERBOSEMODE:
print('verbose mode enabled')
print(f'args: {args}')
# print banner # print banner
if not args['--no-banner'] and DEFAULT_NOBANNER: banner()
banner()
# set colors
if args['--no-color']:
Colors.no_color()
# init noder # init noder
noder = Noder(debug=args['--verbose'], sortsize=args['--sortsize'], noder = Noder(verbose=args['--verbose'], sortsize=args['--sortsize'])
arc=args['--archive'])
# init catalog # init catalog
catalog_path = args['--catalog'] catalog = Catalog(args['--catalog'], verbose=args['--verbose'],
catalog = Catalog(catalog_path, debug=args['--verbose'],
force=args['--force']) force=args['--force'])
# init top node # init top node
top = catalog.restore() top = catalog.restore()
@ -442,88 +193,32 @@ def init(argv: List[str]) -> Tuple[Dict[str, Any],
top = noder.new_top_node() top = noder.new_top_node()
# handle the meta node # handle the meta node
meta = noder.update_metanode(top) meta = noder.update_metanode(noder.get_meta_node(top))
catalog.set_metanode(meta) catalog.set_metanode(meta)
return args, noder, catalog, catalog_path, top
def main() -> bool:
"""entry point"""
args, noder, catalog, catalog_path, top = init(sys.argv[1:])
# parse command # parse command
try: if args['index']:
if args['index']: cmd_index(args, noder, catalog, top)
cmd_index(args, noder, catalog, top) elif args['find']:
elif args['update']: cmd_find(args, noder, top)
if not catalog.exists(): elif args['tree']:
Logger.err(f'no such catalog: {catalog_path}') cmd_tree(args, noder, top)
return False elif args['ls']:
cmd_update(args, noder, catalog, top) cmd_ls(args, noder, top)
cmd_fixsizes(top, noder, catalog) elif args['rm']:
elif args['find']: cmd_rm(args, noder, catalog, top)
if not catalog.exists(): elif args['graph']:
Logger.err(f'no such catalog: {catalog_path}') cmd_graph(args, noder, top)
return False elif args['rename']:
cmd_find(args, noder, top) cmd_rename(args, noder, catalog, top)
elif args['ls']: elif args['edit']:
if not catalog.exists(): cmd_edit(args, noder, catalog, top)
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_ls(args, noder, top)
elif args['tree']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
args['--recursive'] = True
cmd_ls(args, noder, top)
elif args['mount']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
if not cmd_mount(args, top, noder):
return False
elif args['rm']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_rm(args, noder, catalog, top)
elif args['graph']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_graph(args, noder, top)
elif args['rename']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_rename(args, catalog, top)
elif args['edit']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_edit(args, noder, catalog, top)
elif args['du']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_du(args, noder, top)
elif args['fixsizes']:
if not catalog.exists():
Logger.err(f'no such catalog: {catalog_path}')
return False
cmd_fixsizes(top, noder, catalog)
else:
CatcliRepl().cmdloop()
except CatcliException as exc:
Logger.stderr_nocolor('ERROR ' + str(exc))
return False
return True return True
if __name__ == '__main__': if __name__ == '__main__':
''' entry point '''
if main(): if main():
sys.exit(0) sys.exit(0)
sys.exit(1) sys.exit(1)

@ -1,44 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2022, deadc0de6
shell colors
"""
from typing import TypeVar, Type
CLASSTYPE = TypeVar('CLASSTYPE', bound='Colors')
class Colors:
"""shell colors"""
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
PURPLE = '\033[1;35m'
BLUE = '\033[94m'
GRAY = '\033[0;37m'
CYAN = '\033[36m'
MAGENTA = '\033[95m'
WHITE = '\033[97m'
RESET = '\033[0m'
EMPH = '\033[33m'
BOLD = '\033[1m'
UND = '\033[4m'
@classmethod
def no_color(cls: Type[CLASSTYPE]) -> None:
"""disable colors"""
Colors.RED = ''
Colors.GREEN = ''
Colors.YELLOW = ''
Colors.PURPLE = ''
Colors.BLUE = ''
Colors.GRAY = ''
Colors.MAGENTA = ''
Colors.RESET = ''
Colors.EMPH = ''
Colors.BOLD = ''
Colors.UND = ''

@ -1,57 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2017, deadc0de6
Catcli generic compressed data lister
"""
import os
import tarfile
import zipfile
from typing import List
class Decomp:
"""decompressor"""
def __init__(self) -> None:
self.ext = {
'tar': self._tar,
'tgz': self._tar,
'gz': self._tar,
'tar.gz': self._tar,
'xz': self._tar,
'tar.xz': self._tar,
'lzma': self._tar,
'tar.lzma': self._tar,
'tlz': self._tar,
'bz2': self._tar,
'tar.bz2': self._tar,
'zip': self._zip}
def get_formats(self) -> List[str]:
"""return list of supported extensions"""
return list(self.ext.keys())
def get_names(self, path: str) -> List[str]:
"""get tree of compressed archive"""
ext = os.path.splitext(path)[1][1:].lower()
if ext in list(self.ext):
return self.ext[ext](path)
return []
@staticmethod
def _tar(path: str) -> List[str]:
"""return list of file names in tar"""
if not tarfile.is_tarfile(path):
return []
with tarfile.open(path, "r") as tar:
return tar.getnames()
@staticmethod
def _zip(path: str) -> List[str]:
"""return list of file names in zip"""
if not zipfile.is_zipfile(path):
return []
with zipfile.ZipFile(path) as file:
return file.namelist()

@ -1,14 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2022, deadc0de6
Catcli exceptions
"""
class CatcliException(Exception):
"""generic catcli exception"""
class BadFormatException(CatcliException):
"""use of bad format"""

@ -1,133 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2023, deadc0de6
fuse for catcli
"""
import os
from time import time
from stat import S_IFDIR, S_IFREG
from typing import List, Dict, Any, Optional
try:
import fuse
except ModuleNotFoundError:
pass
# local imports
from catcli.noder import Noder
from catcli.nodes import NodeTop, NodeAny
from catcli.nodes_utils import path_to_search_all, path_to_top
from catcli import nodes
class Fuser:
"""fuse filesystem mounter"""
def __init__(self, mountpoint: str,
top: NodeTop,
noder: Noder,
debug: bool = False):
"""fuse filesystem"""
filesystem = CatcliFilesystem(top, noder)
fuse.FUSE(filesystem,
mountpoint,
foreground=debug,
nothreads=True,
debug=debug)
class CatcliFilesystem(fuse.LoggingMixIn, fuse.Operations): # type: ignore
"""in-memory filesystem for catcli catalog"""
def __init__(self, top: NodeTop,
noder: Noder):
"""init fuse filesystem"""
self.top = top
self.noder = noder
def _get_entry(self, path: str) -> Optional[NodeAny]:
"""return the node pointed by path"""
path = path_to_top(path)
found = self.noder.list(self.top, path,
rec=False,
fmt='native',
raw=True)
if found:
return found[0]
return None
def _get_entries(self, path: str) -> List[NodeAny]:
"""return nodes pointed by path"""
path = path_to_search_all(path)
found = self.noder.list(self.top, path,
rec=False,
fmt='native',
raw=True)
return found
def _getattr(self, path: str) -> Dict[str, Any]:
entry = self._get_entry(path)
if not entry:
return {}
maccess = time()
mode: Any = S_IFREG
nodesize: int = 0
if entry.type == nodes.TYPE_ARCHIVED:
mode = S_IFREG
nodesize = entry.nodesize
elif entry.type == nodes.TYPE_DIR:
mode = S_IFDIR
nodesize = entry.nodesize
maccess = entry.maccess
elif entry.type == nodes.TYPE_FILE:
mode = S_IFREG
nodesize = entry.nodesize
maccess = entry.maccess
elif entry.type == nodes.TYPE_STORAGE:
mode = S_IFDIR
nodesize = entry.nodesize
maccess = entry.ts
elif entry.type == nodes.TYPE_META:
mode = S_IFREG
elif entry.type == nodes.TYPE_TOP:
mode = S_IFREG
mode = mode | 0o777
return {
'st_mode': (mode), # file type
'st_nlink': 1, # count hard link
'st_size': nodesize,
'st_ctime': maccess, # attr last modified
'st_mtime': maccess, # content last modified
'st_atime': maccess, # access time
'st_uid': os.getuid(),
'st_gid': os.getgid(),
}
def getattr(self, path: str, _fh: Any = None) -> Dict[str, Any]:
"""return attr of file pointed by path"""
if path == os.path.sep:
# mountpoint
curt = time()
meta = {
'st_mode': (S_IFDIR | 0o777),
'st_nlink': 1,
'st_size': 0,
'st_ctime': curt,
'st_mtime': curt,
'st_atime': curt,
'st_uid': os.getuid(),
'st_gid': os.getgid(),
}
return meta
meta = self._getattr(path)
return meta
def readdir(self, path: str, _fh: Any) -> List[str]:
"""read directory content"""
content = ['.', '..']
entries = self._get_entries(path)
for entry in entries:
content.append(entry.get_name())
return content

@ -6,66 +6,81 @@ Logging helper
""" """
import sys import sys
from typing import TypeVar, Type
# local imports
from catcli.colors import Colors
from catcli.utils import fix_badchars
class Logger:
CLASSTYPE = TypeVar('CLASSTYPE', bound='Logger') RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
PURPLE = '\033[1;35m'
BLUE = '\033[94m'
GRAY = '\033[0;37m'
MAGENTA = '\033[95m'
RESET = '\033[0m'
EMPH = '\033[33m'
BOLD = '\033[1m'
UND = '\033[4m'
def __init__(self):
pass
class Logger: ######################################################################
"""log to stdout/stderr""" # node specific output
######################################################################
def storage(pre, name, attr):
''' print a storage node '''
end = ''
if attr:
end = ' {}({}){}'.format(Logger.GRAY, attr, Logger.RESET)
s = '{}{}storage{}:'.format(pre, Logger.UND, Logger.RESET)
s += ' {}{}{}{}'.format(Logger.PURPLE, name, Logger.RESET, end)
sys.stdout.write(s+'\n')
def file(pre, name, attr):
''' print a file node '''
s = '{}{}'.format(pre, name)
s += ' {}[{}]{}'.format(Logger.GRAY, attr, Logger.RESET)
sys.stdout.write(s+'\n')
@classmethod def dir(pre, name, depth='', attr=None):
def stdout_nocolor(cls: Type[CLASSTYPE], ''' print a directory node '''
string: str) -> None: end = []
"""to stdout no color""" if depth != '':
string = fix_badchars(string) end.append('nbfiles:{}'.format(depth))
sys.stdout.write(f'{string}\n') if attr:
end.append(' '.join(['{}:{}'.format(x, y) for x, y in attr]))
if end:
end = ' [{}]'.format(', '.join(end))
s = '{}{}{}{}'.format(pre, Logger.BLUE, name, Logger.RESET)
s += '{}{}{}'.format(Logger.GRAY, end, Logger.RESET)
sys.stdout.write(s+'\n')
@classmethod ######################################################################
def stderr_nocolor(cls: Type[CLASSTYPE], # generic output
string: str) -> None: ######################################################################
"""to stderr no color""" def out(string):
string = fix_badchars(string) ''' to stdout '''
sys.stderr.write(f'{string}\n') sys.stdout.write(string+'\n')
@classmethod def log(string):
def debug(cls: Type[CLASSTYPE], ''' to stderr '''
string: str) -> None: sys.stderr.write(string+'\n')
"""to stderr no color"""
cls.stderr_nocolor(f'[DBG] {string}')
@classmethod def info(string):
def info(cls: Type[CLASSTYPE], ''' to stderr in color '''
string: str) -> None: s = '{}{}{}'.format(Logger.MAGENTA, string, Logger.RESET)
"""to stdout in color""" sys.stderr.write(s+'\n')
string = fix_badchars(string)
out = f'{Colors.MAGENTA}{string}{Colors.RESET}'
sys.stdout.write(f'{out}\n')
@classmethod def err(string):
def err(cls: Type[CLASSTYPE], ''' to stderr in RED '''
string: str) -> None: s = '{}{}{}'.format(Logger.RED, string, Logger.RESET)
"""to stderr in RED""" sys.stderr.write(s+'\n')
string = fix_badchars(string)
out = f'{Colors.RED}{string}{Colors.RESET}'
sys.stderr.write(f'{out}\n')
@classmethod def progr(string):
def progr(cls: Type[CLASSTYPE], ''' print progress '''
string: str) -> None: sys.stderr.write('{}\r'.format(string))
"""print progress"""
string = fix_badchars(string)
sys.stderr.write(f'{string}\r')
sys.stderr.flush() sys.stderr.flush()
@classmethod def bold(string):
def get_bold_text(cls: Type[CLASSTYPE], return '{}{}{}'.format(Logger.BOLD, string, Logger.RESET)
string: str) -> str:
"""make it bold"""
string = fix_badchars(string)
return f'{Colors.BOLD}{string}{Colors.RESET}'

@ -2,741 +2,284 @@
author: deadc0de6 (https://github.com/deadc0de6) author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2017, deadc0de6 Copyright (c) 2017, deadc0de6
Class that process nodes in the catalog tree Class that represents a node in the catalog tree
""" """
import os import os
import shutil
import time
from typing import List, Union, Tuple, Any, Optional, Dict, cast
import fnmatch
import anytree import anytree
from natsort import os_sort_keygen import psutil
import time
# local imports # local imports
from catcli import nodes from . import __version__ as VERSION
from catcli.nodes import NodeAny, NodeStorage, \ import catcli.utils as utils
NodeTop, NodeFile, NodeArchived, NodeDir, NodeMeta, \
typcast_node
from catcli.utils import md5sum
from catcli.logger import Logger from catcli.logger import Logger
from catcli.printer_native import NativePrinter
from catcli.printer_csv import CsvPrinter
from catcli.decomp import Decomp
from catcli.version import __version__ as VERSION
from catcli.exceptions import CatcliException
'''
class Noder: There are 4 types of node:
"""
handles node in the catalog tree
There are 4 types of node:
* "top" node representing the top node (generic node) * "top" node representing the top node (generic node)
* "storage" node representing a storage * "storage" node representing a storage
* "dir" node representing a directory * "dir" node representing a directory
* "file" node representing a file * "file" node representing a file
""" '''
# pylint: disable=R0904
class Noder:
def __init__(self, debug: bool = False, TOPNAME = 'top'
sortsize: bool = False, METANAME = 'meta'
arc: bool = False) -> None: TYPE_TOP = 'top' # tip top ;-)
""" TYPE_FILE = 'file'
@debug: debug mode TYPE_DIR = 'dir'
@sortsize: sort nodes by size TYPE_STORAGE = 'storage'
@arch: handle archive TYPE_META = 'meta'
"""
def __init__(self, verbose=False, sortsize=False):
self.hash = True self.hash = True
self.debug = debug self.verbose = verbose
self.sortsize = sortsize self.sortsize = sortsize
self.arc = arc
if self.arc:
self.decomp = Decomp()
self.csv_printer = CsvPrinter()
self.native_printer = NativePrinter()
@staticmethod def set_hashing(self, val):
def get_storage_names(top: NodeTop) -> List[str]: self.hash = val
"""return a list of all storage names"""
return [x.name for x in list(top.children)]
def find_storage_node_by_name(self, top: NodeTop, def get_storage_names(self, top):
name: str) -> Optional[NodeStorage]: ''' return a list of all storage names '''
"""find a storage node by name""" return [x.name for x in list(top.children)]
for node in top.children:
if node.type != nodes.TYPE_STORAGE:
continue
if node.name == name:
return cast(NodeStorage, node)
return None
def update_storage_path(self, top: NodeTop, def clean_storage_attr(self, attr):
name: str, if not attr:
newpath: str) -> None: return ''
"""find and update storage path on update""" return ', '.join(attr.splitlines())
storage = self.find_storage_node_by_name(top, name)
if storage and newpath and os.path.exists(newpath):
storage.free = shutil.disk_usage(newpath).free
storage.total = shutil.disk_usage(newpath).total
storage.ts = int(time.time())
@staticmethod def get_node(self, top, path):
def get_node(top: NodeTop, ''' get the node at path '''
path: str, r = anytree.resolver.Resolver('name')
quiet: bool = False) -> Optional[NodeAny]:
"""get the node by internal tree path"""
resolv = anytree.resolver.Resolver('name')
bpath = ''
try: try:
bpath = os.path.basename(path) return r.get(top, path)
the_node = resolv.get(top, bpath)
typcast_node(the_node)
return cast(NodeAny, the_node)
except anytree.resolver.ChildResolverError: except anytree.resolver.ChildResolverError:
if not quiet: Logger.err('No node at path \"{}\"'.format(path))
Logger.err(f'No node at path \"{bpath}\"')
return None return None
def get_node_if_changed(self,
top: NodeTop,
path: str,
treepath: str) -> Tuple[Optional[NodeAny], bool]:
"""
return the node (if any) and if it has changed
@top: top node (storage)
@path: abs path to file
@treepath: rel path from indexed directory
"""
treepath = treepath.lstrip(os.sep)
node = self.get_node(top, treepath, quiet=True)
# node does not exist
if not node:
self._debug('\tchange: node does not exist')
return None, True
if os.path.isdir(path):
return node, False
# force re-indexing if no maccess
maccess = os.path.getmtime(path)
if not node.has_attr('maccess') or \
not node.maccess:
self._debug('\tchange: no maccess found')
return node, True
# maccess changed
old_maccess = node.maccess
if float(maccess) != float(old_maccess):
self._debug(f'\tchange: maccess changed for \"{path}\"')
return node, True
# test hash
if self.hash and node.md5:
md5 = self._get_hash(path)
if md5 and md5 != node.md5:
msg = f'\tchange: checksum changed for \"{path}\"'
self._debug(msg)
return node, True
self._debug(f'\tchange: no change for \"{path}\"')
return node, False
############################################################### ###############################################################
# public helpers # node creationg
############################################################### ###############################################################
@staticmethod def new_top_node(self):
def attrs_to_string(attr: Union[List[str], Dict[str, str], str]) -> str: ''' create a new top node'''
"""format the storage attr for saving""" return anytree.AnyNode(name=self.TOPNAME, type=self.TYPE_TOP)
if not attr:
return ''
if isinstance(attr, list):
return ', '.join(attr)
if isinstance(attr, dict):
ret = []
for key, val in attr.items():
ret.append(f'{key}={val}')
return ', '.join(ret)
attr = attr.rstrip()
return attr
def do_hashing(self, val: bool) -> None: def update_metanode(self, meta):
"""hash files when indexing""" ''' create or update meta node information '''
self.hash = val epoch = int(time.time())
if not meta:
############################################################### attr = {}
# node creation attr['created'] = epoch
############################################################### attr['created_version'] = VERSION
def new_top_node(self) -> NodeTop: meta = anytree.AnyNode(name=self.METANAME, type=self.TYPE_META,
"""create a new top node""" attr=attr)
top = NodeTop(nodes.NAME_TOP) meta.attr['access'] = epoch
self._debug(f'new top node: {top}') meta.attr['access_version'] = VERSION
return top return meta
def new_file_node(self, name: str, path: str, def file_node(self, name, path, parent, storagepath):
parent: NodeAny) -> Optional[NodeFile]: ''' create a new node representing a file '''
"""create a new node representing a file"""
if not os.path.exists(path): if not os.path.exists(path):
Logger.err(f'File \"{path}\" does not exist') Logger.err('File \"{}\" does not exist'.format(path))
return None return None
path = os.path.abspath(path) path = os.path.abspath(path)
try: try:
stat = os.lstat(path) st = os.lstat(path)
except OSError as exc: except OSError as e:
Logger.err(f'OSError: {exc}') Logger.err('OSError: {}'.format(e))
return None return None
md5 = '' md5 = None
if self.hash: if self.hash:
md5 = self._get_hash(path) md5 = utils.md5sum(path)
relpath = os.path.join(os.path.basename(storagepath),
maccess = os.path.getmtime(path) os.path.relpath(path, start=storagepath))
node = NodeFile(name, return self._node(name, self.TYPE_FILE, relpath, parent,
stat.st_size, size=st.st_size, md5=md5)
md5,
maccess, def dir_node(self, name, path, parent, storagepath):
parent=parent) ''' create a new node representing a directory '''
if self.arc:
ext = os.path.splitext(path)[1][1:]
if ext.lower() in self.decomp.get_formats():
self._debug(f'{path} is an archive')
names = self.decomp.get_names(path)
self.list_to_tree(node, names)
else:
self._debug(f'{path} is NOT an archive')
return node
def new_dir_node(self, name: str, path: str,
parent: NodeAny) -> NodeDir:
"""create a new node representing a directory"""
path = os.path.abspath(path) path = os.path.abspath(path)
maccess = os.path.getmtime(path) relpath = os.path.relpath(path, start=storagepath)
return NodeDir(name, return self._node(name, self.TYPE_DIR, relpath, parent)
0,
maccess,
parent=parent)
def new_storage_node(self, name: str, def storage_node(self, name, path, parent, attr=None):
path: str, ''' create a new node representing a storage '''
parent: str,
attrs: Dict[str, Any]) \
-> NodeStorage:
"""create a new node representing a storage"""
path = os.path.abspath(path) path = os.path.abspath(path)
free = shutil.disk_usage(path).free free = psutil.disk_usage(path).free
total = shutil.disk_usage(path).total total = psutil.disk_usage(path).total
epoch = int(time.time())
return NodeStorage(name,
free,
total,
0,
epoch,
self.attrs_to_string(attrs),
parent=parent)
def new_archive_node(self,
name: str,
parent: str,
archive: str) -> NodeArchived:
"""create a new node for archive data"""
return NodeArchived(name=name,
parent=parent, nodesize=0, md5='',
archive=archive)
###############################################################
# node management
###############################################################
def update_metanode(self, top: NodeTop) -> NodeMeta:
"""create or update meta node information"""
meta = self._get_meta_node(top)
epoch = int(time.time()) epoch = int(time.time())
if not meta: return anytree.AnyNode(name=name, type=self.TYPE_STORAGE, free=free,
attrs: Dict[str, Any] = {} total=total, parent=parent, attr=attr, ts=epoch)
attrs['created'] = epoch
attrs['created_version'] = VERSION
meta = NodeMeta(name=nodes.NAME_META,
attr=attrs)
meta.attr['access'] = epoch
meta.attr['access_version'] = VERSION
return meta
def _get_meta_node(self, top: NodeTop) -> Optional[NodeMeta]:
"""return the meta node if any"""
try:
found = next(filter(lambda x: x.type == nodes.TYPE_META,
top.children))
return cast(NodeMeta, found)
except StopIteration:
return None
def clean_not_flagged(self, top: NodeTop) -> int: def _node(self, name, type, relpath, parent, size=None, md5=None):
"""remove any node not flagged and clean flags""" ''' generic node creation '''
cnt = 0 return anytree.AnyNode(name=name, type=type, relpath=relpath,
for node in anytree.PreOrderIter(top): parent=parent, size=size, md5=md5)
typcast_node(node)
if node.type not in [nodes.TYPE_DIR, nodes.TYPE_FILE]:
continue
if self._clean(node):
cnt += 1
return cnt
def _clean(self, node: NodeAny) -> bool:
"""remove node if not flagged"""
if not node.flagged():
node.parent = None
return True
node.unflag()
return False
############################################################### ###############################################################
# printing # printing
############################################################### ###############################################################
def _print_node_csv(self, node: NodeAny, def _print_node(self, node, pre='', withpath=False,
sep: str = ',', withdepth=False, withstorage=False):
raw: bool = False) -> None: ''' print a node '''
""" if node.type == self.TYPE_TOP:
print a node to csv Logger.out('{}{}'.format(pre, node.name))
@node: the node to consider elif node.type == self.TYPE_FILE:
@sep: CSV separator character name = node.name
@raw: print raw size rather than human readable if withpath:
""" name = node.relpath
typcast_node(node) if withstorage:
if not node: storage = self._get_storage(node)
return attr = ''
if node.type == nodes.TYPE_TOP: if node.md5:
return attr = ', md5:{}'.format(node.md5)
if node.type == nodes.TYPE_STORAGE: compl = 'size:{}{}'.format(utils.human(node.size), attr)
self.csv_printer.print_storage(node, if withstorage:
sep=sep, compl += ', storage:{}'.format(Logger.bold(storage.name))
raw=raw) Logger.file(pre, name, compl)
elif node.type == self.TYPE_DIR:
name = node.name
if withpath:
name = node.relpath
depth = ''
if withdepth:
depth = len(node.children)
if withstorage:
storage = self._get_storage(node)
attr = []
if node.size:
attr.append(['totsize', utils.human(node.size)])
if withstorage:
attr.append(['storage', Logger.bold(storage.name)])
Logger.dir(pre, name, depth=depth, attr=attr)
elif node.type == self.TYPE_STORAGE:
hf = utils.human(node.free)
ht = utils.human(node.total)
name = '{} (free:{}, total:{})'.format(node.name, hf, ht)
Logger.storage(pre, name, node.attr)
else: else:
self.csv_printer.print_node(node, Logger.err('Weird node encountered: {}'.format(node))
sep=sep, # Logger.out('{}{}'.format(pre, node.name))
raw=raw)
def _print_node_du(self, node: NodeAny,
raw: bool = False) -> None:
"""
print node du style
"""
typcast_node(node)
thenodes = self._get_entire_tree(node,
dironly=True)
for thenode in thenodes:
self.native_printer.print_du(thenode, raw=raw)
def _print_node_native(self, node: NodeAny, def print_tree(self, node, style=anytree.ContRoundStyle()):
pre: str = '', ''' print the tree similar to unix tool "tree" '''
withpath: bool = False,
withnbchildren: bool = False,
withstorage: bool = False,
raw: bool = False) -> None:
"""
print a node
@node: the node to print
@pre: string to print before node
@withpath: print the node path
@withnbchildren: print the node nb children
@withstorage: print the node storage it belongs to
@raw: print raw size rather than human readable
"""
typcast_node(node)
if node.type == nodes.TYPE_TOP:
# top node
self.native_printer.print_top(pre, node.get_name())
elif node.type == nodes.TYPE_FILE:
# node of type file
self.native_printer.print_file(pre, node,
withpath=withpath,
withstorage=withstorage,
raw=raw)
elif node.type == nodes.TYPE_DIR:
# node of type directory
self.native_printer.print_dir(pre,
node,
withpath=withpath,
withstorage=withstorage,
withnbchildren=withnbchildren,
raw=raw)
elif node.type == nodes.TYPE_STORAGE:
# node of type storage
self.native_printer.print_storage(pre,
node,
raw=raw)
elif node.type == nodes.TYPE_ARCHIVED:
# archive node
if self.arc:
self.native_printer.print_archive(pre, node.name, node.archive)
else:
Logger.err(f'bad node encountered: {node}')
def print_tree(self, node: NodeAny,
fmt: str = 'native',
raw: bool = False) -> None:
"""
print the tree in different format
@node: start node
@style: when fmt=native, defines the tree style
@fmt: output format
@raw: print the raw size rather than human readable
"""
if fmt == 'native':
# "tree" style
rend = anytree.RenderTree(node, childiter=self._sort_tree)
for pre, _, thenode in rend:
self._print_node_native(thenode, pre=pre,
withnbchildren=True, raw=raw)
elif fmt == 'csv':
# csv output
self._print_nodes_csv(node, raw=raw)
elif fmt == 'csv-with-header':
# csv output
self.csv_printer.print_header()
self._print_nodes_csv(node, raw=raw)
def _print_nodes_csv(self, node: NodeAny,
raw: bool = False) -> None:
"""print the tree to csv"""
rend = anytree.RenderTree(node, childiter=self._sort_tree) rend = anytree.RenderTree(node, childiter=self._sort_tree)
for _, _, item in rend: for pre, fill, node in rend:
self._print_node_csv(item, raw=raw) self._print_node(node, pre=pre, withdepth=True)
@staticmethod
def _fzf_prompt(strings: Any) -> Any:
"""prompt with fzf"""
try:
from pyfzf.pyfzf import FzfPrompt # pylint: disable=C0415 # noqa
fzf = FzfPrompt()
selected = fzf.prompt(strings)
return selected
except ModuleNotFoundError:
Logger.err('install pyfzf to use fzf')
return None
def _to_fzf(self, node: NodeAny, fmt: str) -> None:
"""
fzf prompt with list and print selected node(s)
@node: node to start with
@fmt: output format for selected nodes
"""
rendered = anytree.RenderTree(node, childiter=self._sort_tree)
the_nodes = {}
# construct node names list
for _, _, rend in rendered:
if not rend:
continue
parents = rend.get_fullpath()
storage = rend.get_storage_node()
fullpath = os.path.join(storage.get_name(), parents)
the_nodes[fullpath] = rend
# prompt with fzf
paths = self._fzf_prompt(the_nodes.keys())
# print the resulting tree
subfmt = fmt.replace('fzf-', '')
for path in paths:
if not path:
continue
if path not in the_nodes:
continue
rend = the_nodes[path]
self.print_tree(rend, fmt=subfmt)
@staticmethod
def to_dot(top: NodeTop,
path: str = 'tree.dot') -> str:
"""export to dot for graphing"""
anytree.exporter.DotExporter(top).to_dotfile(path)
Logger.info(f'dot file created under \"{path}\"')
return f'dot {path} -T png -o /tmp/tree.png'
############################################################### ###############################################################
# searching # searching
############################################################### ###############################################################
def find(self, top: NodeTop, def find_name(self, root, key, script=False):
key: str, ''' find files based on their names '''
script: bool = False, if self.verbose:
only_dir: bool = False, Logger.info('searching for \"{}\"'.format(key))
startnode: Optional[NodeAny] = None, self.term = key
fmt: str = 'native', found = anytree.findall(root, filter_=self._find_name)
raw: bool = False) -> List[NodeAny]: paths = []
""" for f in found:
find files based on their names if f.type == self.TYPE_STORAGE:
@top: top node # ignore storage nodes
@key: term to search for continue
@script: output script self._print_node(f, withpath=True, withdepth=True,
@directory: only search for directories withstorage=True)
@startpath: node to start with paths.append(f.relpath)
@fmt: output format
@raw: raw size output
returns the found nodes
"""
self._debug(f'searching for \"{key}\"')
# search for nodes based on path
start: Optional[NodeAny] = top
if startnode:
start = self.get_node(top, startnode)
filterfunc = self._callback_find_name(key, only_dir)
found = anytree.findall(start, filter_=filterfunc)
self._debug(f'found {len(found)} node(s)')
# compile found nodes
paths = {}
for item in found:
typcast_node(item)
item.set_name(item.get_name())
key = item.get_fullpath()
paths[key] = item
# handle fzf mode
if fmt.startswith('fzf'):
selected = self._fzf_prompt(paths.keys())
newpaths = {}
subfmt = fmt.replace('fzf-', '')
for item in selected:
if item not in paths:
continue
newpaths[item] = paths[item]
self.print_tree(newpaths[item], fmt=subfmt)
paths = newpaths
else:
if fmt == 'native':
for _, item in paths.items():
self._print_node_native(item,
withpath=True,
withnbchildren=True,
withstorage=True,
raw=raw)
elif fmt.startswith('csv'):
if fmt == 'csv-with-header':
self.csv_printer.print_header()
for _, item in paths.items():
self._print_node_csv(item, raw=raw)
# execute script if any
if script: if script:
tmp = ['${source}/' + x for x in paths] tmp = ['${source}/'+x for x in paths]
tmpstr = ' '.join(tmp) cmd = 'op=file; source=/media/mnt; $op {}'.format(' '.join(tmp))
cmd = f'op=file; source=/media/mnt; $op {tmpstr}'
Logger.info(cmd) Logger.info(cmd)
return found
return list(paths.values()) def _find_name(self, node):
''' callback for finding files '''
def _callback_find_name(self, term: str, only_dir: bool) -> Any: if self.term.lower() in node.name.lower():
"""callback for finding files""" return True
def find_name(node: NodeAny) -> bool: return False
typcast_node(node)
path = node.get_fullpath()
if node.type == nodes.TYPE_STORAGE:
# ignore storage nodes
return False
if node.type == nodes.TYPE_TOP:
# ignore top nodes
return False
if node.type == nodes.TYPE_META:
# ignore meta nodes
return False
if only_dir and node.type == nodes.TYPE_DIR:
# ignore non directory
return False
# filter
if not term:
return True
if term in path:
return True
if self.debug:
Logger.debug(f'match \"{path}\" with \"{term}\"')
if fnmatch.fnmatch(path, term):
return True
# ignore
return False
return find_name
###############################################################
# fixsizes
###############################################################
def fixsizes(self, top: NodeTop) -> None:
"""fix node sizes"""
typcast_node(top)
rend = anytree.RenderTree(top)
for _, _, thenode in rend:
typcast_node(thenode)
thenode.nodesize = thenode.get_rec_size()
############################################################### ###############################################################
# ls # climbing
############################################################### ###############################################################
def list(self, top: NodeTop, def walk(self, root, path, rec=False):
path: str, ''' walk the tree for ls based on names '''
rec: bool = False, if self.verbose:
fmt: str = 'native', Logger.info('walking path: \"{}\"'.format(path))
raw: bool = False) -> List[NodeAny]: r = anytree.resolver.Resolver('name')
"""
list nodes for "ls"
@top: top node
@path: path to search for
@rec: recursive walk
@fmt: output format
@raw: print raw size
"""
self._debug(f'ls walking path: \"{path}\" from \"{top.get_name()}\"')
resolv = anytree.resolver.Resolver('name')
found = [] found = []
try: try:
if '*' in path or '?' in path: found = r.glob(root, path)
# we need to handle glob
self._debug('glob ls...')
found = resolv.glob(top, path)
else:
# we have a canonical path
self._debug('get ls...')
foundone = resolv.get(top, path)
cast(NodeAny, foundone)
typcast_node(foundone)
if foundone and foundone.may_have_children():
# let's find its children as well
modpath = os.path.join(path, '*')
found = resolv.glob(top, modpath)
else:
found = [foundone]
if len(found) < 1: if len(found) < 1:
# nothing found
self._debug('nothing found')
return [] return []
if rec: if rec:
# print the entire tree self.print_tree(found[0].parent)
self.print_tree(found[0].parent, fmt=fmt, raw=raw) return
return found found = sorted(found, key=self._sort, reverse=self.sortsize)
self._print_node(found[0].parent,
# sort found nodes withpath=False, withdepth=True)
found = sorted(found, key=os_sort_keygen(self._sort)) for f in found:
self._print_node(f, withpath=False, pre='- ', withdepth=True)
# print all found nodes
if fmt == 'csv-with-header':
self.csv_printer.print_header()
for item in found:
if fmt == 'native':
self._print_node_native(item,
withpath=True,
withnbchildren=True,
raw=raw)
elif fmt.startswith('csv'):
self._print_node_csv(item, raw=raw)
elif fmt.startswith('fzf'):
self._to_fzf(item, fmt)
except anytree.resolver.ChildResolverError: except anytree.resolver.ChildResolverError:
pass pass
return found return found
###############################################################
# du
###############################################################
def diskusage(self, top: NodeTop,
path: str,
raw: bool = False) -> List[NodeAny]:
"""disk usage"""
self._debug(f'du walking path: \"{path}\" from \"{top.get_name()}\"')
resolv = anytree.resolver.Resolver('name')
found: NodeAny
try:
# we have a canonical path
self._debug('get du...')
found = resolv.get(top, path)
if not found:
# nothing found
self._debug('nothing found')
return []
self._debug(f'du found: {found}')
self._print_node_du(found, raw=raw)
except anytree.resolver.ChildResolverError:
pass
return found
###############################################################
# tree creation
###############################################################
def _add_entry(self, name: str,
top: NodeTop,
resolv: Any) -> None:
"""add an entry to the tree"""
entries = name.rstrip(os.sep).split(os.sep)
if len(entries) == 1:
self.new_archive_node(name, top, top.get_name())
return
sub = os.sep.join(entries[:-1])
nodename = entries[-1]
try:
parent = resolv.get(top, sub)
parent = self.new_archive_node(nodename, parent, top.get_name())
except anytree.resolver.ChildResolverError:
self.new_archive_node(nodename, top, top.get_name())
def list_to_tree(self, parent: NodeAny, names: List[str]) -> None:
"""convert list of files to a tree"""
if not names:
return
resolv = anytree.resolver.Resolver('name')
for name in names:
name = name.rstrip(os.sep)
self._add_entry(name, parent, resolv)
############################################################### ###############################################################
# diverse # diverse
############################################################### ###############################################################
def _get_entire_tree(self, start: NodeAny, def _sort_tree(self, items):
dironly: bool = False) -> List[NodeAny]: ''' sorting a list of items '''
"""
get entire tree and sort it
"""
typcast_node(start)
rend = anytree.RenderTree(start)
thenodes = []
if dironly:
for _, _, thenode in rend:
typcast_node(thenode)
if thenode.type == nodes.TYPE_DIR:
thenodes.append(thenode)
else:
thenodes = [x for _, _, x in rend]
return sorted(thenodes, key=os_sort_keygen(self._sort))
def _sort_tree(self,
items: List[NodeAny]) -> List[NodeAny]:
"""sorting a list of items"""
return sorted(items, key=self._sort, reverse=self.sortsize) return sorted(items, key=self._sort, reverse=self.sortsize)
def _sort(self, lst: NodeAny) -> Any: def _sort(self, x):
"""sort a list"""
if self.sortsize: if self.sortsize:
return self._sort_size(lst) return self._sort_size(x)
return self._sort_fs(lst) return self._sort_fs(x)
@staticmethod def _sort_fs(self, n):
def _sort_fs(node: NodeAny) -> str: ''' sorting nodes dir first and alpha '''
"""sort by name""" return (n.type, n.name.lstrip('\.').lower())
# to sort by types then name
return str(node.name)
@staticmethod def _sort_size(self, n):
def _sort_size(node: NodeAny) -> float: ''' sorting nodes by size '''
"""sorting nodes by size"""
try: try:
if not node.nodesize: if not n.size:
return 0 return 0
return float(node.nodesize) return n.size
except AttributeError: except AttributeError:
return 0 return 0
@staticmethod def to_dot(self, node, path='tree.dot'):
def _get_hash(path: str) -> str: ''' export to dot for graphing '''
"""return md5 hash of node""" anytree.exporter.DotExporter(node).to_dotfile(path)
Logger.info('dot file created under \"{}\"'.format(path))
return 'dot {} -T png -o /tmp/tree.png'.format(path)
def _get_storage(self, node):
''' recursively traverse up to find storage '''
return node.ancestors[1]
def get_meta_node(self, top):
''' return the meta node if any '''
try: try:
return md5sum(path) return next(filter(lambda x: x.type == self.TYPE_META,
except CatcliException as exc: top.children))
Logger.err(str(exc)) except StopIteration:
return '' return None
def _debug(self, string: str) -> None: def rec_size(self, node):
"""print debug""" ''' recursively traverse tree and store dir size '''
if not self.debug: if self.verbose:
return Logger.info('getting folder size recursively')
Logger.debug(string) if node.type == self.TYPE_FILE:
return node.size
size = 0
for i in node.children:
if node.type == self.TYPE_DIR:
size += self.rec_size(i)
if node.type == self.TYPE_STORAGE:
self.rec_size(i)
else:
continue
node.size = size
return size

@ -1,339 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2023, deadc0de6
Class that represents a node in the catalog tree
"""
# pylint: disable=W0622
import os
from typing import Dict, Any, cast
from anytree import NodeMixin
from catcli.exceptions import CatcliException
from catcli.utils import fix_badchars
TYPE_TOP = 'top'
TYPE_FILE = 'file'
TYPE_DIR = 'dir'
TYPE_ARCHIVED = 'arc'
TYPE_STORAGE = 'storage'
TYPE_META = 'meta'
NAME_TOP = 'top'
NAME_META = 'meta'
def typcast_node(node: Any) -> None:
"""typecast node to its sub type"""
if node.type == TYPE_TOP:
node.__class__ = NodeTop
elif node.type == TYPE_FILE:
node.__class__ = NodeFile
elif node.type == TYPE_DIR:
node.__class__ = NodeDir
elif node.type == TYPE_ARCHIVED:
node.__class__ = NodeArchived
elif node.type == TYPE_STORAGE:
node.__class__ = NodeStorage
elif node.type == TYPE_META:
node.__class__ = NodeMeta
else:
raise CatcliException(f"bad node: {node}")
class NodeAny(NodeMixin): # type: ignore
"""generic node"""
def __init__(self, # type: ignore[no-untyped-def]
name=None,
size=0,
parent=None,
children=None):
"""build generic node"""
super().__init__()
self.name = name
self.nodesize = size
self.parent = parent
if children:
self.children = children
def get_name(self) -> str:
"""get node name"""
return fix_badchars(self.name)
def set_name(self, name: str) -> None:
"""set node name"""
self.name = fix_badchars(name)
def has_attr(self, attr: str) -> bool:
"""return True if node has attr as attribute"""
return attr in self.__dict__
def may_have_children(self) -> bool:
"""can node contains sub"""
raise NotImplementedError
def _to_str(self) -> str:
ret = str(self.__class__) + ": " + str(self.__dict__)
if self.children:
ret += '\n'
for child in self.children:
ret += f' child => {child}\n'
return ret
def __str__(self) -> str:
return self._to_str()
def get_fullpath(self) -> str:
"""return full path to this node"""
path = self.get_name()
if self.parent:
typcast_node(self.parent)
ppath = self.parent.get_fullpath()
path = os.path.join(ppath, path)
return fix_badchars(path)
def get_rec_size(self) -> int:
"""recursively traverse tree and return size"""
totsize: int = self.nodesize
for node in self.children:
typcast_node(node)
totsize += node.get_rec_size()
return totsize
def get_storage_node(self) -> NodeMixin:
"""recursively traverse up to find storage"""
return None
def flagged(self) -> bool:
"""is flagged"""
if not hasattr(self, '_flagged'):
return False
return self._flagged
def flag(self) -> None:
"""flag a node"""
self._flagged = True # pylint: disable=W0201
def unflag(self) -> None:
"""unflag node"""
self._flagged = False # pylint: disable=W0201
delattr(self, '_flagged')
class NodeTop(NodeAny):
"""a top node"""
def __init__(self, # type: ignore[no-untyped-def]
name: str,
children=None):
"""build a top node"""
super().__init__() # type: ignore[no-untyped-call]
self.name = name
self.type = TYPE_TOP
self.parent = None
if children:
self.children = children
def get_fullpath(self) -> str:
"""return full path to this node"""
return ''
def may_have_children(self) -> bool:
"""can node contains sub"""
return True
def get_rec_size(self) -> int:
"""
recursively traverse tree and return size
also ensure to update the size on the way
"""
size = super().get_rec_size()
self.nodesize = size
return size
def __str__(self) -> str:
return self._to_str()
class NodeFile(NodeAny):
"""a file node"""
def __init__(self, # type: ignore[no-untyped-def]
name: str,
nodesize: int,
md5: str,
maccess: float,
parent=None,
children=None):
"""build a file node"""
super().__init__() # type: ignore[no-untyped-call]
self.name = name
self.type = TYPE_FILE
self.nodesize = nodesize
self.md5 = md5
self.maccess = maccess
self.parent = parent
if children:
self.children = children
def may_have_children(self) -> bool:
"""can node contains sub"""
return False
def get_storage_node(self) -> NodeAny:
"""recursively traverse up to find storage"""
return cast(NodeStorage, self.ancestors[1])
def __str__(self) -> str:
return self._to_str()
class NodeDir(NodeAny):
"""a directory node"""
def __init__(self, # type: ignore[no-untyped-def]
name: str,
nodesize: int,
maccess: float,
parent=None,
children=None):
"""build a directory node"""
super().__init__() # type: ignore[no-untyped-call]
self.name = name
self.type = TYPE_DIR
self.nodesize = nodesize
self.maccess = maccess
self.parent = parent
if children:
self.children = children
def may_have_children(self) -> bool:
"""can node contains sub"""
return True
def get_rec_size(self) -> int:
"""
recursively traverse tree and return size
also ensure to update the size on the way
"""
size = super().get_rec_size()
self.nodesize = size
return size
def get_storage_node(self) -> NodeAny:
"""recursively traverse up to find storage"""
return cast(NodeStorage, self.ancestors[1])
def __str__(self) -> str:
return self._to_str()
class NodeArchived(NodeAny):
"""an archived node"""
def __init__(self, # type: ignore[no-untyped-def]
name: str,
nodesize: int,
md5: str,
archive: str,
parent=None,
children=None):
"""build an archived node"""
super().__init__() # type: ignore[no-untyped-call]
self.name = name
self.type = TYPE_ARCHIVED
self.nodesize = nodesize
self.md5 = md5
self.archive = archive
self.parent = parent
if children:
self.children = children
def may_have_children(self) -> bool:
"""can node contains sub"""
return False
def get_storage_node(self) -> NodeAny:
"""recursively traverse up to find storage"""
return cast(NodeStorage, self.ancestors[1])
def __str__(self) -> str:
return self._to_str()
class NodeStorage(NodeAny):
"""a storage node"""
def __init__(self, # type: ignore[no-untyped-def]
name: str,
free: int,
total: int,
nodesize: int,
ts: float,
attr: str,
parent=None,
children=None):
"""build a storage node"""
super().__init__() # type: ignore[no-untyped-call]
self.name = name
self.type = TYPE_STORAGE
self.free = free
self.total = total
self.attr = attr
self.nodesize = nodesize
self.ts = ts # pylint: disable=C0103
self.parent = parent
if children:
self.children = children
def may_have_children(self) -> bool:
"""can node contains sub"""
return True
def get_rec_size(self) -> int:
"""
recursively traverse tree and return size
also ensure to update the size on the way
"""
size = super().get_rec_size()
self.nodesize = size
return size
def get_storage_node(self) -> NodeAny:
"""recursively traverse up to find storage"""
return self
def __str__(self) -> str:
return self._to_str()
class NodeMeta(NodeAny):
"""a meta node"""
def __init__(self, # type: ignore[no-untyped-def]
name: str,
attr: Dict[str, Any],
parent=None,
children=None):
"""build a meta node"""
super().__init__() # type: ignore[no-untyped-call]
self.name = name
self.type = TYPE_META
self.attr = attr
self.parent = parent
if children:
self.children = children
def may_have_children(self) -> bool:
"""can node contains sub"""
return False
def get_rec_size(self) -> int:
"""recursively traverse tree and return size"""
return 0
def __str__(self) -> str:
return self._to_str()

@ -1,39 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2024, deadc0de6
nodes helpers
"""
import os
# local imports
from catcli import nodes
def path_to_top(path: str) -> str:
"""path pivot under top"""
pre = f"{os.path.sep}{nodes.NAME_TOP}"
if not path.startswith(pre):
# prepend with top node path
path = pre + path
return path
def path_to_search_all(path: str) -> str:
"""path to search for all subs"""
if not path:
path = os.path.sep
if not path.startswith(os.path.sep):
path = os.path.sep + path
pre = f"{os.path.sep}{nodes.NAME_TOP}"
if not path.startswith(pre):
# prepend with top node path
path = pre + path
# if not path.endswith(os.path.sep):
# # ensure ends with a separator
# path += os.path.sep
# if not path.endswith(WILD):
# # add wild card
# path += WILD
return path

@ -1,81 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2024, deadc0de6
Class for printing nodes in csv format
"""
import sys
from typing import List
from catcli.nodes import NodeAny, NodeStorage, TYPE_DIR
from catcli.utils import size_to_str, epoch_to_str
class CsvPrinter:
"""a node printer class"""
DEFSEP = ','
CSV_HEADER = ('name,type,path,size,indexed_at,'
'maccess,md5,nbfiles,free_space,'
'total_space,meta')
def _print_entries(self, entries: List[str], sep: str = DEFSEP) -> None:
line = sep.join(['"' + o + '"' for o in entries])
if len(line) > 0:
sys.stdout.write(f'{line}\n')
def print_header(self) -> None:
"""print csv header"""
sys.stdout.write(f'{self.CSV_HEADER}\n')
def print_storage(self, node: NodeStorage,
sep: str = DEFSEP,
raw: bool = False) -> None:
"""print a storage node"""
out = []
out.append(node.get_name()) # name
out.append(node.type) # type
out.append('') # fake full path
size = node.get_rec_size()
out.append(size_to_str(size, raw=raw)) # size
out.append(epoch_to_str(node.ts)) # indexed_at
out.append('') # fake maccess
out.append('') # fake md5
out.append(str(len(node.children))) # nbfiles
# fake free_space
out.append(size_to_str(node.free, raw=raw))
# fake total_space
out.append(size_to_str(node.total, raw=raw))
out.append(node.attr) # meta
self._print_entries(out, sep=sep)
def print_node(self, node: NodeAny,
sep: str = DEFSEP,
raw: bool = False) -> None:
"""print other nodes"""
out = []
out.append(node.get_name().replace('"', '""')) # name
out.append(node.type) # type
fullpath = node.get_fullpath()
out.append(fullpath.replace('"', '""')) # full path
out.append(size_to_str(node.nodesize, raw=raw)) # size
storage = node.get_storage_node()
out.append(epoch_to_str(storage.ts)) # indexed_at
if node.has_attr('maccess'):
out.append(epoch_to_str(node.maccess)) # maccess
else:
out.append('') # fake maccess
if node.has_attr('md5'):
out.append(node.md5) # md5
else:
out.append('') # fake md5
if node.type == TYPE_DIR:
out.append(str(len(node.children))) # nbfiles
else:
out.append('') # fake nbfiles
out.append('') # fake free_space
out.append('') # fake total_space
out.append('') # fake meta
self._print_entries(out, sep=sep)

@ -1,165 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2022, deadc0de6
Class for printing nodes in native format
"""
import sys
from catcli.nodes import NodeFile, NodeDir, \
NodeStorage, NodeAny, typcast_node
from catcli.colors import Colors
from catcli.logger import Logger
from catcli.utils import fix_badchars, size_to_str, \
epoch_to_str
COLOR_STORAGE = Colors.YELLOW
COLOR_FILE = Colors.WHITE
COLOR_DIRECTORY = Colors.BLUE
COLOR_ARCHIVE = Colors.PURPLE
COLOR_TS = Colors.CYAN
COLOR_SIZE = Colors.GREEN
FULLPATH_IN_NAME = True
class NativePrinter:
"""a node printer class"""
STORAGE = 'storage'
ARCHIVE = 'archive'
NBFILES = 'nbfiles'
def print_du(self, node: NodeAny,
raw: bool = False) -> None:
"""print du style"""
typcast_node(node)
name = node.get_fullpath()
size = node.nodesize
line = size_to_str(size, raw=raw).ljust(10, ' ')
out = f'{COLOR_SIZE}{line}{Colors.RESET}'
out += ' '
out += f'{COLOR_FILE}{name}{Colors.RESET}'
sys.stdout.write(f'{out}\n')
def print_top(self, pre: str, name: str) -> None:
"""print top node"""
sys.stdout.write(f'{pre}{name}\n')
def print_storage(self, pre: str,
node: NodeStorage,
raw: bool = False) -> None:
"""print a storage node"""
# construct name
name = node.get_name()
# construct attrs
attrs = []
# nb files
attrs.append(f'nbfiles:{len(node.children)}')
# the children size
recsize = node.get_rec_size()
sizestr = size_to_str(recsize, raw=raw)
attrs.append(f'totsize:{sizestr}')
# free
pcent = 0.0
if node.total > 0:
pcent = node.free * 100 / node.total
attrs.append(f'free:{pcent:.1f}%')
# du
sztotal = size_to_str(node.total, raw=raw)
szused = size_to_str(node.total - node.free, raw=raw)
attrs.append(f'du:{szused}/{sztotal}')
# timestamp
if node.has_attr('ts'):
attrs.append(f'date:{epoch_to_str(node.ts)}')
# print
out = f'{pre}{Colors.UND}{self.STORAGE}{Colors.RESET}: '
out += f'{COLOR_STORAGE}{name}{Colors.RESET}'
if attrs:
out += f' [{Colors.WHITE}{"|".join(attrs)}{Colors.RESET}]'
sys.stdout.write(f'{out}\n')
def print_file(self, pre: str,
node: NodeFile,
withpath: bool = False,
withstorage: bool = False,
raw: bool = False) -> None:
"""print a file node"""
# construct name
name = node.get_name()
storage = node.get_storage_node()
if withpath:
name = node.get_fullpath()
# construct attributes
attrs = []
if node.md5:
attrs.append(f'md5:{node.md5}')
if withstorage:
content = Logger.get_bold_text(storage.get_name())
attrs.append(f'storage:{content}')
# print
out = []
out.append(f'{pre}')
out.append(f'{COLOR_FILE}{name}{Colors.RESET}')
size = 0
if node.nodesize:
size = node.nodesize
line = size_to_str(size, raw=raw)
out.append(f'{COLOR_SIZE}{line}{Colors.RESET}')
if node.has_attr('maccess'):
line = epoch_to_str(node.maccess)
out.append(f'{COLOR_TS}{line}{Colors.RESET}')
if attrs:
out.append(f'{Colors.GRAY}[{",".join(attrs)}]{Colors.RESET}')
out = [x for x in out if x]
sys.stdout.write(f'{" ".join(out)}\n')
def print_dir(self, pre: str,
node: NodeDir,
withpath: bool = False,
withstorage: bool = False,
withnbchildren: bool = False,
raw: bool = False) -> None:
"""print a directory node"""
# construct name
name = node.get_name()
storage = node.get_storage_node()
if withpath:
name = node.get_fullpath()
# construct attrs
attrs = []
if withnbchildren:
nbchildren = len(node.children)
attrs.append(f'{self.NBFILES}:{nbchildren}')
if withstorage:
attrs.append(f"storage:{Logger.get_bold_text(storage.get_name())}")
# print
out = []
out.append(f'{pre}')
out.append(f'{COLOR_DIRECTORY}{name}{Colors.RESET}')
size = 0
if node.nodesize:
size = node.nodesize
line = size_to_str(size, raw=raw)
out.append(f'{COLOR_SIZE}{line}{Colors.RESET}')
if node.has_attr('maccess'):
line = epoch_to_str(node.maccess)
out.append(f'{COLOR_TS}{line}{Colors.RESET}')
if attrs:
out.append(f'{Colors.GRAY}[{",".join(attrs)}]{Colors.RESET}')
out = [x for x in out if x]
sys.stdout.write(f'{" ".join(out)}\n')
def print_archive(self, pre: str,
name: str, archive: str) -> None:
"""print an archive"""
name = fix_badchars(name)
out = f'{pre}{COLOR_ARCHIVE}{name}{Colors.RESET} '
out += f'{Colors.GRAY}[{self.ARCHIVE}:{archive}]{Colors.RESET}'
sys.stdout.write(f'{out}\n')

@ -7,86 +7,61 @@ helpers
import os import os
import hashlib import hashlib
import sys
import tempfile import tempfile
import subprocess import subprocess
import datetime
import string
# local imports # local imports
from catcli.exceptions import CatcliException from catcli.logger import Logger
WILD = '*' def md5sum(path):
''' calculate md5 sum of a file '''
p = os.path.realpath(path)
def md5sum(path: str) -> str: if not os.path.exists(p):
""" Logger.err('\nunable to get md5sum on {}'.format(path))
calculate md5 sum of a file return None
may raise exception
"""
rpath = os.path.realpath(path)
if not os.path.exists(rpath):
raise CatcliException(f'md5sum - file does not exist: {rpath}')
try: try:
with open(rpath, mode='rb') as file: with open(p, mode='rb') as f:
hashv = hashlib.md5() d = hashlib.md5()
while True: while True:
buf = file.read(4096) buf = f.read(4096)
if not buf: if not buf:
break break
hashv.update(buf) d.update(buf)
return hashv.hexdigest() return d.hexdigest()
except PermissionError: except PermissionError:
pass pass
except OSError as exc: return None
raise CatcliException(f'md5sum error: {exc}') from exc
return ''
def size_to_str(size: float, def human(size):
raw: bool = True) -> str: ''' human readable size '''
"""convert size to string, optionally human readable"""
div = 1024. div = 1024.
suf = ['B', 'K', 'M', 'G', 'T', 'P'] suf = ['B', 'K', 'M', 'G', 'T', 'P']
if raw or size < div: if size < div:
return f'{size}' return '{}'.format(size)
for i in suf: for i in suf:
if size < div: if size < div:
return f'{size:.1f}{i}' return '{:.1f}{}'.format(size, i)
size = size / div size = size / div
sufix = suf[-1] return '{:.1f}{}'.format(size, suf[-1])
return f'{size:.1f}{sufix}'
def epoch_to_str(epoch: float) -> str:
"""convert epoch to string"""
if not epoch:
return ''
fmt = '%Y-%m-%d %H:%M:%S'
timestamp = datetime.datetime.fromtimestamp(epoch)
return timestamp.strftime(fmt)
def ask(question):
def ask(question: str) -> bool: ''' ask the user what to do '''
"""ask the user what to do""" resp = input('{} [y|N] ? '.format(question))
resp = input(f'{question} [y|N] ? ')
return resp.lower() == 'y' return resp.lower() == 'y'
def edit(data: str) -> str: def edit(string):
"""edit the information with the default EDITOR""" ''' edit the information with the default EDITOR '''
content = fix_badchars(data) string = string.encode('utf-8')
editor = os.environ.get('EDITOR', 'vim') EDITOR = os.environ.get('EDITOR','vim')
with tempfile.NamedTemporaryFile(prefix='catcli', suffix='.tmp') as file: with tempfile.NamedTemporaryFile(prefix='catcli', suffix='.tmp') as f:
file.write(content.encode('utf-8')) f.write(string)
file.flush() f.flush()
subprocess.call([editor, file.get_name()]) subprocess.call([EDITOR, f.name])
file.seek(0) f.seek(0)
new = file.read() new = f.read()
return new.decode('utf-8') return new.decode('utf-8')
def fix_badchars(data: str) -> str:
"""fix none utf-8 chars in string"""
data = "".join(x for x in data if x in string.printable)
return data.encode("utf-8", "ignore").decode("utf-8")

@ -1,6 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2022, deadc0de6
"""
__version__ = '1.0'

@ -6,189 +6,46 @@ Catcli filesystem indexer
""" """
import os import os
from typing import Tuple, Optional import anytree
# local imports # local imports
from catcli.noder import Noder from catcli.noder import Noder
from catcli.logger import Logger from catcli.logger import Logger
from catcli.nodes import NodeAny, NodeTop
class Walker: class Walker:
"""a filesystem walker"""
MAXLINELEN = 80 - 15 MAXLINE = 80 - 15
def __init__(self, noder: Noder, def __init__(self, noder, nohash=False):
usehash: bool = True,
debug: bool = False,
logpath: str = ''):
"""
@noder: the noder to use
@hash: calculate hash of nodes
@debug: debug mode
@logpath: path where to log catalog changes on reindex
"""
self.noder = noder self.noder = noder
self.usehash = usehash self.noder.set_hashing(not nohash)
self.noder.do_hashing(self.usehash)
self.debug = debug
self.lpath = logpath
def index(self, def index(self, path, name, parentpath=None, parent=None, isdir=False):
path: str, ''' index a folder and store in tree '''
parent: NodeAny,
name: str,
storagepath: str = '') -> Tuple[str, int]:
"""
index a directory and store in tree
@path: path to index
@parent: parent node
@name: this stoarge name
"""
self._debug(f'indexing starting at {path}')
if not parent: if not parent:
# create the parent parent = noder.dir_node(name, path, parent)
parent = self.noder.new_dir_node(name,
path,
parent)
if os.path.islink(path):
rel = os.readlink(path)
abspath = os.path.join(path, rel)
if os.path.isdir(abspath):
return parent, 0
cnt = 0 cnt = 0
for (root, dirs, files) in os.walk(path): for (root, dirs, files) in os.walk(path):
for file in files: for f in files:
self._debug(f'found file {file} under {path}') sub = os.path.join(root, f)
sub = os.path.join(root, file) n = f
if not os.path.exists(sub): if len(n) > self.MAXLINE:
continue n = f[:self.MAXLINE]+'...'
self._progress(file) Logger.progr('indexing: {:80}'.format(n))
self._debug(f'index file {sub}') self.noder.file_node(os.path.basename(f), sub,
node = self.noder.new_file_node(os.path.basename(file), parent, parentpath)
sub,
parent)
if node:
cnt += 1
for adir in dirs:
self._debug(f'found dir {adir} under {path}')
base = os.path.basename(adir)
sub = os.path.join(root, adir)
self._debug(f'index directory {sub}')
if not os.path.exists(sub):
continue
dummy = self.noder.new_dir_node(base, sub, parent)
if not dummy:
continue
cnt += 1 cnt += 1
nstoragepath = os.sep.join([storagepath, base]) for d in dirs:
if not storagepath: base = os.path.basename(d)
nstoragepath = base sub = os.path.join(root, d)
_, cnt2 = self.index(sub, dummy, base, nstoragepath) dummy = self.noder.dir_node(base, sub, parent, parentpath)
_, cnt2 = self.index(sub, base,
parent=dummy, parentpath=parentpath)
cnt += cnt2 cnt += cnt2
break break
self._progress('') # clean line
return parent, cnt Logger.progr('{:80}'.format(' '))
def reindex(self, path: str, parent: NodeAny, top: NodeTop) -> int:
"""reindex a directory and store in tree"""
cnt = self._reindex(path, parent, top)
cnt += self.noder.clean_not_flagged(parent)
return cnt
def _reindex(self, path: str,
parent: NodeAny,
top: NodeTop,
storagepath: str = '') -> int:
"""
reindex a directory and store in tree
@path: directory path to re-index
@top: top node (storage)
@storagepath: rel path relative to indexed directory
"""
self._debug(f'reindexing starting at {path}')
cnt = 0
for (root, dirs, files) in os.walk(path):
for file in files:
self._debug(f'found file \"{file}\" under {path}')
sub = os.path.join(root, file)
treepath = os.path.join(storagepath, file)
reindex, node = self._need_reindex(parent, sub, treepath)
if not reindex:
self._debug(f'\tskip file {sub}')
if node:
node.flag()
continue
node = self.noder.new_file_node(os.path.basename(file),
sub,
parent)
if node:
node.flag()
cnt += 1
for adir in dirs:
self._debug(f'found dir \"{adir}\" under {path}')
base = os.path.basename(adir)
sub = os.path.join(root, adir)
treepath = os.path.join(storagepath, adir)
reindex, dummy = self._need_reindex(parent, sub, treepath)
if reindex:
dummy = self.noder.new_dir_node(base, sub,
parent)
cnt += 1
if dummy:
dummy.flag()
self._debug(f'reindexing deeper under {sub}')
nstoragepath = os.sep.join([storagepath, base])
if not storagepath:
nstoragepath = base
if dummy:
cnt2 = self._reindex(sub, dummy, top, nstoragepath)
cnt += cnt2
break
return cnt
def _need_reindex(self,
top: NodeTop,
path: str,
treepath: str) -> Tuple[bool, Optional[NodeTop]]:
"""
test if node needs re-indexing
@top: top node (storage)
@path: abs path to file
@treepath: rel path from indexed directory
"""
node, changed = self.noder.get_node_if_changed(top, path, treepath)
if not node:
self._debug(f'\t{path} does not exist')
return True, node
if node and not changed:
# ignore this node
self._debug(f'\t{path} has not changed')
return False, node
if node and changed:
# remove this node and re-add
self._debug(f'\t{path} has changed')
self._debug(f"\tremoving node {node.get_name()} for {path}")
node.parent = None
return True, node
def _debug(self, string: str) -> None: return parent, cnt
"""print to debug"""
if not self.debug:
return
Logger.debug(string)
def _progress(self, string: str) -> None:
"""print progress"""
if self.debug:
return
if not string:
# clean
Logger.progr(' ' * 80)
return
if len(string) > self.MAXLINELEN:
string = string[:self.MAXLINELEN] + '...'
Logger.progr(f'indexing: {string:80}')

@ -1,8 +1,3 @@
docopt; python_version >= '3.0' docopt; python_version >= '3.0'
types-docopt; python_version >= '3.0'
anytree; python_version >= '3.0' anytree; python_version >= '3.0'
pyfzf; python_version >= '3.0' psutil; python_version >= '3.0'
fusepy; python_version >= '3.0'
natsort; python_version >= '3.0'
cmd2; python_version >= '3.0'
gnureadline; python_version >= '3.0'

@ -0,0 +1,11 @@
[metadata]
description-file = README.md
license_file = LICENSE
[bdist_wheel]
python-tag = py3
[files]
extra_files =
LICENSE
README.md

@ -1,55 +1,45 @@
"""setup.py"""
from os import path
from setuptools import setup, find_packages from setuptools import setup, find_packages
from catcli import version from codecs import open
from os import path
import catcli
README = 'README.md' readme = 'README.md'
here = path.abspath(path.dirname(__file__)) here = path.abspath(path.dirname(__file__))
VERSION = version.__version__
REQUIRES_PYTHON = '>=3'
def read_readme(readme_path): try:
"""read readme content""" from pypandoc import convert
with open(readme_path, encoding="utf-8") as file: read_readme = lambda f: convert(f, 'rst')
return file.read() except ImportError:
print('\n[WARNING] pypandoc not found, could not convert \"{}\"\n'.format(readme))
read_readme = lambda f: open(f, 'r').read()
VERSION = catcli.__version__
URL = f'https://github.com/deadc0de6/catcli/archive/v{VERSION}.tar.gz'
setup( setup(
name='catcli', name='catcli',
version=VERSION, version=VERSION,
description='The command line catalog tool for your offline data', description='The command line catalog tool for your offline data',
long_description=read_readme(README), long_description=read_readme(readme),
long_description_content_type='text/markdown',
license_files=('LICENSE',),
url='https://github.com/deadc0de6/catcli', url='https://github.com/deadc0de6/catcli',
download_url=URL, download_url = 'https://github.com/deadc0de6/catcli/archive/v'+VERSION+'.tar.gz',
options={"bdist_wheel": {"python_tag": "py3"}},
# include anything from MANIFEST.in
include_package_data=True,
author='deadc0de6', author='deadc0de6',
author_email='deadc0de6@foo.bar', author_email='deadc0de6@foo.bar',
license='GPLv3', license='GPLv3',
python_requires=REQUIRES_PYTHON,
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.11',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
], ],
keywords='catalog commandline indexer offline', keywords='catalog commandline indexer offline',
packages=find_packages(exclude=['tests*']), packages=find_packages(exclude=['tests*']),
install_requires=['docopt', 'types-docopt', 'anytree', install_requires=['docopt', 'anytree','psutil'],
'pyfzf', 'fusepy', 'natsort', 'cmd2',
'gnureadline'],
extras_require={ extras_require={
'dev': ['check-manifest'], 'dev': ['check-manifest'],

@ -1,22 +0,0 @@
#!/usr/bin/env bash
# run me from the root of the package
source tests-ng/helper
rm -f tests-ng/assets/github.catalog.json
python3 -m catcli.catcli index -c github .github --catalog=tests-ng/assets/github.catalog.json
# edit catalog
clean_catalog "tests-ng/assets/github.catalog.json"
# native
python3 -m catcli.catcli ls -r -s -B --catalog=tests-ng/assets/github.catalog.json | \
sed -e 's/free:.*%/free:0.0%/g' \
-e 's/....-..-.. ..:..:../2023-03-09 16:20:59/g' \
-e 's#du:[^|]* |#du:0/0 |#g' > tests-ng/assets/github.catalog.native.txt
# csv
python3 -m catcli.catcli ls -r -s -B --catalog=tests-ng/assets/github.catalog.json --format=csv | \
sed -e 's/"3","[^"]*","[^"]*",""/"3","0","0",""/g' | \
sed 's/20..-..-.. ..:..:..//g' > tests-ng/assets/github.catalog.csv.txt

@ -1,6 +0,0 @@
"github","storage","","4865","","","","3","0","0",""
"FUNDING.yml","file","github/FUNDING.yml","17","","","0c6407a84d412c514007313fb3bca4de","","","",""
"codecov.yml","file","github/codecov.yml","104","","","4203204f75b43cd4bf032402beb3359d","","","",""
"workflows","dir","github/workflows","3082","","","","2","","",""
"pypi-release.yml","file","github/workflows/pypi-release.yml","691","","","57699a7a6a03e20e864f220e19f8e197","","","",""
"testing.yml","file","github/workflows/testing.yml","850","","","691df1a4d2f254b5cd04c152e7c6ccaf","","","",""

@ -1,65 +0,0 @@
{
"children": [
{
"attr": "",
"children": [
{
"maccess": 1666206037.0786593,
"md5": "0c6407a84d412c514007313fb3bca4de",
"name": "FUNDING.yml",
"size": 17,
"type": "file"
},
{
"maccess": 1704320710.7056112,
"md5": "4203204f75b43cd4bf032402beb3359d",
"name": "codecov.yml",
"size": 104,
"type": "file"
},
{
"children": [
{
"maccess": 1666206037.078865,
"md5": "57699a7a6a03e20e864f220e19f8e197",
"name": "pypi-release.yml",
"size": 691,
"type": "file"
},
{
"maccess": 1704403569.24789,
"md5": "691df1a4d2f254b5cd04c152e7c6ccaf",
"name": "testing.yml",
"size": 850,
"type": "file"
}
],
"maccess": 1704320727.2641916,
"name": "workflows",
"size": 1541,
"type": "dir"
}
],
"free": 0,
"name": "github",
"size": 1662,
"total": 0,
"ts": 1704923096,
"type": "storage"
},
{
"attr": {
"access": 1704923096,
"access_version": "0.9.6",
"created": 1704923096,
"created_version": "0.9.6"
},
"name": "meta",
"size": null,
"type": "meta"
}
],
"name": "top",
"size": null,
"type": "top"
}

@ -1,7 +0,0 @@
top
└── storage: github [nbfiles:3|totsize:4865|free:0.0%|du:0/0|date:2023-03-09 16:20:59]
├── FUNDING.yml 17 2023-03-09 16:20:59 [md5:0c6407a84d412c514007313fb3bca4de]
├── codecov.yml 104 2023-03-09 16:20:59 [md5:4203204f75b43cd4bf032402beb3359d]
└── workflows 3082 2023-03-09 16:20:59 [nbfiles:2]
├── pypi-release.yml 691 2023-03-09 16:20:59 [md5:57699a7a6a03e20e864f220e19f8e197]
└── testing.yml 850 2023-03-09 16:20:59 [md5:691df1a4d2f254b5cd04c152e7c6ccaf]

@ -1,136 +0,0 @@
#!/usr/bin/env bash
# author: deadc0de6 (https://github.com/deadc0de6)
# Copyright (c) 2023, deadc0de6
set -e
cur=$(cd "$(dirname "${0}")" && pwd)
prev="${cur}/.."
cd "${prev}"
# coverage
bin="python3 -m catcli.catcli"
if command -v coverage 2>/dev/null; then
mkdir -p coverages/
bin="coverage run -p --data-file coverages/coverage --source=catcli -m catcli.catcli"
fi
echo "current dir: $(pwd)"
echo "pythonpath: ${PYTHONPATH}"
echo "bin: ${bin}"
${bin} --version
# get the helpers
# shellcheck source=tests-ng/helper
source "${cur}"/helper
echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)"
##########################################################
# the test
##########################################################
# create temp dirs
tmpd=$(mktemp -d)
clear_on_exit "${tmpd}"
catalog="${tmpd}/catalog"
# index
${bin} -B index -c --catalog="${catalog}" github .github
clean_catalog "${catalog}"
ls -laR .github
cat "${catalog}"
#cat "${catalog}"
echo ""
# compare keys
echo "[+] compare keys"
src="tests-ng/assets/github.catalog.json"
src_keys="${tmpd}/src-keys"
dst_keys="${tmpd}/dst-keys"
cat "${src}" | jq '.. | keys?' | jq '.[]' | sort > "${src_keys}"
cat "${catalog}" | jq '.. | keys?' | jq '.[]' | sort > "${dst_keys}"
echo "src:"
cat "${src_keys}"
echo "dst:"
cat "${dst_keys}"
diff "${src_keys}" "${dst_keys}"
echo "ok!"
# compare children 1
echo "[+] compare children 1"
src_keys="${tmpd}/src-child1"
dst_keys="${tmpd}/dst-child1"
cat "${src}" | jq '. | select(.type=="top") | .children | .[].name' | sort > "${src_keys}"
cat "${catalog}" | jq '. | select(.type=="top") | .children | .[].name' | sort > "${dst_keys}"
echo "src:"
cat "${src_keys}"
echo "dst:"
cat "${dst_keys}"
diff "${src_keys}" "${dst_keys}"
echo "ok!"
# compare children 2
echo "[+] compare children 2"
src_keys="${tmpd}/src-child2"
dst_keys="${tmpd}/dst-child2"
cat "${src}" | jq '. | select(.type=="top") | .children | .[] | select(.name=="github") | .children | .[].name' | sort > "${src_keys}"
cat "${catalog}" | jq '. | select(.type=="top") | .children | .[] | select(.name=="github") | .children | .[].name' | sort > "${dst_keys}"
echo "src:"
cat "${src_keys}"
echo "dst:"
cat "${dst_keys}"
diff "${src_keys}" "${dst_keys}"
echo "ok!"
# compare children 3
echo "[+] compare children 3"
src_keys="${tmpd}/src-child3"
dst_keys="${tmpd}/dst-child3"
cat "${src}" | jq '. | select(.type=="top") | .children | .[] | select(.name=="github") | .children | .[] | select(.name=="workflows") | .children | .[].name' | sort > "${src_keys}"
cat "${catalog}" | jq '. | select(.type=="top") | .children | .[] | select(.name=="github") | .children | .[] | select(.name=="workflows") | .children | .[].name' | sort > "${dst_keys}"
echo "src:"
cat "${src_keys}"
echo "dst:"
cat "${dst_keys}"
diff "${src_keys}" "${dst_keys}"
echo "ok!"
# native
echo "[+] compare native output"
native="${tmpd}/native.txt"
${bin} -B ls -s -r --format=native --catalog="${catalog}" > "${native}"
mod="${tmpd}/native.mod.txt"
cat "${native}" | sed -e 's/free:.*%/free:0.0%/g' \
-e 's/....-..-.. ..:..:../2023-03-09 16:20:59/g' \
-e 's#du:[^|]* |#du:0/0 |#g' > "${mod}"
if command -v delta >/dev/null; then
delta -s "tests-ng/assets/github.catalog.native.txt" "${mod}"
fi
diff --color=always "tests-ng/assets/github.catalog.native.txt" "${mod}"
echo "ok!"
# csv
echo "[+] compare csv output"
csv="${tmpd}/csv.txt"
${bin} -B ls -s -r --format=csv --catalog="${catalog}" > "${csv}"
# modify created csv
mod="${tmpd}/csv.mod.txt"
cat "${csv}" | \
sed -e 's/"3","[^"]*","[^"]*",""/"3","0","0",""/g' | \
sed 's/20..-..-.. ..:..:..//g' > "${mod}"
# modify original
ori="${tmpd}/ori.mod.txt"
cat "tests-ng/assets/github.catalog.csv.txt" | \
sed 's/....-..-.. ..:..:..//g' | \
sed 's/"3","[^"]*","[^"]*",""/"3","0","0",""/g' > "${ori}"
if command -v delta >/dev/null; then
delta -s "${ori}" "${mod}"
fi
diff "${ori}" "${mod}"
echo "ok!"
# the end
echo "test \"$(basename "$0")\" success"
cd "${cur}"
exit 0

@ -1,61 +0,0 @@
#!/usr/bin/env bash
# author: deadc0de6 (https://github.com/deadc0de6)
# Copyright (c) 2023, deadc0de6
set -e
cur=$(cd "$(dirname "${0}")" && pwd)
prev="${cur}/.."
cd "${prev}"
# coverage
bin="python3 -m catcli.catcli"
if command -v coverage 2>/dev/null; then
mkdir -p coverages/
bin="coverage run -p --data-file coverages/coverage --source=catcli -m catcli.catcli"
fi
echo "current dir: $(pwd)"
echo "pythonpath: ${PYTHONPATH}"
echo "bin: ${bin}"
${bin} --version
# get the helpers
# shellcheck source=tests-ng/helper
source "${cur}"/helper
echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)"
##########################################################
# the test
##########################################################
# create temp dirs
tmpd=$(mktemp -d)
clear_on_exit "${tmpd}"
catalog="${tmpd}/catalog"
# index
${bin} -B index -c -f --catalog="${catalog}" github1 .github
${bin} -B index -c -f --catalog="${catalog}" github2 .github
clean_catalog "${catalog}"
#cat "${catalog}"
echo ""
${bin} -B ls -r --catalog="${catalog}"
echo "finding \"testing.yml\""
${bin} -B find --catalog="${catalog}" testing.yml
cnt=$(${bin} -B find --catalog="${catalog}" testing.yml | wc -l)
[ "${cnt}" != "2" ] && echo "should return 2 (not ${cnt})" && exit 1
echo "finding \"*.yml\""
${bin} -B find --catalog="${catalog}" '*.yml'
cnt=$(${bin} -B find --catalog="${catalog}" '*.yml' | wc -l)
[ "${cnt}" != "8" ] && echo "should return 8 (not ${cnt})" && exit 1
# the end
echo ""
echo "test \"$(basename "$0")\" success"
cd "${cur}"
exit 0

@ -1,76 +0,0 @@
# author: deadc0de6 (https://github.com/deadc0de6)
# Copyright (c) 2023, deadc0de6
#
# file to be sourced from test scripts
#
declare -a to_be_cleared
# add a file/directory to be cleared
# on exit
#
# $1: file path to clear
clear_on_exit()
{
local len="${#to_be_cleared[*]}"
# shellcheck disable=SC2004
to_be_cleared[${len}]="$1"
if [ "${len}" = "0" ]; then
# set trap
trap on_exit EXIT
fi
}
# clear catalog stuff for testing
# $1: catalog path
clean_catalog()
{
sed -i 's/"free": .*,/"free": 0,/g' "${1}"
sed -i 's/"total": .*,/"total": 0,/g' "${1}"
}
# clear files
on_exit()
{
for i in "${to_be_cleared[@]}"; do
rm -rf "${i}"
done
}
# osx tricks
# brew install coreutils gnu-sed
if [[ $OSTYPE == 'darwin'* ]]; then
mktemp() {
gmktemp "$@"
}
stat() {
gstat "$@"
}
sed() {
gsed "$@"
}
wc() {
gwc "$@"
}
date() {
gdate "$@"
}
chmod() {
gchmod "$@"
}
readlink() {
greadlink "$@"
}
realpath() {
grealpath "$@"
}
export -f mktemp
export -f stat
export -f sed
export -f wc
export -f date
export -f chmod
export -f readlink
export -f realpath
fi

@ -1,59 +0,0 @@
#!/usr/bin/env bash
# author: deadc0de6 (https://github.com/deadc0de6)
# Copyright (c) 2024, deadc0de6
set -e
cur=$(cd "$(dirname "${0}")" && pwd)
prev="${cur}/.."
cd "${prev}"
# coverage
bin="python3 -m catcli.catcli"
if command -v coverage 2>/dev/null; then
mkdir -p coverages/
bin="coverage run -p --data-file coverages/coverage --source=catcli -m catcli.catcli"
fi
echo "current dir: $(pwd)"
echo "pythonpath: ${PYTHONPATH}"
echo "bin: ${bin}"
${bin} --version
# get the helpers
# shellcheck source=tests-ng/helper
source "${cur}"/helper
echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)"
##########################################################
# the test
##########################################################
# create temp dirs
tmpd=$(mktemp -d)
clear_on_exit "${tmpd}"
catalog="${tmpd}/catalog"
# index
${bin} -B index -c -f --catalog="${catalog}" github1 .github
${bin} -B index -c -f --catalog="${catalog}" github2 .github
clean_catalog "${catalog}"
#cat "${catalog}"
echo ""
${bin} -B ls -r --catalog="${catalog}"
${bin} -B ls --catalog="${catalog}" 'github1/*.yml'
cnt=$(${bin} -B ls --catalog="${catalog}" 'github1/*.yml' | wc -l)
[ "${cnt}" != "2" ] && echo "should return 2 (not ${cnt})" && exit 1
${bin} -B ls --catalog="${catalog}" 'github*/*.yml'
cnt=$(${bin} -B ls --catalog="${catalog}" 'github*/*.yml' | wc -l)
[ "${cnt}" != "4" ] && echo "should return 4 (not ${cnt})" && exit 1
# the end
echo ""
echo "test \"$(basename "$0")\" success"
cd "${cur}"
exit 0

@ -1,80 +0,0 @@
#!/usr/bin/env bash
# author: deadc0de6 (https://github.com/deadc0de6)
# Copyright (c) 2021, deadc0de6
set -e
cur=$(cd "$(dirname "${0}")" && pwd)
prev="${cur}/.."
cd "${prev}"
# coverage
bin="python3 -m catcli.catcli"
if command -v coverage 2>/dev/null; then
mkdir -p coverages/
bin="coverage run -p --data-file coverages/coverage --source=catcli -m catcli.catcli"
fi
echo "current dir: $(pwd)"
echo "pythonpath: ${PYTHONPATH}"
echo "bin: ${bin}"
${bin} --version
# get the helpers
# shellcheck source=tests-ng/helper
source "${cur}"/helper
echo -e "$(tput setaf 6)==> RUNNING $(basename "${BASH_SOURCE[0]}") <==$(tput sgr0)"
##########################################################
# the test
##########################################################
# create temp dirs
tmpd=$(mktemp -d)
clear_on_exit "${tmpd}"
tmpu="${tmpd}/dir2"
mkdir -p "${tmpu}"
catalog="${tmpd}/catalog"
mkdir -p "${tmpd}/dir"
echo "abc" > "${tmpd}/dir/a"
# index
${bin} -B index --catalog="${catalog}" dir "${tmpd}/dir"
${bin} -B ls --catalog="${catalog}"
# get attributes
freeb=$(${bin} -B ls --catalog="${catalog}" | grep free: | sed 's/^.*,free:\([^ ]*\).*$/\1/g')
dub=$(${bin} -B ls --catalog="${catalog}" | grep du: | sed 's/^.*,du:\([^ ]*\).*$/\1/g')
dateb=$(${bin} -B ls --catalog="${catalog}" | grep date: | sed 's/^.*,date: \(.*\)$/\1/g')
echo "before: free:${freeb} | du:${dub} | date:${dateb}"
# change content
echo "abc" >> "${tmpd}/dir/a"
echo "abc" > "${tmpd}/dir/b"
# move dir
cp -r "${tmpd}/dir" "${tmpu}/"
# sleep to force date change
sleep 1
# update
${bin} -B update -f --catalog="${catalog}" dir "${tmpu}/dir"
${bin} -B ls --catalog="${catalog}"
# get new attributes
freea=$(${bin} -B ls --catalog="${catalog}" | grep free: | sed 's/^.*,free:\([^ ]*\).*$/\1/g')
dua=$(${bin} -B ls --catalog="${catalog}" | grep du: | sed 's/^.*,du:\([^ ]*\).*$/\1/g')
datea=$(${bin} -B ls --catalog="${catalog}" | grep date: | sed 's/^.*,date: \(.*\)$/\1/g')
echo "after: free:${freea} | du:${dua} | date:${datea}"
# test they are all different
[ "${freeb}" = "${freea}" ] && echo "WARNING free didn't change!"
[ "${dub}" = "${dua}" ] && echo "WARNING du didn't change!"
[ "${dateb}" = "${datea}" ] && echo "WARNING date didn't change!" && exit 1
# the end
echo "test \"$(basename "$0")\" success"
cd "${cur}"
exit 0

@ -1,8 +0,0 @@
pycodestyle; python_version >= '3.0'
pyflakes; python_version >= '3.0'
nose2; python_version >= '3.0'
coverage; python_version >= '3.0'
pylint; python_version > '3.0'
mypy; python_version > '3.0'
pytest; python_version > '3.0'
pytype; python_version > '3.0'

@ -2,105 +2,9 @@
# author: deadc0de6 (https://github.com/deadc0de6) # author: deadc0de6 (https://github.com/deadc0de6)
# Copyright (c) 2017, deadc0de6 # Copyright (c) 2017, deadc0de6
cur=$(dirname "$(readlink -f "${0}")")
# stop on first error # stop on first error
set -e set -ev
#set -v
# pycodestyle
echo "[+] pycodestyle"
pycodestyle --version
pycodestyle catcli/ pycodestyle catcli/
pycodestyle tests/ pycodestyle tests/
pycodestyle setup.py PYTHONPATH=catcli python3 -m nose -s --with-coverage --cover-package=catcli
# pyflakes
echo "[+] pyflakes"
pyflakes --version
pyflakes catcli/
pyflakes tests/
pyflakes setup.py
# pylint
# R0914: Too many local variables
# R0913: Too many arguments
# R0912: Too many branches
# R0915: Too many statements
# R0911: Too many return statements
# R0903: Too few public methods
# R0902: Too many instance attributes
# R0201: no-self-used
echo "[+] pylint"
pylint --version
pylint -sn \
--disable=R0914 \
--disable=R0913 \
--disable=R0912 \
--disable=R0915 \
--disable=R0911 \
--disable=R0903 \
--disable=R0902 \
--disable=R0201 \
--disable=R0022 \
catcli/
# R0801: Similar lines in 2 files
# W0212: Access to a protected member
# R0914: Too many local variables
# R0915: Too many statements
pylint -sn \
--disable=R0801 \
--disable=W0212 \
--disable=R0914 \
--disable=R0915 \
tests/
pylint -sn setup.py
# mypy
echo "[+] mypy"
mypy --version
mypy --config-file=.mypy.ini catcli/
# pytype
echo "[+] pytype"
pytype --version
pytype catcli/
set +e
grep -R 'TODO' catcli/ && echo "TODO found" && exit 1
grep -R 'FIXME' catcli/ && echo "FIXME found" && exit 1
set -e
# unittest
echo "[+] unittests"
mkdir -p coverages/
coverage run -p --data-file coverages/coverage -m pytest tests
# tests-ng
echo "[+] tests-ng"
for t in "${cur}"/tests-ng/*.sh; do
echo "running test \"$(basename "${t}")\""
${t}
done
# check shells
echo "[+] shellcheck"
if ! command -v shellcheck >/dev/null 2>&1; then
echo "Install shellcheck"
exit 1
fi
shellcheck --version
find . -iname '*.sh' | while read -r script; do
shellcheck -x \
--exclude SC2002 \
"${script}"
done
# merge coverage
echo "[+] coverage merge"
coverage combine coverages/*
coverage xml
echo "ALL TESTS DONE OK"
exit 0

@ -10,8 +10,6 @@ import string
import random import random
import tempfile import tempfile
import shutil import shutil
import subprocess
import hashlib
TMPSUFFIX = '.catcli' TMPSUFFIX = '.catcli'
@ -21,32 +19,13 @@ TMPSUFFIX = '.catcli'
def get_rnd_string(length): def get_rnd_string(length):
"""Get a random string of specific length """ '''Get a random string of specific length '''
alpha = string.ascii_uppercase + string.digits alpha = string.ascii_uppercase + string.digits
return ''.join(random.choice(alpha) for _ in range(length)) return ''.join(random.choice(alpha) for _ in range(length))
def md5sum(path):
"""calculate md5 sum of a file"""
rpath = os.path.realpath(path)
if not os.path.exists(rpath):
return None
try:
with open(rpath, mode='rb') as file:
val = hashlib.md5()
while True:
buf = file.read(4096)
if not buf:
break
val.update(buf)
return val.hexdigest()
except PermissionError:
pass
return None
def clean(path): def clean(path):
"""Delete file or folder.""" '''Delete file or folder.'''
if not os.path.exists(path): if not os.path.exists(path):
return return
if os.path.islink(path): if os.path.islink(path):
@ -56,28 +35,13 @@ def clean(path):
else: else:
os.remove(path) os.remove(path)
def edit_file(path, newcontent):
"""edit file content"""
return write_to_file(path, newcontent)
def unix_tree(path):
"""print using unix tree tool"""
if not os.path.exists(path):
return
# cmd = ['tree', path]
cmd = ['ls', '-R', path]
subprocess.call(cmd)
############################################################ ############################################################
# catcli specific # catcli specific
############################################################ ############################################################
def create_tree(): def create_tree():
""" create a random tree of files and directories """ ''' create a random tree of files and directories '''
dirpath = get_tempdir() dirpath = get_tempdir()
# create 3 files # create 3 files
create_rnd_file(dirpath, get_rnd_string(5)) create_rnd_file(dirpath, get_rnd_string(5))
@ -85,13 +49,13 @@ def create_tree():
create_rnd_file(dirpath, get_rnd_string(5)) create_rnd_file(dirpath, get_rnd_string(5))
# create 2 directories # create 2 directories
dir1 = create_dir(dirpath, get_rnd_string(3)) d1 = create_dir(dirpath, get_rnd_string(3))
dir2 = create_dir(dirpath, get_rnd_string(3)) d2 = create_dir(dirpath, get_rnd_string(3))
# fill directories # fill directories
create_rnd_file(dir1, get_rnd_string(4)) create_rnd_file(d1, get_rnd_string(4))
create_rnd_file(dir1, get_rnd_string(4)) create_rnd_file(d1, get_rnd_string(4))
create_rnd_file(dir2, get_rnd_string(6)) create_rnd_file(d2, get_rnd_string(6))
return dirpath return dirpath
@ -101,12 +65,12 @@ def create_tree():
def get_tempdir(): def get_tempdir():
"""Get a temporary directory """ '''Get a temporary directory '''
return tempfile.mkdtemp(suffix=TMPSUFFIX) return tempfile.mkdtemp(suffix=TMPSUFFIX)
def create_dir(path, dirname): def create_dir(path, dirname):
"""Create a directory """ '''Create a directory '''
fpath = os.path.join(path, dirname) fpath = os.path.join(path, dirname)
if not os.path.exists(fpath): if not os.path.exists(fpath):
os.mkdir(fpath) os.mkdir(fpath)
@ -114,33 +78,19 @@ def create_dir(path, dirname):
def create_rnd_file(path, filename, content=None): def create_rnd_file(path, filename, content=None):
"""Create the file filename in path with random content if None """ '''Create the file filename in path with random content if None '''
if not content: if not content:
content = get_rnd_string(100) content = get_rnd_string(100)
fpath = os.path.join(path, filename) fpath = os.path.join(path, filename)
return write_to_file(fpath, content) with open(fpath, 'w') as f:
f.write(content)
return fpath
def write_to_file(path, content):
"""write content to file"""
with open(path, 'w', encoding='utf-8') as file:
file.write(content)
return path
def read_from_file(path):
"""read file content"""
if not os.path.exists(path):
return ''
with open(path, 'r', encoding='utf-8') as file:
content = file.read()
return content
############################################################ ############################################################
# fake tree in json # fake tree in json
############################################################ ############################################################
FAKECATALOG = """ FAKECATALOG = '''
{ {
"children": [ "children": [
{ {
@ -149,18 +99,21 @@ FAKECATALOG = """
{ {
"md5": null, "md5": null,
"name": "7544G", "name": "7544G",
"relpath": "tmpj5602ih7.catcli/7544G",
"size": 100, "size": 100,
"type": "file" "type": "file"
}, },
{ {
"md5": null, "md5": null,
"name": "KF2ZC", "name": "KF2ZC",
"relpath": "tmpj5602ih7.catcli/KF2ZC",
"size": 100, "size": 100,
"type": "file" "type": "file"
}, },
{ {
"md5": null, "md5": null,
"name": "Z9OII", "name": "Z9OII",
"relpath": "tmpj5602ih7.catcli/Z9OII",
"size": 100, "size": 100,
"type": "file" "type": "file"
}, },
@ -169,12 +122,14 @@ FAKECATALOG = """
{ {
"md5": null, "md5": null,
"name": "M592O9", "name": "M592O9",
"relpath": "tmpj5602ih7.catcli/VNN/M592O9",
"size": 100, "size": 100,
"type": "file" "type": "file"
} }
], ],
"md5": null, "md5": null,
"name": "VNN", "name": "VNN",
"relpath": "VNN",
"size": 100, "size": 100,
"type": "dir" "type": "dir"
}, },
@ -183,18 +138,21 @@ FAKECATALOG = """
{ {
"md5": null, "md5": null,
"name": "X37H", "name": "X37H",
"relpath": "tmpj5602ih7.catcli/P4C/X37H",
"size": 100, "size": 100,
"type": "file" "type": "file"
}, },
{ {
"md5": null, "md5": null,
"name": "I566", "name": "I566",
"relpath": "tmpj5602ih7.catcli/P4C/I566",
"size": 100, "size": 100,
"type": "file" "type": "file"
} }
], ],
"md5": null, "md5": null,
"name": "P4C", "name": "P4C",
"relpath": "P4C",
"size": 200, "size": 200,
"type": "dir" "type": "dir"
} }
@ -210,9 +168,9 @@ FAKECATALOG = """
"name": "top", "name": "top",
"type": "top" "type": "top"
} }
""" '''
def get_fakecatalog(): def get_fakecatalog():
"""catalog constructed through test_index""" # catalog constructed through test_index
return FAKECATALOG return FAKECATALOG

@ -1,29 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2017, deadc0de6
Basic unittest for ls
"""
import unittest
from catcli.decomp import Decomp
class TestDecomp(unittest.TestCase):
"""test ls"""
def test_list(self):
"""test decomp formats"""
dec = Decomp()
formats = dec.get_formats()
self.assertTrue('zip' in formats)
def main():
"""entry point"""
unittest.main()
if __name__ == '__main__':
main()

@ -7,27 +7,28 @@ Basic unittest for find
import unittest import unittest
from catcli.catcli import cmd_find import catcli
from catcli.catcli import *
from catcli.noder import Noder from catcli.noder import Noder
from catcli.walker import Walker
from catcli.catalog import Catalog from catcli.catalog import Catalog
from tests.helpers import get_fakecatalog from catcli.logger import Logger
from catcli.utils import *
from tests.helpers import *
class TestFind(unittest.TestCase): class TestFind(unittest.TestCase):
"""test find"""
def test_find(self): def test_find(self):
"""test find"""
# init # init
catalog = Catalog('fake', force=True, debug=False) catalog = Catalog('fake', force=True, verbose=False)
top = catalog._restore_json(get_fakecatalog()) top = catalog._restore_json(get_fakecatalog())
noder = Noder() noder = Noder()
# create fake args # create fake args
args = {'<term>': '7544G', '--script': True, args = {'<term>': '7544G', '--script': True,
'--verbose': True, '--parent': False, '--verbose': True}
'--directory': False, '--path': None,
'--format': 'native', '--raw-size': False}
# try to find something # try to find something
found = cmd_find(args, noder, top) found = cmd_find(args, noder, top)
@ -40,7 +41,6 @@ class TestFind(unittest.TestCase):
def main(): def main():
"""entry point"""
unittest.main() unittest.main()

@ -6,26 +6,23 @@ Basic unittest for graph
""" """
import unittest import unittest
import tempfile
import os
from catcli.catcli import cmd_graph from catcli.catcli import *
from catcli.noder import Noder from catcli.noder import Noder
from catcli.walker import Walker
from catcli.catalog import Catalog from catcli.catalog import Catalog
from tests.helpers import clean, get_fakecatalog from tests.helpers import *
class TestGraph(unittest.TestCase): class TestGraph(unittest.TestCase):
"""test graph"""
def test_graph(self): def test_graph(self):
"""test graph"""
# init # init
path = 'fake' path = 'fake'
gpath = tempfile.gettempdir() + os.sep + 'graph.dot' gpath = '/tmp/graph.dot'
self.addCleanup(clean, path) self.addCleanup(clean, path)
self.addCleanup(clean, gpath) self.addCleanup(clean, gpath)
catalog = Catalog(path, force=True, debug=False) catalog = Catalog(path, force=True, verbose=False)
top = catalog._restore_json(get_fakecatalog()) top = catalog._restore_json(get_fakecatalog())
noder = Noder() noder = Noder()
@ -40,7 +37,6 @@ class TestGraph(unittest.TestCase):
def main(): def main():
"""entry point"""
unittest.main() unittest.main()

@ -5,21 +5,18 @@ Copyright (c) 2017, deadc0de6
Basic unittest for indexing Basic unittest for indexing
""" """
import os
import unittest import unittest
from catcli.catcli import cmd_index from catcli.catcli import *
from catcli.noder import Noder from catcli.noder import Noder
from catcli.walker import Walker
from catcli.catalog import Catalog from catcli.catalog import Catalog
from tests.helpers import get_tempdir, create_rnd_file, clean, \ from tests.helpers import *
get_rnd_string, create_dir
class TestIndexing(unittest.TestCase): class TestIndexing(unittest.TestCase):
"""test index"""
def test_index(self): def test_index(self):
"""test index"""
# init # init
workingdir = get_tempdir() workingdir = get_tempdir()
catalogpath = create_rnd_file(workingdir, 'catalog.json', content='') catalogpath = create_rnd_file(workingdir, 'catalog.json', content='')
@ -29,28 +26,29 @@ class TestIndexing(unittest.TestCase):
self.addCleanup(clean, dirpath) self.addCleanup(clean, dirpath)
# create 3 files # create 3 files
file1 = create_rnd_file(dirpath, get_rnd_string(5)) f1 = create_rnd_file(dirpath, get_rnd_string(5))
file2 = create_rnd_file(dirpath, get_rnd_string(5)) f2 = create_rnd_file(dirpath, get_rnd_string(5))
file3 = create_rnd_file(dirpath, get_rnd_string(5)) f3 = create_rnd_file(dirpath, get_rnd_string(5))
# create 2 directories # create 2 directories
dir1 = create_dir(dirpath, get_rnd_string(3)) d1 = create_dir(dirpath, get_rnd_string(3))
dir2 = create_dir(dirpath, get_rnd_string(3)) d2 = create_dir(dirpath, get_rnd_string(3))
# fill directories with files # fill directories with files
_ = create_rnd_file(dir1, get_rnd_string(4)) d1f1 = create_rnd_file(d1, get_rnd_string(4))
_ = create_rnd_file(dir1, get_rnd_string(4)) d1f2 = create_rnd_file(d1, get_rnd_string(4))
_ = create_rnd_file(dir2, get_rnd_string(6)) d2f1 = create_rnd_file(d2, get_rnd_string(6))
noder = Noder() noder = Noder()
top = noder.new_top_node() top = noder.new_top_node()
catalog = Catalog(catalogpath, force=True, debug=False) walker = Walker(noder)
catalog = Catalog(catalogpath, force=True, verbose=False)
# create fake args # create fake args
tmpdirname = 'tmpdir' tmpdirname = 'tmpdir'
args = {'<path>': dirpath, '<name>': tmpdirname, args = {'<path>': dirpath, '<name>': tmpdirname,
'--hash': True, '--meta': ['some meta'], '--hash': True, '--meta': 'some meta',
'--verbose': True} '--subsize': True, '--verbose': True}
# index the directory # index the directory
cmd_index(args, noder, catalog, top) cmd_index(args, noder, catalog, top)
@ -62,22 +60,21 @@ class TestIndexing(unittest.TestCase):
self.assertTrue(len(storage.children) == 5) self.assertTrue(len(storage.children) == 5)
# ensures files and directories are in # ensures files and directories are in
names = [x.get_name() for x in storage.children] names = [x.name for x in storage.children]
self.assertTrue(os.path.basename(file1) in names) self.assertTrue(os.path.basename(f1) in names)
self.assertTrue(os.path.basename(file2) in names) self.assertTrue(os.path.basename(f2) in names)
self.assertTrue(os.path.basename(file3) in names) self.assertTrue(os.path.basename(f3) in names)
self.assertTrue(os.path.basename(dir1) in names) self.assertTrue(os.path.basename(d1) in names)
self.assertTrue(os.path.basename(dir2) in names) self.assertTrue(os.path.basename(d2) in names)
for node in storage.children: for node in storage.children:
if node.get_name() == os.path.basename(dir1): if node.name == os.path.basename(d1):
self.assertTrue(len(node.children) == 2) self.assertTrue(len(node.children) == 2)
elif node.get_name() == os.path.basename(dir2): elif node.name == os.path.basename(d2):
self.assertTrue(len(node.children) == 1) self.assertTrue(len(node.children) == 1)
def main(): def main():
"""entry point"""
unittest.main() unittest.main()

@ -7,29 +7,26 @@ Basic unittest for ls
import unittest import unittest
from catcli.catcli import cmd_ls from catcli.catcli import *
from catcli.noder import Noder from catcli.noder import Noder
from catcli.walker import Walker
from catcli.catalog import Catalog from catcli.catalog import Catalog
from tests.helpers import get_fakecatalog, clean from tests.helpers import *
class TestWalking(unittest.TestCase): class TestWalking(unittest.TestCase):
"""test ls"""
def test_ls(self): def test_ls(self):
"""test ls"""
# init # init
path = 'fake' path = 'fake'
self.addCleanup(clean, path) self.addCleanup(clean, path)
catalog = Catalog(path, force=True, debug=False) catalog = Catalog(path, force=True, verbose=False)
top = catalog._restore_json(get_fakecatalog()) top = catalog._restore_json(get_fakecatalog())
noder = Noder() noder = Noder()
# create fake args # create fake args
args = {'<path>': '', '--recursive': False, args = {'<path>': '', '--recursive': False,
'--verbose': True, '--verbose': True}
'--format': 'native',
'--raw-size': False}
# list root # list root
args['<path>'] = '' args['<path>'] = ''
@ -58,7 +55,6 @@ class TestWalking(unittest.TestCase):
def main(): def main():
"""entry point"""
unittest.main() unittest.main()

@ -7,29 +7,26 @@ Basic unittest for rm
import unittest import unittest
from catcli.catcli import cmd_rm, cmd_ls from catcli.catcli import *
from catcli.noder import Noder from catcli.noder import Noder
from catcli.walker import Walker
from catcli.catalog import Catalog from catcli.catalog import Catalog
from tests.helpers import clean, get_fakecatalog from tests.helpers import *
class TestRm(unittest.TestCase): class TestRm(unittest.TestCase):
"""test rm"""
def test_rm(self): def test_rm(self):
"""test rm"""
# init # init
path = 'fake' path = 'fake'
self.addCleanup(clean, path) self.addCleanup(clean, path)
catalog = Catalog(path, force=True, debug=False) catalog = Catalog(path, force=True, verbose=False)
top = catalog._restore_json(get_fakecatalog()) top = catalog._restore_json(get_fakecatalog())
noder = Noder() noder = Noder()
# create fake args dict # create fake args dict
args = {'<path>': '', '--recursive': False, args = {'<path>': '', '--recursive': False,
'--verbose': True, '--verbose': True}
'--format': 'native',
'--raw-size': False}
# list files and make sure there are children # list files and make sure there are children
args['<path>'] = '' args['<path>'] = ''
@ -46,11 +43,10 @@ class TestRm(unittest.TestCase):
top = cmd_rm(args, noder, catalog, top) top = cmd_rm(args, noder, catalog, top)
# ensure there no children anymore # ensure there no children anymore
self.assertEqual(len(top.children), 0) self.assertTrue(len(top.children) == 0)
def main(): def main():
"""entry point"""
unittest.main() unittest.main()

@ -7,40 +7,31 @@ Basic unittest for tree
import unittest import unittest
from catcli.catcli import cmd_ls from catcli.catcli import *
from catcli.noder import Noder from catcli.noder import Noder
from catcli.walker import Walker
from catcli.catalog import Catalog from catcli.catalog import Catalog
from tests.helpers import clean, get_fakecatalog from tests.helpers import *
class TestTree(unittest.TestCase): class TestTree(unittest.TestCase):
"""Test the tree"""
def test_tree(self): def test_tree(self):
"""test the tree"""
# init # init
path = 'fake' path = 'fake'
self.addCleanup(clean, path) self.addCleanup(clean, path)
catalog = Catalog(path, force=True, debug=False) catalog = Catalog(path, force=True, verbose=False)
top = catalog._restore_json(get_fakecatalog()) top = catalog._restore_json(get_fakecatalog())
noder = Noder() noder = Noder()
# create fake args dict # create fake args dict
args = { args = {'<path>': path, '--verbose': True}
'<path>': path,
'--verbose': True,
'--format': 'native',
'--header': False,
'--raw-size': False,
'--recursive': True,
}
# print tree and wait for any errors # print tree and wait for any errors
cmd_ls(args, noder, top) cmd_tree(args, noder, top)
def main(): def main():
"""entry point"""
unittest.main() unittest.main()

@ -1,222 +0,0 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2017, deadc0de6
Basic unittest for updating an index
"""
import unittest
import os
import anytree
from catcli.catcli import cmd_index, cmd_update
from catcli.noder import Noder
from catcli.catalog import Catalog
from tests.helpers import create_dir, create_rnd_file, get_tempdir, \
clean, unix_tree, edit_file, read_from_file, md5sum
class TestUpdate(unittest.TestCase):
"""test update"""
def test_update(self):
"""test update"""
# init
workingdir = get_tempdir()
catalogpath = create_rnd_file(workingdir, 'catalog.json', content='')
self.addCleanup(clean, workingdir)
dirpath = get_tempdir()
self.addCleanup(clean, dirpath)
# create 3 files
file1 = create_rnd_file(dirpath, 'file1')
file2 = create_rnd_file(dirpath, 'file2')
file3 = create_rnd_file(dirpath, 'file3')
file4 = create_rnd_file(dirpath, 'file4')
# create 2 directories
dir1 = create_dir(dirpath, 'dir1')
dir2 = create_dir(dirpath, 'dir2')
# fill directories with files
d1f1 = create_rnd_file(dir1, 'dir1file1')
d1f2 = create_rnd_file(dir1, 'dir1file2')
d2f1 = create_rnd_file(dir2, 'dir2file1')
d2f2 = create_rnd_file(dir2, 'dir2file2')
noder = Noder(debug=True)
noder.do_hashing(True)
top = noder.new_top_node()
catalog = Catalog(catalogpath, force=True, debug=False)
# get checksums
f4_md5 = md5sum(file4)
self.assertTrue(f4_md5)
d1f1_md5 = md5sum(d1f1)
self.assertTrue(d1f1_md5)
d2f2_md5 = md5sum(d2f2)
self.assertTrue(d2f2_md5)
# create fake args
tmpdirname = 'tmpdir'
args = {'<path>': dirpath, '<name>': tmpdirname,
'--hash': True, '--meta': ['some meta'],
'--verbose': True, '--lpath': None}
# index the directory
unix_tree(dirpath)
cmd_index(args, noder, catalog, top)
self.assertTrue(os.stat(catalogpath).st_size != 0)
# ensure md5 sum are in
nods = noder.find(top, os.path.basename(file4))
self.assertEqual(len(nods), 1)
nod = nods[0]
self.assertTrue(nod)
self.assertEqual(nod.md5, f4_md5)
# print catalog
noder.print_tree(top)
# add some files and directories
new1 = create_rnd_file(dir1, 'newf1')
new2 = create_rnd_file(dirpath, 'newf2')
new3 = create_dir(dirpath, 'newd3')
new4 = create_dir(dir2, 'newd4')
new5 = create_rnd_file(new4, 'newf5')
unix_tree(dirpath)
# modify files
editval = 'edited'
edit_file(d1f1, editval)
d1f1_md5_new = md5sum(d1f1)
self.assertTrue(d1f1_md5_new)
self.assertTrue(d1f1_md5_new != d1f1_md5)
# change file without mtime
maccess = os.path.getmtime(file4)
editval = 'edited'
edit_file(file4, editval)
# reset edit time
os.utime(file4, (maccess, maccess))
f4_md5_new = md5sum(d1f1)
self.assertTrue(f4_md5_new)
self.assertTrue(f4_md5_new != f4_md5)
# change file without mtime
maccess = os.path.getmtime(d2f2)
editval = 'edited'
edit_file(d2f2, editval)
# reset edit time
os.utime(d2f2, (maccess, maccess))
d2f2_md5_new = md5sum(d2f2)
self.assertTrue(d2f2_md5_new)
self.assertTrue(d2f2_md5_new != d2f2_md5)
# update storage
cmd_update(args, noder, catalog, top)
# print catalog
# print(read_from_file(catalogpath))
noder.print_tree(top)
# explore the top node to find all nodes
self.assertEqual(len(top.children), 1)
storage = top.children[0]
self.assertEqual(len(storage.children), 8)
# ensure d1f1 md5 sum has changed in catalog
nods = noder.find(top, os.path.basename(d1f1))
self.assertTrue(len(nods) == 1)
nod = nods[0]
self.assertTrue(nod)
self.assertTrue(nod.md5 != d1f1_md5)
self.assertTrue(nod.md5 == d1f1_md5_new)
# ensure f4 md5 sum has changed in catalog
nods = noder.find(top, os.path.basename(file4))
self.assertTrue(len(nods) == 1)
nod = nods[0]
self.assertTrue(nod)
self.assertTrue(nod.md5 != f4_md5)
self.assertTrue(nod.md5 == f4_md5_new)
# ensure d2f2 md5 sum has changed in catalog
nods = noder.find(top, os.path.basename(d2f2))
self.assertTrue(len(nods) == 1)
nod = nods[0]
self.assertTrue(nod)
self.assertTrue(nod.md5 != d2f2_md5)
self.assertTrue(nod.md5 == d2f2_md5_new)
# ensures files and directories are in
names = [node.get_name() for node in anytree.PreOrderIter(storage)]
print(names)
self.assertTrue(os.path.basename(file1) in names)
self.assertTrue(os.path.basename(file2) in names)
self.assertTrue(os.path.basename(file3) in names)
self.assertTrue(os.path.basename(file4) in names)
self.assertTrue(os.path.basename(dir1) in names)
self.assertTrue(os.path.basename(d1f1) in names)
self.assertTrue(os.path.basename(d1f2) in names)
self.assertTrue(os.path.basename(dir2) in names)
self.assertTrue(os.path.basename(d2f1) in names)
self.assertTrue(os.path.basename(new1) in names)
self.assertTrue(os.path.basename(new2) in names)
self.assertTrue(os.path.basename(new3) in names)
self.assertTrue(os.path.basename(new4) in names)
self.assertTrue(os.path.basename(new5) in names)
for node in storage.children:
if node.get_name() == os.path.basename(dir1):
self.assertTrue(len(node.children) == 3)
elif node.get_name() == os.path.basename(dir2):
self.assertTrue(len(node.children) == 3)
elif node.get_name() == os.path.basename(new3):
self.assertTrue(len(node.children) == 0)
elif node.get_name() == os.path.basename(new4):
self.assertTrue(len(node.children) == 1)
self.assertTrue(read_from_file(d1f1) == editval)
# remove some files
clean(d1f1)
clean(dir2)
clean(new2)
clean(new4)
# update storage
cmd_update(args, noder, catalog, top)
# ensures files and directories are (not) in
names = [node.get_name() for node in anytree.PreOrderIter(storage)]
print(names)
self.assertTrue(os.path.basename(file1) in names)
self.assertTrue(os.path.basename(file2) in names)
self.assertTrue(os.path.basename(file3) in names)
self.assertTrue(os.path.basename(file4) in names)
self.assertTrue(os.path.basename(dir1) in names)
self.assertTrue(os.path.basename(d1f1) not in names)
self.assertTrue(os.path.basename(d1f2) in names)
self.assertTrue(os.path.basename(dir2) not in names)
self.assertTrue(os.path.basename(d2f1) not in names)
self.assertTrue(os.path.basename(d2f1) not in names)
self.assertTrue(os.path.basename(new1) in names)
self.assertTrue(os.path.basename(new2) not in names)
self.assertTrue(os.path.basename(new3) in names)
self.assertTrue(os.path.basename(new4) not in names)
self.assertTrue(os.path.basename(new5) not in names)
for node in storage.children:
if node.get_name() == os.path.basename(dir1):
self.assertTrue(len(node.children) == 2)
elif node.get_name() == os.path.basename(new3):
self.assertTrue(len(node.children) == 0)
def main():
"""entry point"""
unittest.main()
if __name__ == '__main__':
main()
Loading…
Cancel
Save