housekeeper 4.13.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- README.md +91 -0
- housekeeper/__init__.py +2 -0
- housekeeper/__main__.py +16 -0
- housekeeper/archive/__init__.py +0 -0
- housekeeper/cli/__init__.py +2 -0
- housekeeper/cli/add.py +226 -0
- housekeeper/cli/core.py +62 -0
- housekeeper/cli/delete.py +243 -0
- housekeeper/cli/get.py +181 -0
- housekeeper/cli/include.py +59 -0
- housekeeper/cli/init.py +30 -0
- housekeeper/cli/tables.py +67 -0
- housekeeper/constants.py +11 -0
- housekeeper/date.py +70 -0
- housekeeper/exc.py +14 -0
- housekeeper/files.py +50 -0
- housekeeper/include.py +68 -0
- housekeeper/services/__init__.py +0 -0
- housekeeper/services/file_report_service/__init__.py +0 -0
- housekeeper/services/file_report_service/file_report_service.py +26 -0
- housekeeper/services/file_report_service/utils.py +121 -0
- housekeeper/services/file_service/__init__.py +0 -0
- housekeeper/services/file_service/file_service.py +18 -0
- housekeeper/store/__init__.py +1 -0
- housekeeper/store/api/__init__.py +0 -0
- housekeeper/store/api/schema.py +122 -0
- housekeeper/store/base.py +40 -0
- housekeeper/store/crud/__init__.py +0 -0
- housekeeper/store/crud/create.py +159 -0
- housekeeper/store/crud/read.py +310 -0
- housekeeper/store/crud/update.py +70 -0
- housekeeper/store/database.py +54 -0
- housekeeper/store/filters/__init__.py +0 -0
- housekeeper/store/filters/archive_filters.py +61 -0
- housekeeper/store/filters/bundle_filters.py +39 -0
- housekeeper/store/filters/file_filters.py +76 -0
- housekeeper/store/filters/tag_filters.py +31 -0
- housekeeper/store/filters/version_bundle_filters.py +36 -0
- housekeeper/store/filters/version_filters.py +38 -0
- housekeeper/store/models.py +124 -0
- housekeeper/store/store.py +32 -0
- housekeeper-4.13.5.dist-info/LICENSE +7 -0
- housekeeper-4.13.5.dist-info/METADATA +114 -0
- housekeeper-4.13.5.dist-info/RECORD +88 -0
- housekeeper-4.13.5.dist-info/WHEEL +4 -0
- housekeeper-4.13.5.dist-info/entry_points.txt +3 -0
- tests/__init__.py +0 -0
- tests/cli/add/conftest.py +18 -0
- tests/cli/add/test_cli_add_bundle.py +162 -0
- tests/cli/add/test_cli_add_file.py +171 -0
- tests/cli/add/test_cli_add_tags.py +101 -0
- tests/cli/add/test_cli_add_version.py +190 -0
- tests/cli/conftest.py +121 -0
- tests/cli/delete/test_cli_delete_bundle.py +82 -0
- tests/cli/delete/test_cli_delete_file.py +66 -0
- tests/cli/delete/test_cli_delete_files.py +130 -0
- tests/cli/delete/test_cli_delete_tag.py +89 -0
- tests/cli/delete/test_cli_delete_version.py +25 -0
- tests/cli/get/test_get_bundle.py +165 -0
- tests/cli/get/test_get_files.py +164 -0
- tests/cli/get/test_get_tag.py +75 -0
- tests/cli/get/test_get_version.py +100 -0
- tests/cli/test_cli_core.py +122 -0
- tests/cli/test_cli_include.py +223 -0
- tests/conftest.py +519 -0
- tests/fixtures/26a90105b99c05381328317f913e9509e373b64f.txt +15 -0
- tests/fixtures/sequencing_files/lane1.spring +0 -0
- tests/fixtures/sequencing_files/lane2.spring +0 -0
- tests/fixtures/sequencing_files/lane3.spring +0 -0
- tests/fixtures/vcfs/example.2.vcf +0 -0
- tests/fixtures/vcfs/example.vcf +0 -0
- tests/fixtures/vcfs/family.2.vcf +0 -0
- tests/fixtures/vcfs/family.3.vcf +0 -0
- tests/fixtures/vcfs/family.vcf +0 -0
- tests/helper_functions.py +73 -0
- tests/store/conftest.py +70 -0
- tests/store/crud/test_createhandler.py +216 -0
- tests/store/crud/test_readhandler.py +339 -0
- tests/store/crud/test_updatehandler.py +126 -0
- tests/store/filters/test_archive_filters.py +111 -0
- tests/store/filters/test_bundle_filters.py +59 -0
- tests/store/filters/test_file_filters.py +155 -0
- tests/store/filters/test_tag_filters.py +82 -0
- tests/store/filters/test_version_bundle_filters.py +57 -0
- tests/store/filters/test_version_filters.py +69 -0
- tests/store/test_models.py +64 -0
- tests/test_date.py +90 -0
- tests/test_include.py +57 -0
README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Housekeeper
|
|
2
|
+
![Housekeeper tests][github-url] [![Coverage Status][coveralls-image]][coveralls-url] [![CodeFactor][codefactor-image]][codefactor-url] [![Code style: black][black-image]][black-url]
|
|
3
|
+
|
|
4
|
+
### Store, tag, fetch, and archive files with ease 🗃
|
|
5
|
+
|
|
6
|
+
**Housekeeper** is a tool that aims to provide:
|
|
7
|
+
|
|
8
|
+
- a backend for storing versioned bundles of files
|
|
9
|
+
- different interfaces (Python, CLI, REST) for fetching files based on tags
|
|
10
|
+
- a way to backup and retrieve bundles from long-term storage
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Housekeeper written in Python 3.6+ and is available on the [Python Package Index][pypi] (PyPI).
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
poetry install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If you would like to install the latest development version:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/Clinical-Genomics/housekeeper
|
|
24
|
+
cd housekeeper
|
|
25
|
+
poetry install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Contributing
|
|
29
|
+
|
|
30
|
+
Housekeeper is using GitHub flow branching model as described in our [development manual][development manual].
|
|
31
|
+
|
|
32
|
+
## Documentation
|
|
33
|
+
|
|
34
|
+
### Command line interface
|
|
35
|
+
|
|
36
|
+
#### Config file
|
|
37
|
+
|
|
38
|
+
Housekeeper supports a basic YAML config. The following options are supported:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
---
|
|
42
|
+
database: mysql+pymysql://userName:passWord@domain.com/database
|
|
43
|
+
root: /path/to/root/dir
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The `root` option is used to store files within the Housekeeper context.
|
|
47
|
+
|
|
48
|
+
#### Command: `init`
|
|
49
|
+
|
|
50
|
+
Setup (or reset) the database. It will simply setup all the tables in the database. You can reset an existing database by using the `--reset` option.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
housekeeper --database "sqlite:///hk.sqlite3" init
|
|
54
|
+
Success! New tables: bundle, file, file_tag_link, tag, version
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
#### Command: `include`
|
|
58
|
+
|
|
59
|
+
Include (hard-link) all files of an existing bundle version into Housekeeper and the `root` path.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
housekeeper myBundle
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This will only work if the bundle only has a single version which can be "imported". If you want to import a specific version of a bundle you can use the `--version` option.
|
|
66
|
+
|
|
67
|
+
#### Command: `delete files`
|
|
68
|
+
|
|
69
|
+
Delete files that are not on disk anymore like his:
|
|
70
|
+
`housekeeper delete files --tag fastq --notondisk`
|
|
71
|
+
|
|
72
|
+
Remove all bam files before a certain date:
|
|
73
|
+
`housekeeper delete files --tag bam --before 2017-06-15`
|
|
74
|
+
|
|
75
|
+
Remove fastq files from a flowcell:
|
|
76
|
+
`housekeeper delete files --tag fastq --tag H0HKKALXX`
|
|
77
|
+
|
|
78
|
+
It'll always ask for confirmation, unless you add --yes:
|
|
79
|
+
`housekeeper delete files --bundle sillyfish --yes`
|
|
80
|
+
|
|
81
|
+
If you do not provide a --tag or --bundle, essentially deleting everything, the function will not let you do that.
|
|
82
|
+
|
|
83
|
+
[pypi]: https://pypi.python.org/pypi/housekeeper/
|
|
84
|
+
[coveralls-url]: https://coveralls.io/r/Clinical-Genomics/housekeeper
|
|
85
|
+
[coveralls-image]: https://img.shields.io/coveralls/Clinical-Genomics/housekeeper.svg?style=flat-square
|
|
86
|
+
[github-url]: https://github.com/Clinical-Genomics/housekeeper/workflows/Housekeeper%20tests/badge.svg
|
|
87
|
+
[development manual]: http://www.clinicalgenomics.se/development/dev/githubflow/
|
|
88
|
+
[codefactor-image]: https://www.codefactor.io/repository/github/clinical-genomics/housekeeper/badge
|
|
89
|
+
[codefactor-url]: https://www.codefactor.io/repository/github/clinical-genomics/housekeeper
|
|
90
|
+
[black-image]: https://img.shields.io/badge/code%20style-black-000000.svg
|
|
91
|
+
[black-url]: https://github.com/psf/black
|
housekeeper/__init__.py
ADDED
housekeeper/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
housekeeper.__main__
|
|
3
|
+
|
|
4
|
+
The main entry point for the command line interface.
|
|
5
|
+
|
|
6
|
+
Invoke as `housekeeper` (if installed)
|
|
7
|
+
or `python -m housekeeper` (no install required).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from housekeeper.cli.core import base
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
# exit using whatever exit code the CLI returned
|
|
16
|
+
sys.exit(base())
|
|
File without changes
|
housekeeper/cli/add.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Module for adding via CLI"""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import logging
|
|
5
|
+
from logging import Logger
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Generator
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from housekeeper.constants import ROOT
|
|
12
|
+
from housekeeper.date import get_date
|
|
13
|
+
from housekeeper.exc import VersionIncludedError
|
|
14
|
+
from housekeeper.files import load_json, validate_input
|
|
15
|
+
from housekeeper.include import include_version, link_to_relative_path, relative_path
|
|
16
|
+
from housekeeper.store.models import Bundle, Tag, Version
|
|
17
|
+
from housekeeper.store.store import Store
|
|
18
|
+
|
|
19
|
+
LOG: Logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_args(arg: str, json: str, arg_name: str) -> None:
|
|
23
|
+
"""Check if input is valid
|
|
24
|
+
|
|
25
|
+
One of the arguments has to be specified. Both arguments are not allowed
|
|
26
|
+
"""
|
|
27
|
+
if not (arg or json):
|
|
28
|
+
LOG.error("Please input json or %s", arg_name)
|
|
29
|
+
raise click.Abort
|
|
30
|
+
|
|
31
|
+
if arg and json:
|
|
32
|
+
LOG.warning("Can not input both json and %s", arg_name)
|
|
33
|
+
raise click.Abort
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.group()
|
|
37
|
+
def add():
|
|
38
|
+
"""Add things to the store."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@add.command("bundle")
|
|
42
|
+
@click.argument("bundle_name", required=False)
|
|
43
|
+
@click.option("-j", "--json", help="Input json string")
|
|
44
|
+
@click.option("-e", "--exclude", help="Use to not include bundle when adding it.", is_flag=True)
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def bundle_cmd(context: click.Context, bundle_name: str, json: str, exclude: bool):
|
|
47
|
+
"""Add a new bundle."""
|
|
48
|
+
LOG.info("Running add bundle")
|
|
49
|
+
store: Store = context.obj["store"]
|
|
50
|
+
|
|
51
|
+
validate_args(arg=bundle_name, json=json, arg_name="bundle_name")
|
|
52
|
+
|
|
53
|
+
data: dict = {"name": bundle_name, "created_at": str(dt.datetime.now())}
|
|
54
|
+
# This is to preserve the behaviour of adding a bundle without providing all information
|
|
55
|
+
if json:
|
|
56
|
+
data: dict = load_json(json)
|
|
57
|
+
|
|
58
|
+
bundle_name = data["name"]
|
|
59
|
+
if store.get_bundle_by_name(bundle_name=bundle_name):
|
|
60
|
+
LOG.warning("bundle name %s already exists", bundle_name)
|
|
61
|
+
raise click.Abort
|
|
62
|
+
|
|
63
|
+
validate_input(data, input_type="bundle")
|
|
64
|
+
data["created_at"] = get_date(data.get("created_at"))
|
|
65
|
+
|
|
66
|
+
if "files" not in data:
|
|
67
|
+
data["files"] = []
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
new_bundle, new_version = store.add_bundle(data)
|
|
71
|
+
except FileNotFoundError as err:
|
|
72
|
+
LOG.warning("File %s does not exist", err)
|
|
73
|
+
raise click.Abort
|
|
74
|
+
store.session.add(new_bundle)
|
|
75
|
+
new_version.bundle: Bundle = new_bundle
|
|
76
|
+
store.session.add(new_version)
|
|
77
|
+
if not exclude:
|
|
78
|
+
try:
|
|
79
|
+
context_root_dir: str = context.obj[ROOT]
|
|
80
|
+
include_version(global_root=context_root_dir, version_obj=new_version)
|
|
81
|
+
except VersionIncludedError as error:
|
|
82
|
+
LOG.error(error.message)
|
|
83
|
+
raise click.Abort
|
|
84
|
+
new_version.included_at = dt.datetime.now()
|
|
85
|
+
store.session.commit()
|
|
86
|
+
LOG.info("new bundle added: %s (%s)", new_bundle.name, new_bundle.id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@add.command("file")
|
|
90
|
+
@click.option("-t", "--tag", "tags", multiple=True, help="tag to associate the file by")
|
|
91
|
+
@click.option("-b", "--bundle-name", help="name of bundle that file should be added to")
|
|
92
|
+
@click.option("-j", "--json", help="json formatted input")
|
|
93
|
+
@click.option(
|
|
94
|
+
"-kip",
|
|
95
|
+
"--keep-input-path",
|
|
96
|
+
is_flag=True,
|
|
97
|
+
default=False,
|
|
98
|
+
show_default=True,
|
|
99
|
+
help="Flag to use the input path in Housekeeper",
|
|
100
|
+
)
|
|
101
|
+
@click.argument("path", required=False)
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def file_cmd(
|
|
104
|
+
context: click.Context,
|
|
105
|
+
tags: list[str],
|
|
106
|
+
bundle_name: str,
|
|
107
|
+
json: str,
|
|
108
|
+
keep_input_path: bool,
|
|
109
|
+
path: str,
|
|
110
|
+
):
|
|
111
|
+
"""Add a file to the latest version of a bundle."""
|
|
112
|
+
LOG.info("Running add file")
|
|
113
|
+
store: Store = context.obj["store"]
|
|
114
|
+
validate_args(arg=path, json=json, arg_name="path")
|
|
115
|
+
|
|
116
|
+
data: dict = {}
|
|
117
|
+
if json:
|
|
118
|
+
data: dict = load_json(json)
|
|
119
|
+
validate_input(data, input_type="file")
|
|
120
|
+
|
|
121
|
+
file_path: Path = Path(data.get("path", path))
|
|
122
|
+
if not file_path.exists():
|
|
123
|
+
LOG.warning("File: %s does not exist", file_path)
|
|
124
|
+
raise click.Abort
|
|
125
|
+
|
|
126
|
+
bundle_name = data.get("bundle", bundle_name)
|
|
127
|
+
bundle = store.get_bundle_by_name(bundle_name=bundle_name)
|
|
128
|
+
|
|
129
|
+
if bundle is None:
|
|
130
|
+
LOG.warning("unknown bundle: %s", bundle_name)
|
|
131
|
+
raise click.Abort
|
|
132
|
+
|
|
133
|
+
tags = data.get("tags", tags)
|
|
134
|
+
if not keep_input_path:
|
|
135
|
+
version: Version = bundle.versions[0]
|
|
136
|
+
link_to_relative_path(version=version, file_path=file_path, root_path=context.obj[ROOT])
|
|
137
|
+
file_path: Path = relative_path(version=version, file=file_path)
|
|
138
|
+
new_file = store.add_file(file_path=file_path, bundle=bundle, tags=tags)
|
|
139
|
+
store.session.add(new_file)
|
|
140
|
+
store.session.commit()
|
|
141
|
+
LOG.info("new file added: %s (%s)", new_file.path, new_file.id)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@add.command("version")
|
|
145
|
+
@click.option("-j", "--json", help="Input in json format")
|
|
146
|
+
@click.option("--created-at", help="Date when created")
|
|
147
|
+
@click.argument("bundle_name", required=False)
|
|
148
|
+
@click.pass_context
|
|
149
|
+
def version_cmd(context: click.Context, bundle_name: str, created_at: str, json: str):
|
|
150
|
+
"""Add a new version to a bundle."""
|
|
151
|
+
LOG.info("Running add version")
|
|
152
|
+
store: Store = context.obj["store"]
|
|
153
|
+
|
|
154
|
+
validate_args(arg=bundle_name, json=json, arg_name="bundle_name")
|
|
155
|
+
|
|
156
|
+
data: dict = {"bundle_name": bundle_name, "created_at": created_at}
|
|
157
|
+
if json:
|
|
158
|
+
data: dict = load_json(json)
|
|
159
|
+
bundle_name = data["bundle_name"]
|
|
160
|
+
|
|
161
|
+
data["created_at"] = data.get("created_at") or str(dt.datetime.now())
|
|
162
|
+
validate_input(data, input_type="version")
|
|
163
|
+
|
|
164
|
+
bundle = store.get_bundle_by_name(bundle_name=bundle_name)
|
|
165
|
+
if bundle is None:
|
|
166
|
+
LOG.warning("unknown bundle: %s", bundle_name)
|
|
167
|
+
raise click.Abort
|
|
168
|
+
|
|
169
|
+
data["created_at"] = get_date(data.get("created_at"))
|
|
170
|
+
new_version = store.add_version(data, bundle)
|
|
171
|
+
|
|
172
|
+
if not new_version:
|
|
173
|
+
LOG.warning("Seems like version already exists for the bundle")
|
|
174
|
+
raise click.Abort
|
|
175
|
+
|
|
176
|
+
store.session.add(new_version)
|
|
177
|
+
store.session.commit()
|
|
178
|
+
LOG.info("new version (%s) added to bundle %s", new_version.id, bundle.name)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@add.command("tag")
|
|
182
|
+
@click.argument("tags", nargs=-1)
|
|
183
|
+
@click.option("-f", "--file-id", type=int)
|
|
184
|
+
@click.pass_context
|
|
185
|
+
def tag_cmd(context: click.Context, tags: list[str], file_id: int):
|
|
186
|
+
"""Add tags to housekeeper. Use `--file-id` to add tags to existing file"""
|
|
187
|
+
LOG.info("Running add tag")
|
|
188
|
+
store: Store = context.obj["store"]
|
|
189
|
+
file = None
|
|
190
|
+
if not tags:
|
|
191
|
+
LOG.warning("No tags provided")
|
|
192
|
+
raise click.Abort
|
|
193
|
+
|
|
194
|
+
if file_id:
|
|
195
|
+
file = store.get_file_by_id(file_id=file_id)
|
|
196
|
+
|
|
197
|
+
if not file:
|
|
198
|
+
LOG.warning(f"unable to find file with id: {file_id}")
|
|
199
|
+
raise click.Abort
|
|
200
|
+
|
|
201
|
+
tag_name: str
|
|
202
|
+
for tag_name in tags:
|
|
203
|
+
tag: Tag = store.get_tag(tag_name)
|
|
204
|
+
|
|
205
|
+
if not tag:
|
|
206
|
+
LOG.info("%s: tag created", tag_name)
|
|
207
|
+
tag: Tag = store.new_tag(tag_name)
|
|
208
|
+
store.session.add(tag)
|
|
209
|
+
store.session.commit()
|
|
210
|
+
|
|
211
|
+
if not file:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if tag in file.tags:
|
|
215
|
+
LOG.info("%s: tag already added", tag_name)
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
file.tags.append(tag)
|
|
219
|
+
|
|
220
|
+
store.session.commit()
|
|
221
|
+
|
|
222
|
+
if not file:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
all_tags: Generator = (tag.name for tag in file.tags)
|
|
226
|
+
LOG.info("file tags: %s", ", ".join(all_tags))
|
housekeeper/cli/core.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Module for base CLI"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import coloredlogs
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
import housekeeper
|
|
10
|
+
from housekeeper.constants import ROOT
|
|
11
|
+
from housekeeper.services.file_service.file_service import FileService
|
|
12
|
+
from housekeeper.services.file_report_service.file_report_service import FileReportService
|
|
13
|
+
from housekeeper.store.database import initialize_database
|
|
14
|
+
from housekeeper.store.store import Store
|
|
15
|
+
|
|
16
|
+
from . import add, delete, get, include, init
|
|
17
|
+
|
|
18
|
+
LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.option("-c", "--config", type=click.File())
|
|
23
|
+
@click.option("-d", "--database", help="path/URI of the SQL database")
|
|
24
|
+
@click.option("-r", "--root", type=click.Path(exists=True), help="Housekeeper root dir")
|
|
25
|
+
@click.option("-l", "--log-level", default="INFO")
|
|
26
|
+
@click.version_option(housekeeper.__version__, prog_name=housekeeper.__title__)
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def base(
|
|
29
|
+
context: click.Context,
|
|
30
|
+
config: click.File,
|
|
31
|
+
database: str | None,
|
|
32
|
+
root: str | None,
|
|
33
|
+
log_level: str,
|
|
34
|
+
):
|
|
35
|
+
"""Housekeeper - Access your files!"""
|
|
36
|
+
coloredlogs.install(level=log_level)
|
|
37
|
+
config_values = {}
|
|
38
|
+
if config:
|
|
39
|
+
config_values = yaml.full_load(config)
|
|
40
|
+
context.obj = config_values
|
|
41
|
+
db_path = database or context.obj.get("database")
|
|
42
|
+
root_path = context.obj[ROOT] = root or context.obj.get(ROOT)
|
|
43
|
+
if not db_path:
|
|
44
|
+
LOG.error("Please point to a database")
|
|
45
|
+
raise click.Abort
|
|
46
|
+
if not root_path:
|
|
47
|
+
LOG.error("Please specify a root dir")
|
|
48
|
+
raise click.Abort
|
|
49
|
+
context.obj["database"] = db_path
|
|
50
|
+
LOG.info("Use root path %s", root_path)
|
|
51
|
+
initialize_database(db_path)
|
|
52
|
+
store = Store(root=root_path)
|
|
53
|
+
context.obj["store"] = store
|
|
54
|
+
context.obj["file_service"] = FileService(store)
|
|
55
|
+
context.obj["file_report_service"] = FileReportService()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
base.add_command(init.init)
|
|
59
|
+
base.add_command(add.add)
|
|
60
|
+
base.add_command(include.include)
|
|
61
|
+
base.add_command(get.get)
|
|
62
|
+
base.add_command(delete.delete)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Module for deleting via CLI"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from housekeeper.date import get_date
|
|
10
|
+
from housekeeper.store.models import File, Tag
|
|
11
|
+
from housekeeper.store.store import Store
|
|
12
|
+
|
|
13
|
+
LOG = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def delete():
|
|
18
|
+
"""Delete things in the database."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@delete.command("bundle")
|
|
22
|
+
@click.option("-y", "--yes", is_flag=True, help="skip checks")
|
|
23
|
+
@click.argument("bundle_name")
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def bundle_cmd(context, yes, bundle_name):
|
|
26
|
+
"""Delete a empty bundle, that is a bundle without versions."""
|
|
27
|
+
store: Store = context.obj["store"]
|
|
28
|
+
bundle = store.get_bundle_by_name(bundle_name=bundle_name)
|
|
29
|
+
if bundle is None:
|
|
30
|
+
LOG.warning("bundle %s not found", bundle_name)
|
|
31
|
+
raise click.Abort
|
|
32
|
+
|
|
33
|
+
if bundle.versions:
|
|
34
|
+
LOG.warning("Can not delete bundle, please remove all versions first")
|
|
35
|
+
raise click.Abort
|
|
36
|
+
|
|
37
|
+
question = f"Remove bundle {bundle.name} from database?"
|
|
38
|
+
if not (yes or click.confirm(question)):
|
|
39
|
+
raise click.Abort
|
|
40
|
+
|
|
41
|
+
store.session.delete(bundle)
|
|
42
|
+
store.session.commit()
|
|
43
|
+
LOG.info(f"Deleted bundle {bundle.name}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@delete.command("version")
|
|
47
|
+
@click.option("-b", "--bundle-name", help="Fetch all versions from a bundle")
|
|
48
|
+
@click.option("-i", "--version-id", type=int, help="Fetch a specific version")
|
|
49
|
+
@click.option("-y", "--yes", is_flag=True, help="skip checks")
|
|
50
|
+
@click.pass_context
|
|
51
|
+
def version_cmd(context, bundle_name, version_id, yes):
|
|
52
|
+
"""Delete a version from database."""
|
|
53
|
+
store: Store = context.obj["store"]
|
|
54
|
+
if not (bundle_name or version_id):
|
|
55
|
+
LOG.info("Please select a bundle or a version")
|
|
56
|
+
raise click.Abort
|
|
57
|
+
|
|
58
|
+
if bundle_name:
|
|
59
|
+
bundle = store.get_bundle_by_name(bundle_name=bundle_name)
|
|
60
|
+
if not bundle:
|
|
61
|
+
LOG.info(f"Could not find bundle {bundle_name}")
|
|
62
|
+
return
|
|
63
|
+
if len(bundle.versions) == 0:
|
|
64
|
+
LOG.warning(f"Could not find versions for bundle {bundle_name}")
|
|
65
|
+
return
|
|
66
|
+
LOG.info(f"Deleting the latest version of bundle {bundle_name}")
|
|
67
|
+
version_obj = bundle.versions[0]
|
|
68
|
+
|
|
69
|
+
if version_id:
|
|
70
|
+
version = store.get_version_by_id(version_id=version_id)
|
|
71
|
+
if not version:
|
|
72
|
+
LOG.warning(f"Could not find version {version_id}")
|
|
73
|
+
raise click.Abort
|
|
74
|
+
bundle = store.get_bundle_by_id(bundle_id=version.bundle_id)
|
|
75
|
+
for ver in bundle.versions:
|
|
76
|
+
if ver.id == version_id:
|
|
77
|
+
version_obj = ver
|
|
78
|
+
|
|
79
|
+
if version_obj.included_at:
|
|
80
|
+
question = f"Remove bundle version {version_obj.full_path} from file system and database?"
|
|
81
|
+
else:
|
|
82
|
+
question = f"Remove bundle version {version_obj.created_at.date()} from database?"
|
|
83
|
+
|
|
84
|
+
if not (yes or click.confirm(question)):
|
|
85
|
+
raise click.Abort
|
|
86
|
+
|
|
87
|
+
if version_obj.included_at:
|
|
88
|
+
shutil.rmtree(version_obj.full_path, ignore_errors=True)
|
|
89
|
+
|
|
90
|
+
store.session.delete(version_obj)
|
|
91
|
+
store.session.commit()
|
|
92
|
+
LOG.info(f"version deleted: {version_obj.full_path}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@delete.command("files")
|
|
96
|
+
@click.option("--yes", is_flag=True, help="skip checks")
|
|
97
|
+
@click.option("-t", "--tag", multiple=True, help="file tag")
|
|
98
|
+
@click.option("-b", "--bundle-name", help="bundle name")
|
|
99
|
+
@click.option("-a", "--before", help="version created before...")
|
|
100
|
+
@click.option("-n", "--notondisk", is_flag=True, help="rm db entry from files not on disk")
|
|
101
|
+
@click.option("-l", "--list-files", is_flag=True, help="lists files that will be deleted")
|
|
102
|
+
@click.option("-L", "--list-files-verbose", is_flag=True, help="lists additional information")
|
|
103
|
+
@click.pass_context
|
|
104
|
+
def files_cmd(context, yes, tag, bundle_name, before, notondisk, list_files, list_files_verbose):
|
|
105
|
+
"""Delete files based on tags."""
|
|
106
|
+
|
|
107
|
+
validate_delete_options(tag=tag, bundle_name=bundle_name)
|
|
108
|
+
before_date = parse_date(before) if before else None
|
|
109
|
+
store: Store = context.obj["store"]
|
|
110
|
+
|
|
111
|
+
if bundle_name:
|
|
112
|
+
validate_bundle_exists(store=store, bundle_name=bundle_name)
|
|
113
|
+
|
|
114
|
+
files = store.get_files_before(bundle_name=bundle_name, tag_names=tag, before_date=before_date)
|
|
115
|
+
|
|
116
|
+
if notondisk:
|
|
117
|
+
files = store.get_files_not_on_disk(files=files)
|
|
118
|
+
|
|
119
|
+
if not files:
|
|
120
|
+
LOG.warning("No files found")
|
|
121
|
+
raise click.Abort
|
|
122
|
+
|
|
123
|
+
if list_files_verbose:
|
|
124
|
+
list_files_verbosely(files=files)
|
|
125
|
+
|
|
126
|
+
elif list_files:
|
|
127
|
+
list_files_with_full_path(files=files)
|
|
128
|
+
|
|
129
|
+
if not (yes or click.confirm(f"Are you sure you want to delete {len(files)} files?")):
|
|
130
|
+
raise click.Abort
|
|
131
|
+
|
|
132
|
+
for file in files:
|
|
133
|
+
if file.archive:
|
|
134
|
+
LOG.warning(
|
|
135
|
+
f"File {file.path} is archived, please delete it with 'cg archive delete-file' instead. Skipping."
|
|
136
|
+
)
|
|
137
|
+
continue
|
|
138
|
+
if yes or click.confirm(f"Remove file from disk and database: {file.full_path}?"):
|
|
139
|
+
delete_file(file=file, store=store)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@delete.command("tag")
|
|
143
|
+
@click.option("-n", "--name", help="Name of the tag to delete")
|
|
144
|
+
@click.option("-y", "--yes", is_flag=True, help="skip checks")
|
|
145
|
+
@click.pass_context
|
|
146
|
+
def tag_cmd(context, yes, name: str):
|
|
147
|
+
"""Delete a tag from the database.
|
|
148
|
+
Entries in the file_tag_link table associated with the tag will be removed."""
|
|
149
|
+
store: Store = context.obj["store"]
|
|
150
|
+
tag: Tag | None = store.get_tag(tag_name=name)
|
|
151
|
+
if not tag:
|
|
152
|
+
LOG.warning(f"Tag {name} not found")
|
|
153
|
+
raise click.Abort
|
|
154
|
+
|
|
155
|
+
tag_file_count: int = len(tag.files) if tag.files else 0
|
|
156
|
+
question = f"Delete tag {name} with {tag_file_count} associated files?"
|
|
157
|
+
|
|
158
|
+
if yes or click.confirm(question):
|
|
159
|
+
store.session.delete(tag)
|
|
160
|
+
store.session.commit()
|
|
161
|
+
LOG.info(f"Tag {name} deleted")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def validate_delete_options(tag: str, bundle_name: str):
|
|
165
|
+
"""Validate delete options."""
|
|
166
|
+
if not (tag or bundle_name):
|
|
167
|
+
LOG.info("Please specify a bundle or a tag")
|
|
168
|
+
raise click.Abort
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def validate_bundle_exists(store: Store, bundle_name: str):
|
|
172
|
+
"""Validate bundle exists."""
|
|
173
|
+
if not store.get_bundle_by_name(bundle_name=bundle_name):
|
|
174
|
+
LOG.warning(f"Bundle {bundle_name} not found")
|
|
175
|
+
raise click.Abort
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def list_files_verbosely(files):
|
|
179
|
+
"""List files verbosely."""
|
|
180
|
+
for file in files:
|
|
181
|
+
tags = ", ".join(tag.name for tag in file.tags)
|
|
182
|
+
click.echo(
|
|
183
|
+
f"{click.style(str(file.id), fg='blue')} | {file.full_path} | "
|
|
184
|
+
f"{click.style(tags, fg='yellow')}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def list_files_with_full_path(files):
|
|
189
|
+
for file in files:
|
|
190
|
+
click.echo(file.full_path)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_file(file: File, store: Store):
|
|
194
|
+
file_path = Path(file.full_path)
|
|
195
|
+
if file_should_be_unlinked(file):
|
|
196
|
+
file_path.unlink()
|
|
197
|
+
|
|
198
|
+
store.session.delete(file)
|
|
199
|
+
store.session.commit()
|
|
200
|
+
LOG.info(f"{file.full_path} deleted")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def file_should_be_unlinked(file: File):
|
|
204
|
+
"""Check if file should be unlinked."""
|
|
205
|
+
file_path = Path(file.full_path)
|
|
206
|
+
return file.is_included and (file_path.exists() or file_path.is_symlink())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse_date(date: str):
|
|
210
|
+
"""Attempt to parse date in two different formats."""
|
|
211
|
+
try:
|
|
212
|
+
return get_date(date=date)
|
|
213
|
+
except ValueError:
|
|
214
|
+
return get_date(date=date, date_format="%Y-%m-%d %H:%M:%S")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@delete.command("file")
|
|
218
|
+
@click.option("-y", "--yes", is_flag=True, help="skip checks")
|
|
219
|
+
@click.argument("file_id", type=int)
|
|
220
|
+
@click.pass_context
|
|
221
|
+
def file_cmd(context, yes, file_id):
|
|
222
|
+
"""Delete a file."""
|
|
223
|
+
store: Store = context.obj["store"]
|
|
224
|
+
file = store.get_file_by_id(file_id=file_id)
|
|
225
|
+
if not file:
|
|
226
|
+
LOG.info("file not found")
|
|
227
|
+
raise click.Abort
|
|
228
|
+
if file.archive:
|
|
229
|
+
LOG.warning("File is archived, please delete it with 'cg archive delete-file' instead")
|
|
230
|
+
raise click.Abort
|
|
231
|
+
|
|
232
|
+
if file.is_included:
|
|
233
|
+
question = f"Remove file {file.full_path} from file system and database?"
|
|
234
|
+
else:
|
|
235
|
+
question = f"Remove file {file.full_path} from database?"
|
|
236
|
+
|
|
237
|
+
if yes or click.confirm(question):
|
|
238
|
+
if file.is_included and Path(file.full_path).exists():
|
|
239
|
+
Path(file.full_path).unlink()
|
|
240
|
+
|
|
241
|
+
store.session.delete(file)
|
|
242
|
+
store.session.commit()
|
|
243
|
+
LOG.info("file deleted")
|