Skip to content

Commit 0420562

Browse files
committed
Implement CLI resource export command
Closes #1000
1 parent 7cff970 commit 0420562

File tree

3 files changed

+155
-21
lines changed

3 files changed

+155
-21
lines changed

Tekst-API/tekst/__main__.py

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import asyncio
22

3+
from pathlib import Path
4+
from typing import get_args
5+
36
import click
47

8+
from beanie import PydanticObjectId
9+
from beanie.operators import Eq, In
10+
511
from tekst.config import get_config
612
from tekst.db import init_odm, migrations
13+
from tekst.errors import TekstHTTPException
14+
from tekst.models.resource import (
15+
ResourceBaseDocument,
16+
ResourceExportFormat,
17+
res_exp_fmt_info,
18+
)
719
from tekst.openapi import generate_openapi_json
820
from tekst.platform import bootstrap as app_bootstrap
921
from tekst.platform import cleanup_task
1022
from tekst.resources import call_resource_precompute_hooks
23+
from tekst.routers.resources import export_resource_contents_task
1124
from tekst.search import create_indices_task
1225

1326

@@ -16,32 +29,97 @@
1629
"""
1730

1831

19-
async def _prepare_odm() -> None:
20-
await init_odm()
21-
22-
2332
async def _create_indices() -> None:
24-
await _prepare_odm()
33+
await init_odm()
2534
await create_indices_task()
2635

2736

2837
async def _refresh_precomputed_cache() -> None:
29-
await _prepare_odm()
38+
await init_odm()
3039
await call_resource_precompute_hooks()
3140

3241

3342
async def _cleanup() -> None:
34-
await _prepare_odm()
43+
await init_odm()
3544
await cleanup_task()
3645

3746

3847
async def _maintenance() -> None:
39-
await _prepare_odm()
48+
await init_odm()
4049
await create_indices_task()
4150
await call_resource_precompute_hooks()
4251
await cleanup_task()
4352

4453

54+
async def _export(
55+
resource_ids: list[PydanticObjectId] | None,
56+
*,
57+
formats: list[ResourceExportFormat],
58+
output_dir_path: Path,
59+
quiet: bool = False,
60+
delete: bool = False,
61+
) -> None:
62+
# check output path
63+
if not output_dir_path.exists():
64+
output_dir_path.mkdir(parents=True)
65+
if not output_dir_path.is_dir():
66+
click.echo(f"Output directory {output_dir_path} is not a directory", err=True)
67+
exit(1)
68+
# delete all existing files in output directory
69+
if delete:
70+
if not quiet:
71+
click.echo(f"Deleting all existing files in {output_dir_path} ...")
72+
for child in output_dir_path.iterdir():
73+
if child.is_file():
74+
child.unlink()
75+
# prepare system
76+
await init_odm()
77+
await call_resource_precompute_hooks()
78+
# get IDs of resources to export
79+
target_resources = await ResourceBaseDocument.find(
80+
Eq(ResourceBaseDocument.public, True),
81+
In(ResourceBaseDocument.id, resource_ids) if resource_ids else {},
82+
with_children=True,
83+
).to_list()
84+
# give feedback on resources that could not be found
85+
for res in resource_ids or []:
86+
if res not in [res.id for res in target_resources]:
87+
click.echo(f"Resource ID {res} not found or not public", err=True)
88+
# run exports
89+
if not target_resources:
90+
click.echo("No resources to export", err=True)
91+
exit(1)
92+
cfg = get_config()
93+
for res in target_resources:
94+
res_id_str = str(res.id)
95+
for fmt in formats:
96+
if not quiet:
97+
click.echo(f"Exporting resource {res_id_str} as {fmt} ...")
98+
try:
99+
export_props = await export_resource_contents_task(
100+
user=None,
101+
cfg=cfg,
102+
resource_id=res.id,
103+
export_format=fmt,
104+
)
105+
except TekstHTTPException as e:
106+
if e.detail.detail.key == "unsupportedExportFormat":
107+
click.echo(
108+
f"Resource {res_id_str} does not support export format {fmt}",
109+
err=True,
110+
)
111+
continue
112+
else:
113+
raise
114+
# move exported file to output directory
115+
source_path = cfg.temp_files_dir / export_props["artifact"]
116+
target_ext = res_exp_fmt_info[fmt]["extension"]
117+
target_path = output_dir_path / f"{res_id_str}_{fmt}.{target_ext}"
118+
source_path.replace(target_path)
119+
if not quiet:
120+
click.echo(f"Exported resource {res_id_str} as {str(target_path)}.")
121+
122+
45123
@click.command()
46124
def bootstrap():
47125
"""Runs the Tekst initial bootstrap procedure"""
@@ -159,6 +237,69 @@ def schema(
159237
click.echo(schema_str)
160238

161239

240+
@click.command()
241+
@click.option(
242+
"--resource",
243+
"-r",
244+
required=False,
245+
default=None,
246+
show_default=True,
247+
multiple=True,
248+
help="ID(s) of resource(s) to export, all if not set",
249+
)
250+
@click.option(
251+
"--format",
252+
"-f",
253+
required=False,
254+
default=get_args(ResourceExportFormat.__value__),
255+
show_default=True,
256+
multiple=True,
257+
help="Format(s) to export, all if not set",
258+
)
259+
@click.option(
260+
"--output",
261+
"-o",
262+
required=False,
263+
default="/tmp/tekst_resource_export/",
264+
show_default=True,
265+
help="Output directory to write to",
266+
)
267+
@click.option(
268+
"--quiet",
269+
"-q",
270+
is_flag=True,
271+
default=False,
272+
help="Don't output anything (except errors and warnings)",
273+
)
274+
@click.option(
275+
"--delete",
276+
"-d",
277+
is_flag=True,
278+
default=False,
279+
help="Delete all existing files in the output directory",
280+
)
281+
def export(
282+
resource: list[str] | None,
283+
format: list[ResourceExportFormat],
284+
output: str,
285+
quiet: bool = False,
286+
delete: bool = False,
287+
) -> None:
288+
"""
289+
Exports the contents of the given resources (or all)
290+
using the given formats (or all) to the given output directory
291+
"""
292+
asyncio.run(
293+
_export(
294+
[PydanticObjectId(res_id) for res_id in resource] if resource else None,
295+
formats=format,
296+
output_dir_path=Path(output),
297+
quiet=quiet,
298+
delete=delete,
299+
)
300+
)
301+
302+
162303
@click.group()
163304
def cli():
164305
"""Command line interface to the main functionalities of Tekst server"""
@@ -173,6 +314,7 @@ def cli():
173314
cli.add_command(maintenance)
174315
cli.add_command(migrate)
175316
cli.add_command(schema)
317+
cli.add_command(export)
176318

177319

178320
if __name__ == "__main__":

Tekst-API/tekst/models/resource.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ class ResourceCoverage(ModelBase):
589589
details: list[ParentCoverage]
590590

591591

592-
ResourceExportFormat = Literal["json", "tekst-json", "csv"]
592+
type ResourceExportFormat = Literal["json", "tekst-json", "csv"]
593593

594594
res_exp_fmt_info = {
595595
"json": {
@@ -604,12 +604,4 @@ class ResourceCoverage(ModelBase):
604604
"extension": "csv",
605605
"mimetype": "text/csv",
606606
},
607-
"txt": {
608-
"extension": "txt",
609-
"mimetype": "text/plain",
610-
},
611-
"html": {
612-
"extension": "html",
613-
"mimetype": "text/html",
614-
},
615607
}

Tekst-API/tekst/routers/resources.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -995,13 +995,13 @@ async def import_resource_contents(
995995
)
996996

997997

998-
async def _export_resource_contents_task(
998+
async def export_resource_contents_task(
999999
user: OptionalUserDep,
10001000
cfg: TekstConfig,
10011001
resource_id: PydanticObjectId,
10021002
export_format: ResourceExportFormat,
1003-
location_from_id: PydanticObjectId | None,
1004-
location_to_id: PydanticObjectId | None,
1003+
location_from_id: PydanticObjectId | None = None,
1004+
location_to_id: PydanticObjectId | None = None,
10051005
) -> dict[str, Any]:
10061006
# check if user has permission to read this resource, if so, fetch from DB
10071007
resource: ResourceBaseDocument = await ResourceBaseDocument.find_one(
@@ -1142,7 +1142,7 @@ async def export_resource_contents(
11421142
raise errors.E_403_FORBIDDEN
11431143
# create and return background task
11441144
return await tasks.create_task(
1145-
_export_resource_contents_task,
1145+
export_resource_contents_task,
11461146
tasks.TaskType.RESOURCE_EXPORT,
11471147
user_id=user.id if user else None,
11481148
target_id=user.id

0 commit comments

Comments
 (0)