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.
Files changed (88) hide show
  1. README.md +91 -0
  2. housekeeper/__init__.py +2 -0
  3. housekeeper/__main__.py +16 -0
  4. housekeeper/archive/__init__.py +0 -0
  5. housekeeper/cli/__init__.py +2 -0
  6. housekeeper/cli/add.py +226 -0
  7. housekeeper/cli/core.py +62 -0
  8. housekeeper/cli/delete.py +243 -0
  9. housekeeper/cli/get.py +181 -0
  10. housekeeper/cli/include.py +59 -0
  11. housekeeper/cli/init.py +30 -0
  12. housekeeper/cli/tables.py +67 -0
  13. housekeeper/constants.py +11 -0
  14. housekeeper/date.py +70 -0
  15. housekeeper/exc.py +14 -0
  16. housekeeper/files.py +50 -0
  17. housekeeper/include.py +68 -0
  18. housekeeper/services/__init__.py +0 -0
  19. housekeeper/services/file_report_service/__init__.py +0 -0
  20. housekeeper/services/file_report_service/file_report_service.py +26 -0
  21. housekeeper/services/file_report_service/utils.py +121 -0
  22. housekeeper/services/file_service/__init__.py +0 -0
  23. housekeeper/services/file_service/file_service.py +18 -0
  24. housekeeper/store/__init__.py +1 -0
  25. housekeeper/store/api/__init__.py +0 -0
  26. housekeeper/store/api/schema.py +122 -0
  27. housekeeper/store/base.py +40 -0
  28. housekeeper/store/crud/__init__.py +0 -0
  29. housekeeper/store/crud/create.py +159 -0
  30. housekeeper/store/crud/read.py +310 -0
  31. housekeeper/store/crud/update.py +70 -0
  32. housekeeper/store/database.py +54 -0
  33. housekeeper/store/filters/__init__.py +0 -0
  34. housekeeper/store/filters/archive_filters.py +61 -0
  35. housekeeper/store/filters/bundle_filters.py +39 -0
  36. housekeeper/store/filters/file_filters.py +76 -0
  37. housekeeper/store/filters/tag_filters.py +31 -0
  38. housekeeper/store/filters/version_bundle_filters.py +36 -0
  39. housekeeper/store/filters/version_filters.py +38 -0
  40. housekeeper/store/models.py +124 -0
  41. housekeeper/store/store.py +32 -0
  42. housekeeper-4.13.5.dist-info/LICENSE +7 -0
  43. housekeeper-4.13.5.dist-info/METADATA +114 -0
  44. housekeeper-4.13.5.dist-info/RECORD +88 -0
  45. housekeeper-4.13.5.dist-info/WHEEL +4 -0
  46. housekeeper-4.13.5.dist-info/entry_points.txt +3 -0
  47. tests/__init__.py +0 -0
  48. tests/cli/add/conftest.py +18 -0
  49. tests/cli/add/test_cli_add_bundle.py +162 -0
  50. tests/cli/add/test_cli_add_file.py +171 -0
  51. tests/cli/add/test_cli_add_tags.py +101 -0
  52. tests/cli/add/test_cli_add_version.py +190 -0
  53. tests/cli/conftest.py +121 -0
  54. tests/cli/delete/test_cli_delete_bundle.py +82 -0
  55. tests/cli/delete/test_cli_delete_file.py +66 -0
  56. tests/cli/delete/test_cli_delete_files.py +130 -0
  57. tests/cli/delete/test_cli_delete_tag.py +89 -0
  58. tests/cli/delete/test_cli_delete_version.py +25 -0
  59. tests/cli/get/test_get_bundle.py +165 -0
  60. tests/cli/get/test_get_files.py +164 -0
  61. tests/cli/get/test_get_tag.py +75 -0
  62. tests/cli/get/test_get_version.py +100 -0
  63. tests/cli/test_cli_core.py +122 -0
  64. tests/cli/test_cli_include.py +223 -0
  65. tests/conftest.py +519 -0
  66. tests/fixtures/26a90105b99c05381328317f913e9509e373b64f.txt +15 -0
  67. tests/fixtures/sequencing_files/lane1.spring +0 -0
  68. tests/fixtures/sequencing_files/lane2.spring +0 -0
  69. tests/fixtures/sequencing_files/lane3.spring +0 -0
  70. tests/fixtures/vcfs/example.2.vcf +0 -0
  71. tests/fixtures/vcfs/example.vcf +0 -0
  72. tests/fixtures/vcfs/family.2.vcf +0 -0
  73. tests/fixtures/vcfs/family.3.vcf +0 -0
  74. tests/fixtures/vcfs/family.vcf +0 -0
  75. tests/helper_functions.py +73 -0
  76. tests/store/conftest.py +70 -0
  77. tests/store/crud/test_createhandler.py +216 -0
  78. tests/store/crud/test_readhandler.py +339 -0
  79. tests/store/crud/test_updatehandler.py +126 -0
  80. tests/store/filters/test_archive_filters.py +111 -0
  81. tests/store/filters/test_bundle_filters.py +59 -0
  82. tests/store/filters/test_file_filters.py +155 -0
  83. tests/store/filters/test_tag_filters.py +82 -0
  84. tests/store/filters/test_version_bundle_filters.py +57 -0
  85. tests/store/filters/test_version_filters.py +69 -0
  86. tests/store/test_models.py +64 -0
  87. tests/test_date.py +90 -0
  88. 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
@@ -0,0 +1,2 @@
1
+ __title__ = "housekeeper"
2
+ __version__ = "4.13.5"
@@ -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
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .core import base
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))
@@ -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")