Skip to content
Open
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
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"oold>=0.15.0",
# registered_credentials(), used to find the wiki of a file without a URL
"oold>=2.3.0",
"opensemantic",
"opensemantic.core>=0.57.4",
# the file controllers this module wraps
"opensemantic.core>=0.59.0",
"opensemantic.base>=0.42.7",
"pydantic[email]>=2.12.0",
"datamodel-code-generator==0.51.0",
Expand Down
10 changes: 10 additions & 0 deletions src/osw/controller/file/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""File controllers.

Imported eagerly so the controllers register themselves: a caller that resolves
a file entity through the controller registry then finds the wiki
implementation without importing it by name.
"""

from osw.controller.file import base, local, memory, remote, wiki

__all__ = ["base", "local", "memory", "remote", "wiki"]
69 changes: 6 additions & 63 deletions src/osw/controller/file/base.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,9 @@
from abc import abstractmethod
from typing import IO, Any, Dict
"""File controller interface.

from osw.core import model
The implementation moved to ``opensemantic.core``, which owns the file models.
This module keeps the import path working.
"""

from opensemantic.core.v1 import FileControllerMixin as FileController

class FileController(model.File):
"""Base class for file controllers"""

@abstractmethod
def get(self) -> IO:
"""Reads or downloads a file from an IO object
Make sure to close the IO object after reading

Returns
-------
the IO object of the opened file
"""

@abstractmethod
def put(self, file: IO, **kwargs: Dict[str, Any]):
"""Writes or uploads a file from an IO object

Parameters
-------
file
the IO object to read from
"""

def put_from(self, other: "FileController", **kwargs: Dict[str, Any]):
"""Writes or uploads a file from another file controller
Automatically closes the other file controllers IO object after reading

Parameters
----------
other
another file controller to read from
"""
with other.get() as f:
self.put(f, **kwargs)

def get_to(self, other: "FileController"):
"""Reads or downloads a file to another file controller
Automatically closes this file controllers IO object after reading

Parameters
----------
other
another file controller to write to
"""
with self.get() as f:
other.put(f)

@classmethod
def from_other(cls, other: "FileController", **kwargs: Any) -> "FileController":
"""Create an instance based on another file controller
copying all common attributes

Parameters
----------
other
another file controller to copy from
kwargs
additional attributes to be set
"""
return other.cast(cls, **kwargs)
__all__ = ["FileController"]
68 changes: 39 additions & 29 deletions src/osw/controller/file/local.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,68 @@
"""Local file controller.

