Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
include picpocket/database/*.sql
include picpocket/database/schema/*/*.sql
include picpocket/web/scripts/dialog.applescript
include picpocket/web/templates/*.css
include picpocket/web/templates/*.js
Expand Down
3 changes: 2 additions & 1 deletion picpocket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
.. todo::
Add support for managing Dockerized Postgres
"""

import base64
import logging
import random
Expand Down Expand Up @@ -76,7 +77,7 @@ async def initialize(
"minor": VERSION.minor,
"patch": VERSION.patch,
},
"backend": {"type": api_type.BACKEND_NAME},
"backend": {"type": backend},
"files": {"formats": sorted(IMAGE_FORMATS)},
"web": {
# TODO: rotate this automatically ever x days
Expand Down
115 changes: 102 additions & 13 deletions picpocket/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def parse_connection_info(

Parse information required for connecting to the underlying
database PicPocket is built on top of. Backends may take
whatever argumetns they want as long as they accept `directory`
whatever arguments they want as long as they accept `directory`
and `store_credentials`

Args:
Expand All @@ -105,7 +105,7 @@ async def initialize(self):
exception if anything fails.
"""

def get_api_version(self) -> Version:
def api_version(self) -> Version:
"""Get the version of the PicPocket API

This should always match package version.
Expand All @@ -114,27 +114,93 @@ def get_api_version(self) -> Version:
A Version object.
"""

async def get_version(self) -> Version:
"""Get teh version of the backend API
def backend_api_version(self) -> Version:
"""Get the version of the backend API

Returns:
The version of the backend interface being used.
"""

async def matching_version(self) -> bool:
"""Check version compatibility
async def compatible_backend(self) -> bool:
"""Check that the configured backend matches the actual backend

Check whether the configured backend is compatible with this API
Returns:
True if the backend type and version match
"""

async def upgrade_backend(self, path: Optional[Path]) -> Optional[Path]:
"""Update the backend version

Migrate the backend storage to a version supported by the
PicPocket API.

Args:
path: A location to save a backup prior to migrating the
back end

Returns:
The backup file if one was generated
"""

async def create_backup(self, path: Path) -> Path:
"""Backup PicPocket data

Create a backup of PicPocket's backend (locations, tasks, image
info, tags).

Returns:
The path to the generated backup file.

.. note::
Unlike `export_data`, this stores the data in a
backend-specific way. `create_backup` will create a file
that is (probably) smaller and (probably) quicker to restore
than `export_data` but will only be usable by the current
backend.

.. note::
`create_backup` may not be implemented for all backends.

.. warning::
This file will not contain the images themselves, just the
metadata you've created for the image (tags, captions,
alt text, etc.).

Args:
path: The directory to save the backup to. The format of
the resulting backup is backend-specific.
"""

async def restore_backup(self, path: Path):
"""Restore PicPocket data from a previously created backup

Restore a backup of PicPocket's backend (locations, tasks, image
info, tags). This operation will be destructive.

.. note::
Unlike `import_data`, the backup must have been created from
the same backend as the one currently being used.

.. note::
`restore_backup` may not be implemented for all backends.

.. warning::
This backup will not contain the images themselves, just the
metadata you've created for the images (tags, captions,
alt text, etc.).

Args:
path: The path to the existing backup file.
"""

async def import_data(
self,
path: Path,
locations: Optional[list[str] | dict[str, Optional[Path]]] = None,
):
"""Import a PicPocket backup
"""Import PicPocket data

Load data (locations, tasks, images, tags) from a PicPocket
Load data (locations, tasks, image info, tags) from a PicPocket
backup created using :meth:`.export_data`. This is the
recommended way to migrate between backends.

Expand Down Expand Up @@ -162,18 +228,25 @@ async def import_data(
async def export_data(
self, path: Path, locations: Optional[list[str | int]] = None
):
"""Create a PicPocket backup
"""Export PicPocket data

Export data (locations, tasks, iamges, tags) from PicPocket.
Export data (locations, tasks, image info, tags) from PicPocket.
This is the recommended way to migrate between backends.

.. note::
Unlike `create_backup`, this stores the data in a
backend-agnostic way. `create_backup` will create a file
that is (probably) smaller and (probably) quicker to restore
than `export_data` but will only be usable by the current
backend.

.. warning::
This file will not contain the images themselves, just the
metadata you've created for the image (tags, captions,
alt text, etc.).

Args:
path: Where to save teh JSON file to store data to.
path: Where to save the JSON file to store data to.
locations: Only export images and task related to this
location.
"""
Expand Down Expand Up @@ -578,7 +651,8 @@ async def remove_image(self, id: int, *, delete: bool = False):
id: The image to remove from PicPocket
delete: Delete the image on-disk as well. If `True`, the
image will only be removed from PicPocket if the
delete is successful
delete is successful. Deleted files will be sent to the
trash.
"""

async def get_image(self, id: int, tags: bool = False) -> Optional[Image]:
Expand Down Expand Up @@ -884,6 +958,21 @@ async def get_tag(self, name: str, children: bool = False) -> Tag:
The tag
"""

async def set_tag_example(self, tag: str, image: int):
"""Set an image as the example of a tag

Args:
tag: The tag to add an image for
image: The id of an image that represents the tag
"""

async def clear_tag_example(self, tag: str):
"""Remove the example image for a tag

Args:
tag: The tag to remove an image example from.
"""

async def all_tag_names(self) -> set[str]:
"""Get the names of all used tags

Expand Down
83 changes: 80 additions & 3 deletions picpocket/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Run PicPocket from the command line"""

import asyncio
import json
import logging
Expand Down Expand Up @@ -100,7 +101,7 @@ def main(
runner.run(initialize(args.directory, args.backend, **kwargs))
else:
match args.command:
case "import" | "export" | "web":
case "upgrade" | "backup" | "restore" | "import" | "export" | "web":
function = run_meta
case "location":
function = run_location
Expand Down Expand Up @@ -306,6 +307,36 @@ def build_meta(action) -> list[ArgumentParser]:
),
)

upgrader = action.add_parser("upgrade", description="Upgrade the PicPocket backend")
upgrader_location_group = upgrader.add_mutually_exclusive_group()
upgrader_location_group.add_argument(
"--path",
default=Path.cwd(),
type=Path,
help="Where to save the backup file",
)
upgrader_location_group.add_argument(
"--no-backup",
dest="path",
action="store_const",
const=None,
help="Don't back up the database before upgrading",
)

backuper = action.add_parser("backup", description="Back up the PicPocket backend")
backuper.add_argument(
"path",
nargs="?",
default=Path.cwd(),
type=Path,
help="Where to save the backup file",
)

restorer = action.add_parser(
"restore", description="Restor a backup of the PicPocket backend"
)
restorer.add_argument("path", type=Path, help="The backup to restore")

importer = action.add_parser("import", description="Import a PicPocket backup")
importer.add_argument("path", type=full_path, help="The backup to import")
importer_locations_group = importer.add_mutually_exclusive_group()
Expand All @@ -325,7 +356,7 @@ def build_meta(action) -> list[ArgumentParser]:
exporter.add_argument("path", type=full_path, help="where to save the backup")
exporter.add_argument("--locations", nargs="*", help="Only export these locations")

return [web, importer, exporter]
return [web, upgrader, backuper, restorer, importer, exporter]


async def run_meta(picpocket: PicPocket, args: Namespace, print=print):
Expand All @@ -347,6 +378,31 @@ async def run_meta(picpocket: PicPocket, args: Namespace, print=print):
)
except KeyboardInterrupt:
print("shutting down")
case "upgrade":
try:
backup = await picpocket.upgrade_backend(path=args.path)
except Exception:
print("Upgrading database failed")
raise
else:
print("Upgrade complete")

if backup:
print(f"Backup of previous version saved to: {backup}")
case "backup":
try:
backup = await picpocket.create_backup(args.path)
except NotImplementedError:
print("Backups not supported for the current backend")
exit(1)
else:
print(f"Backup saved to {backup}")
case "restore":
try:
await picpocket.restore_backup(args.path)
except NotImplementedError:
print("Restoring backups not supported for the current backend")
exit(1)
case "import":
if args.locations:
if isinstance(args.locations[0], list):
Expand Down Expand Up @@ -1698,7 +1754,19 @@ def build_tags(action) -> list[ArgumentParser]:
help="Output tags as json",
)

return [add, move, remove, listing]
set_example = subparsers.add_parser(
"set",
help="Set an example image for a tag",
)
set_example.add_argument("name", help="The name of the tag")
set_example.add_argument("image", type=int, help="The image ID")

clear_example = subparsers.add_parser(
"clear", help="Clear the example image for a tag"
)
clear_example.add_argument("name", help="The name of the tag")

return [add, move, remove, listing, set_example, clear_example]


async def run_tag(picpocket: PicPocket, args: Namespace, print=print):
Expand All @@ -1722,6 +1790,12 @@ async def run_tag(picpocket: PicPocket, args: Namespace, print=print):
else:
for tag in sorted(tags):
print_tags(tags, print=print)
case "set":
await picpocket.set_tag_example(args.name, args.image)
print(f"example image set for tag {args.name}")
case "clear":
await picpocket.clear_tag_example(args.name)
print(f"example image cleared for tag {args.name}")
case _:
raise NotImplementedError(
f"Command 'tag {args.subcommand}' not implemented"
Expand Down Expand Up @@ -1777,6 +1851,9 @@ def print_tags(tags: dict, print=print, parents=""):
if value["description"]:
line = f"{line}: {value['description']}"

if value["exemplar"]:
line = f"{line} (see {value['exemplar']})"

print(line)
print_tags(value["children"], print=print, parents=name)

Expand Down
Loading