1
0
mirror of https://github.com/enpaul/kodak.git synced 2025-11-09 20:22:31 +00:00

Rename project to kodak

This commit is contained in:
2021-10-28 23:17:00 -04:00
parent 99d2ca4816
commit 67abbd5374
31 changed files with 132 additions and 152 deletions

9
kodak/__about__.py Normal file
View File

@@ -0,0 +1,9 @@
"""Programatically accessible project metadata"""
__title__ = "kodak"
__version__ = "0.1.0"
__authors__ = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
__license__ = "MIT"
__summary__ = "HTTP server for uploading images and generating thumbnails"
__url__ = "https://github.com/enpaul/kodak/"

0
kodak/__init__.py Normal file
View File

43
kodak/__main__.py Normal file
View File

@@ -0,0 +1,43 @@
"""Development server stub entrypoint
Flask comes with a built-in development server. This entrypoint allows ``kodak``
to be run directly to run the development server and expose some simple config options for ease of
access. Run the below command to start the server:
::
python -m kodak
In addition to the helpful CLI flags, the Flask development server run by this module will also
load any ``.env`` files in the current working directory when running the application.
.. warning:: As the development server will tell you on startup, do not use this for production
deployments.
"""
import argparse
import sys
from kodak.application import APPLICATION
# pylint: disable=invalid-name
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"-b",
"--bind",
help="Address or socket to bind the server to",
default="127.0.0.1",
)
parser.add_argument(
"-p", "--port", help="Port bind the server to", default=5000, type=int
)
parser.add_argument(
"-D", "--debug", help="Run Flask in debug mode", action="store_true"
)
args = parser.parse_args()
APPLICATION.run(host=args.bind, port=args.port, debug=args.debug, load_dotenv=True)
if __name__ == "__main__":
sys.exit(main())

32
kodak/_server.py Normal file
View File