The implementation moved to ``opensemantic.core``, which owns the file models.
This subclass keeps the ``path`` argument and the filename-derived label of the
controller it replaces.
"""

import os
import shutil
from io import TextIOBase
from pathlib import Path
from typing import IO, Any, Dict, List, Optional
from typing import IO, Any, Dict, Optional

from opensemantic.core.v1 import LocalFileController as _LocalFileController

from osw.controller.file.base import FileController
from osw.core import model


class LocalFileController(FileController, model.LocalFile):
class LocalFileController(_LocalFileController):
"""File controller for local files"""

label: Optional[List[model.Label]] = [model.Label(text="Unnamed file")]
"""The label of the file, defaults to 'Unnamed file'"""
path: Path
"""The path to the file"""
path: Optional[Path] = None
"""Where the content lives, as a Path. A field, not a property: a property
of this name would win over the ``path`` field declared by
``osw.express.FileResult`` and make that field's value unreadable. Kept in
step with the model's own ``local_path`` by the constructor, so either name
reads the same file."""

class Config:
arbitrary_types_allowed = True

@classmethod
def from_other(
cls, other: "FileController", path: Path, **kwargs
) -> "LocalFileController":
return other.cast(cls, path=path, **kwargs)

def __init__(self, **kwargs):
path = kwargs.get("path")
if path is not None and not kwargs.get("local_path"):
kwargs["local_path"] = str(path)
super().__init__(**kwargs)
if not self.local_path:
# The controller this replaces declared path as a required field,
# so an instance without one never existed. Raising here keeps the
# error at the call site that is wrong, instead of surfacing it on
# the first read.
raise ValueError(
f"{type(self).__name__} needs a file: pass path= or local_path="
)
self.path = Path(self.local_path)
self._set_metadata()

@classmethod
def from_other(cls, other, path=None, **kwargs) -> "LocalFileController":
if path is not None:
kwargs["local_path"] = str(path)
return other.cast(cls, **kwargs)

def get(self) -> IO:
self._set_metadata()
return open(self.path, "rb")
return super().get()

def put(self, file: IO, **kwargs: Dict[str, Any]):
self._set_metadata()
mode = "wb"
if isinstance(file, TextIOBase):
mode = "w"
with open(self.path, mode) as f:
# f.write(file.read()) # In-memory - limited by available RAM
shutil.copyfileobj(file, f) # Chunked copy

def delete(self):
if os.path.exists(self.path):
os.remove(self.path)
super().put(file, **kwargs)

def _set_metadata(self):
file = LocalFileController.extract_metadata(self.path)
self.label = file.label
self.label = LocalFileController.extract_metadata(self.path).label

@classmethod
def extract_metadata(cls, path: Path) -> model.LocalFile:
file = model.LocalFile(label=[model.Label(text=path.name)])
file = model.LocalFile(label=[model.Label(text=Path(path).name)])
if os.path.exists(path):
pass
# stats = os.stat(path)
Expand Down
31 changes: 6 additions & 25 deletions src/osw/controller/file/memory.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,9 @@
import shutil
from io import BytesIO
from typing import IO, Any, Dict, List, Optional
"""In-memory file controller.

from pydantic.v1 import Field
The implementation moved to ``opensemantic.core``, which owns the file models.
This module keeps the import path working.
"""

from osw.controller.file.base import FileController
from osw.core import model
from opensemantic.core.v1 import InMemoryController


class InMemoryController(FileController, model.LocalFile):
"""File controller for in-memory streams"""

label: Optional[List[model.Label]] = [model.Label(text="Unnamed stream")]
"""the label of the stream, e.g., the name of the file the stream
originates from. Defaults to 'Unnamed stream'."""
stream: Any = Field(default_factory=BytesIO)
"""the stream to the file, any file-like object. Defaults to an empty
binary buffer. Byte-oriented, to match the get/put counterparts."""

class Config:
arbitrary_types_allowed = True

def get(self) -> IO:
return self.stream

def put(self, file: IO, **kwargs: Dict[str, Any]):
shutil.copyfileobj(file, self.stream)
__all__ = ["InMemoryController"]
34 changes: 6 additions & 28 deletions src/osw/controller/file/remote.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,9 @@
from abc import abstractmethod
from typing import IO, Any, Dict
"""Remote file controller.

from osw.controller.file.base import FileController
from osw.core import model
The implementation moved to ``opensemantic.core``, which owns the file models.
This module keeps the import path working.
"""

# TODO: add additional remove file with
# https://docs.prefect.io/2.11.4/concepts/filesystems/
from opensemantic.core.v1 import RemoteFileController


# Note: the order of the base classes is important
# The data class must be the first base class, otherwise subclass controllers fall back
# to the data model of the controller superclass
class RemoteFileController(model.RemoteFile, FileController):
@abstractmethod
def get(self) -> IO:
pass

@abstractmethod
def put(self, file: IO, **kwargs: Dict[str, Any]):
pass

# @classmethod
# def from_local(self, local: "local.LocalFileController") -> "RemoteFileController":
# rf = local.cast(self)
# rf.put(local.get())
# return rf

# def from_local(self, local: "local.LocalFileController") -> None:
# with local.get() as f:
# self.put(f)
__all__ = ["RemoteFileController"]
Loading
Loading