@@ -0,0 +1,32 @@
import flask
from kodak import configuration
from kodak import database
from kodak import exceptions
def make_the_tea() -> None:
"""Just for fun
https://en.wikipedia.org/wiki/Hyper_Text_Coffee_Pot_Control_Protocol
"""
if flask.request.content_type == "message/coffeepot":
raise exceptions.IAmATeapotError(
f"Coffee brewing request for '{flask.request.path}' cannot be completed by teapot application"
)
def initialize_database() -> None:
"""Initialize the database connection"""
database.initialize(flask.current_app.appconfig)
class KodakFlask(flask.Flask):
"""Extend the default Flask object to add the custom application config
There's probably an easier/more kosher way to do this, but ¯\\_(ツ)_/¯
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.appconfig: configuration.KodakConfig = configuration.load()

17
kodak/application.py Normal file
View File

@@ -0,0 +1,17 @@
import flask_restful
from kodak import resources
from kodak._server import initialize_database
from kodak._server import KodakFlask
from kodak._server import make_the_tea
APPLICATION = KodakFlask(__name__)
API = flask_restful.Api(APPLICATION, catch_all_404s=True)
APPLICATION.before_request(make_the_tea)
APPLICATION.before_first_request(initialize_database)
for resource in resources.RESOURCES:
API.add_resource(resource, *resource.routes)

160
kodak/configuration.py Normal file
View File

@@ -0,0 +1,160 @@
import json
import os
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
from typing import Any
from typing import Dict
from typing import NamedTuple
from typing import Optional
from typing import Sequence
from typing import Union
from kodak import constants
class DimensionConfig(NamedTuple):
strategy: constants.DimensionStrategy
anchor: constants.Anchor
value: int
@dataclass
class DatabaseSqliteConfig:
path: Path = Path.cwd() / "fresnel.db"
pragmas: Dict[str, Any] = field(
default_factory=lambda: constants.DEFAULT_SQLITE_PRAGMAS
)
@classmethod
def from_env(cls):
return cls(
path=Path(os.environ.get("KODAK_DB_SQLITE_PATH", cls.path)),
pragmas=json.loads(os.environ["KODAK_DB_SQLITE_PRAGMAS"])
if "KODAK_DB_SQLITE_PRAGMAS" in os.environ
else constants.DEFAULT_SQLITE_PRAGMAS,
)
@dataclass
class DatabaseMariaConfig:
hostname: str = "localhost"
username: str = "root"
password: Optional[str] = None
port: int = 3306
schema: str = "fresnel"
@classmethod
def from_env(cls):
return cls(
hostname=os.getenv("KODAK_DB_MARIA_HOSTNAME", cls.hostname),
username=os.getenv("KODAK_DB_MARIA_USERNAME", cls.username),
password=os.environ.get("KODAK_DB_MARIA_PASSWORD", cls.password),
port=int(os.environ.get("KODAK_DB_MARIA_PORT", cls.port)),
schema=os.getenv("KODAK_DB_MARIA_SCHEMA", cls.schema),
)
@dataclass
class DatabaseConfig:
backend: constants.DatabaseBackend = constants.DatabaseBackend.SQLITE
sqlite: DatabaseSqliteConfig = field(default_factory=DatabaseSqliteConfig.from_env)
mariadb: DatabaseMariaConfig = field(default_factory=DatabaseMariaConfig.from_env)
@classmethod
def from_env(cls):
return cls(
backend=constants.DatabaseBackend[os.environ["KODAK_DB_BACKEND"].upper()]
if "KODAK_DB_BACKEND" in os.environ
else cls.backend
)
@dataclass
class ManipConfig:
name: str
strategy: constants.DimensionStrategy = constants.DimensionStrategy.SCALE
anchor: constants.Anchor = constants.Anchor.C
formats: Sequence[constants.ImageFormat] = (
constants.ImageFormat.JPEG,
constants.ImageFormat.PNG,
)
horizontal: Optional[Union[int, float]] = None
vertical: Optional[Union[int, float]] = None
@classmethod
def from_env(cls, key: str):
strategy = (
constants.DimensionStrategy[
os.environ[f"KODAK_MANIP_{key}_STRATEGY"].upper()
]
if f"KODAK_MANIP_{key}_STRATEGY" in os.environ
else cls.strategy
)
dimension_conversion = (
float if strategy == constants.DimensionStrategy.RELATIVE else int
)
return cls(
name=os.getenv(f"KODAK_MANIP_{key}_NAME", key.lower()),
strategy=strategy,
anchor=constants.Anchor(os.environ[f"KODAK_MANIP_{key}_ANCHOR"].lower())
if f"KODAK_MANIP_{key}_ANCHOR" in os.environ
else cls.anchor,
formats=[
constants.ImageFormat[item.upper()]
for item in os.environ[f"KODAK_MANIP_{key}_FORMATS"].split(",")
]
if f"KODAK_MANIP_{key}_FORMATS" in os.environ
else cls.formats,
horizontal=dimension_conversion(os.environ[f"KODAK_MANIP_{key}_HORIZONTAL"])
if f"KODAK_MANIP_{key}_HORIZONTAL" in os.environ
else cls.horizontal,
vertical=dimension_conversion(os.environ[f"KODAK_MANIP_{key}_VERTICAL"])
if f"KODAK_MANIP_{key}_VERTICAL" in os.environ
else cls.vertical,
)
@dataclass
class KodakConfig:
database: DatabaseConfig = field(default_factory=DatabaseConfig.from_env)
sourcedir: Path = Path.cwd() / "images"
manipdir: Path = Path.cwd() / "images"
expose_source: bool = False
private: bool = False
manips: Dict[str, ManipConfig] = field(default_factory=dict)
@classmethod
def from_env(cls):
manip_names = set(
[
key.replace("KODAK_MANIP_", "").partition("_")[0]
for key in os.environ.keys()
if key.startswith("KODAK_MANIP_")
]
)
return cls(
sourcedir=Path(os.environ.get("KODAK_SOURCEDIR", cls.sourcedir))
.expanduser()
.resolve(),
manipdir=Path(os.environ.get("KODAK_MANIPDIR", cls.manipdir))
.expanduser()
.resolve(),
expose_source=os.getenv(
"KODAK_EXPOSE_SOURCE", str(cls.expose_source)
).lower()
== "true",
private=os.getenv("KODAK_PRIVATE", str(cls.private)).lower() == "true",
manips={name.lower(): ManipConfig.from_env(name) for name in manip_names},
)
def load() -> KodakConfig:
try:
return KodakConfig.from_env()
except (ValueError, TypeError, IndexError, KeyError) as err:
raise RuntimeError(err)

41
kodak/constants.py Normal file
View File

@@ -0,0 +1,41 @@
import enum
import peewee
class DatabaseBackend(enum.Enum):
MARIADB = peewee.MySQLDatabase
SQLITE = peewee.SqliteDatabase
class DimensionStrategy(enum.Enum):
CROP = enum.auto()
SCALE = enum.auto()
RELATIVE = enum.auto()
class ImageFormat(enum.Enum):
JPEG = enum.auto()
PNG = enum.auto()
GIF = enum.auto()
class Anchor(enum.Enum):
TL = "top-left"
TC = "top-center"
TR = "top-center"
CL = "center-left"
C = "center"
CR = "center-right"
BL = "bottom-left"
BC = "bottom-center"
BR = "bottom-right"
DEFAULT_SQLITE_PRAGMAS = {
"journal_mode": "wal",
"cache_size": -1 * 64000,
"foreign_keys": 1,
"ignore_check_constraints": 0,
"synchronous": 0,
}

View File

@@ -0,0 +1,58 @@
import logging
from typing import Tuple
import peewee
from kodak import constants
from kodak.configuration import KodakConfig
from kodak.database._shared import INTERFACE as interface
from kodak.database._shared import KodakModel
from kodak.database.image import ImageRecord
from kodak.database.thumbnail import ThumbnailRecord
MODELS: Tuple[KodakModel, ...] = (ImageRecord, ThumbnailRecord)
def initialize(config: KodakConfig):
"""Initialize the database interface
Defining the database as an
`unconfigured proxy object <http://docs.peewee-orm.com/en/latest/peewee/database.html#setting-the-database-at-run-time>`_
allows it to be configured at runtime based on the config values.
:param config: Populated configuration container object
"""
logger = logging.getLogger(__name__)
if config.database.backend == constants.SupportedDatabaseBackend.SQLITE:
logger.debug("Using SQLite database backend")
logger.debug(f"Applying SQLite pragmas: {config.database.sqlite.pragmas}")
database = peewee.SqliteDatabase(
config.database.sqlite.path, pragmas=config.database.sqlite.pragmas
)
elif config.database.backend == constants.SupportedDatabaseBackend.MARIADB:
logger.debug("Using MariaDB database backend")
logger.debug(
"Configuring MariaDB:"
f" {config.database.mariadb.username}@{config.database.mariadb.hostname}:{config.database.mariadb.port},"
f" with database '{config.database.mariadb.schema}'"
)
database = peewee.MySQLDatabase(
config.database.mariadb.schema,
host=config.database.mariadb.hostname,
port=config.database.mariadb.port,
user=config.database.mariadb.username,
password=config.database.mariadb.password,
charset="utf8mb4",
)
else:
raise ValueError(
f"Invalid storage backend in configuration: {config.database.backend}"
)
interface.initialize(database)
with interface.atomic():
interface.create_tables(MODELS)

15
kodak/database/_shared.py Normal file
View File

@@ -0,0 +1,15 @@
import datetime
import uuid
import peewee
INTERFACE = peewee.DatabaseProxy()
class KodakModel(peewee.Model):
class Meta: # pylint: disable=too-few-public-methods,missing-class-docstring
database = INTERFACE
uuid = peewee.UUIDField(null=False, unique=True, default=uuid.uuid4)
created = peewee.DateTimeField(null=False, default=datetime.datetime.utcnow)

30
kodak/database/image.py Normal file
View File

@@ -0,0 +1,30 @@
import json
import uuid
from typing import List
import peewee
from kodak.database._shared import KodakModel
class ImageRecord(KodakModel):
"""Database record for"""
width = peewee.IntegerField(null=False)
height = peewee.IntegerField(null=False)
format = peewee.CharField(null=False)
deleted = peewee.BooleanField(null=False, default=False)
public = peewee.BooleanField(null=False, default=False)
owner = peewee.UUIDField(null=False)
sha256 = peewee.CharField(null=False)
_readable = peewee.CharField(null=False, default="[]")
@property
def readable(self) -> List[uuid.UUID]:
"""List of UUIDs corresponding to accounts that can read the file"""
return [uuid.UUID(item) for item in json.loads(self._readable)]
@readable.setter
def readable(self, value: List[uuid.UUID]):
"""Update the list of UUIDs for accounts that can read the file"""
self._readable = json.dumps([str(item) for item in value])

View File

@@ -0,0 +1,11 @@
import peewee
from kodak.database._shared import KodakModel
from kodak.database.image import ImageRecord
class ThumbnailRecord(KodakModel):
parent = peewee.ForeignKeyField(ImageRecord)
width = peewee.IntegerField(null=False)
height = peewee.IntegerField(null=False)

39
kodak/exceptions.py Normal file
View File

@@ -0,0 +1,39 @@
"""Application exceptions
::
KodakException
+-- ClientError
+-- ServerError
"""
class KodakException(Exception):
"""Whomp whomp, something went wrong
But seriously, don't ever raise this exception
"""
status: int
class ClientError(KodakException):
"""Error while processing client side input"""
status = 400
class ImageResourceDeletedError(ClientError):
"""Requested image resource has been deleted"""
status = 410
class ServerError(KodakException):
"""Error while processing server side data"""
status = 500
class ImageFileRemovedError(ServerError):
"""Image file removed from server"""

0
kodak/py.typed Normal file
View File

View File

@@ -0,0 +1,15 @@
from typing import Tuple
from kodak.resources._shared import KodakResource
from kodak.resources.alias import ImageAlias
from kodak.resources.heartbeat import Heartbeat
from kodak.resources.image import Image
from kodak.resources.openapi import OpenAPI
RESOURCES: Tuple[KodakResource, ...] = (
Heartbeat,
Image,
ImageAlias,
OpenAPI,
)

112
kodak/resources/_shared.py Normal file
View File

@@ -0,0 +1,112 @@
"""Shared resource base with common functionality"""
import logging
from typing import Any
from typing import Dict
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import Tuple
from typing import Union
import flask
import flask_restful
from kodak import __about__
ResponseBody = Optional[Union[Dict[str, Any], List[Dict[str, Any]], List[str]]]
ResponseHeaders = Dict[str, str]
class ResponseTuple(NamedTuple):
"""Namedtuple representing the format of a flask-restful response tuple
:param body: Response body; must be comprised only of JSON-friendly primative types
:param code: HTTP response code
:param headers: Dictionary of headers
"""
body: ResponseBody
code: int
headers: ResponseHeaders
class KodakResource(flask_restful.Resource):
"""Extension of the default :class:`flask_restful.Resource` class
Add a couple of useful things to the default resource class:
* Adds the :meth:`options` method to respond to HTTP OPTION requests
* Adds the :meth:`_head` method as a stub helper for responding to HTTP HEAD requests
* Adds the :meth:`make_response` method which handles response formatting boilerplate
* Type hints the :attr:`routes` attribute for usage in subclasses
* Adds an instance logger
.. warning:: This class is a stub and should not be directly attached to an application
:attribute routes: Tuple of route paths that this resource should handle; can be unpacked into
``flask_restful.Api().add_route()``
"""
routes: Tuple[str, ...]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = logging.getLogger()
def options(
self, *args, **kwargs
) -> ResponseTuple: # pylint: disable=unused-argument
"""Implement HTTP ``OPTIONS`` support
`Reference documentation <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS>`_
"""
verbs = ",".join([verb.upper() for verb in flask.request.url_rule.methods])
return self.make_response(None, 204, {"Allowed": verbs})
def _head(self, response: ResponseTuple) -> ResponseTuple:
"""Wrapper to implement HTTP ``HEAD`` support
`Reference documentation <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD>`_
.. note:: The ``head`` method cannot be implemented directly as an alias of ``get`` because
that would require a uniform signature for ``get`` across all resources; or some
hacky nonsense that wouldn't be worth it. This stub instead lets child resources
implement ``head`` as a oneliner.
"""
return self.make_response(None, response.code, response.headers)
def make_response(
self,
data: ResponseBody,
code: int = 200,
headers: Optional[ResponseHeaders] = None,
):
"""Create a response tuple from the current context
Helper function for generating defaults, parsing common data, and formatting the response.
:param data: Response data to return from the request
:param code: Response code to return; defaults to `200: Ok <https://httpstatuses.com/200>`_
:param headers: Additional headers to return with the request; the default headers will
be added automatically and do not need to be passed.
:returns: Response tuple ready to be returned out of a resource method
.. note:: This function will handle pagination and header assembly internally. The response
data passed to the ``data`` parameter should be unpaginated.
"""
headers = headers or {}
headers.update({"Server": f"{__about__.__title__}-{__about__.__version__}"})
# 204 code specifies that it must never include a response body. Most clients will ignore
# any response body when a 204 is given, but that's no reason to abandon best practices here
# on the server side
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204
return ResponseTuple(
body=data if code != 204 else None, code=code, headers=headers
)

10
kodak/resources/alias.py Normal file
View File

@@ -0,0 +1,10 @@
from kodak.resources._shared import KodakResource
from kodak.resources._shared import ResponseTuple
class ImageAlias(KodakResource):
routes = ("/image/<string:image_name>/<string:alias>",)
def get(self, image_name: str, alias: str) -> ResponseTuple:
raise NotImplementedError

View File

@@ -0,0 +1,18 @@
from kodak import configuration
from kodak import database
from kodak.resources._shared import KodakResource
from kodak.resources._shared import ResponseTuple
class Heartbeat(KodakResource):
routes = ("/heartbeat",)
def get(self) -> ResponseTuple:
configuration.load()
database.ImageRecord.select().count()
return self.make_response(None)
def head(self) -> ResponseTuple:
return self._head(self.get())

10
kodak/resources/image.py Normal file
View File

@@ -0,0 +1,10 @@
from kodak.resources._shared import KodakResource
from kodak.resources._shared import ResponseTuple
class Image(KodakResource):
routes = ("/image/<string:image_name>",)
def get(self, image_name: str) -> ResponseTuple:
raise NotImplementedError

View File

@@ -0,0 +1,25 @@
from pathlib import Path
from ruamel.yaml import YAML
from kodak.resources._shared import KodakResource
from kodak.resources._shared import ResponseTuple
yaml = YAML(typ="safe")
class OpenAPI(KodakResource):
"""Handle requests for the OpenAPI specification resource"""
routes = ("/openapi.json",)
def get(self) -> ResponseTuple:
"""Retrieve the OpenAPI specification document"""
with (Path(__file__).parent / "openapi.yaml").open() as infile:
data = yaml.load(infile)
return self.make_response(data)
def head(self) -> ResponseTuple:
"""Alias of GET with no response body"""
return self._head(self.get())