Skip to content

Commit 2fd39ef

Browse files
authored
Merge pull request #5 from MAK-Relic-Tool/fs.opener-support
fs.opener support
2 parents 838db92 + 61caba0 commit 2fd39ef

8 files changed

Lines changed: 205 additions & 25 deletions

File tree

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ strict_concatenate = True
2323
[mypy-serialization_tools.*]
2424
ignore_missing_imports = True
2525

26+
[mypy-pkg_resources.*]
27+
ignore_missing_imports = True
28+
2629
[mypy-tests.*]
2730
ignore_missing_imports = True
2831
ignore_errors = True

setup.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,10 @@ install_requires =
2727
relic-tool-core
2828
fs
2929

30+
[options.entry_points]
31+
fs.opener =
32+
sga = relic.sga.core.filesystem:EssenceFSOpener
33+
34+
3035
[options.packages.find]
3136
where = src

src/relic/sga/core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"""
44
from relic.sga.core.definitions import Version, MagicWord, StorageType, VerificationType
55

6-
__version__ = "1.0.0"
6+
__version__ = "1.1.0"

src/relic/sga/core/definitions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@
77
from enum import Enum
88
from typing import ClassVar, BinaryIO
99

10+
from relic.core.errors import MismatchError
1011
from serialization_tools.magic import MagicWordIO
1112
from serialization_tools.structx import Struct
1213

1314
MagicWord = MagicWordIO(Struct("< 8s"), "_ARCHIVE".encode("ascii"))
1415

1516

17+
def _validate_magic_word(self: MagicWordIO, stream: BinaryIO, advance: bool) -> None:
18+
magic = self.read_magic_word(stream, advance)
19+
if magic != self.word:
20+
raise MismatchError("MagicWord", magic, self.word)
21+
22+
1623
@dataclass
1724
class Version:
1825
"""A Version object.

src/relic/sga/core/filesystem.py

Lines changed: 164 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
import abc
4+
import os
5+
from os.path import expanduser
36
from typing import (
47
Optional,
58
Dict,
@@ -10,53 +13,128 @@
1013
Mapping,
1114
cast,
1215
Protocol,
16+
TypeVar,
17+
Generic,
18+
runtime_checkable,
1319
)
1420

21+
import fs.opener.errors
22+
import pkg_resources
1523
from fs import ResourceType, errors
1624
from fs.base import FS
1725
from fs.info import Info
1826
from fs.memoryfs import MemoryFS, _DirEntry, _MemoryFile
1927
from fs.multifs import MultiFS
28+
from fs.opener import Opener, registry as fs_registry
29+
from fs.opener.parse import ParseResult
2030
from fs.path import split
2131

22-
from relic.sga.core.definitions import Version, MagicWord
32+
from relic.sga.core.definitions import Version, MagicWord, _validate_magic_word
2333
from relic.sga.core.errors import VersionNotSupportedError
2434

2535
ESSENCE_NAMESPACE = "essence"
2636

37+
TKey = TypeVar("TKey")
38+
TValue = TypeVar("TValue")
2739

40+
41+
class EntrypointRegistry(Generic[TKey, TValue]):
42+
def __init__(self, entry_point_path: str, autoload: bool = False):
43+
self._entry_point_path = entry_point_path
44+
self._mapping: Dict[TKey, TValue] = {}
45+
self._autoload = autoload
46+
47+
def register(self, key: TKey, value: TValue) -> None:
48+
self._mapping[key] = value
49+
50+
@abc.abstractmethod
51+
def auto_register(self, value: TValue) -> None:
52+
raise NotImplementedError
53+
54+
def get(self, key: TKey, default: Optional[TValue] = None) -> Optional[TValue]:
55+
if key in self._mapping:
56+
return self._mapping[key]
57+
58+
if self._autoload:
59+
try:
60+
entry_point = next(
61+
pkg_resources.iter_entry_points(
62+
self._entry_point_path, self._key2entry_point_path(key)
63+
)
64+
)
65+
except StopIteration:
66+
entry_point = None
67+
if entry_point is None:
68+
return default
69+
self._auto_register_entrypoint(entry_point)
70+
if key not in self._mapping:
71+
raise NotImplementedError # TODO specify autoload failed to load in a usable value
72+
return self._mapping[key]
73+
return default
74+
75+
@abc.abstractmethod
76+
def _key2entry_point_path(self, key: TKey) -> str:
77+
raise NotImplementedError
78+
79+
def _auto_register_entrypoint(self, entry_point: Any) -> None:
80+
try:
81+
entry_point_result = entry_point.load()
82+
except: # Wrap in exception
83+
raise
84+
return self._register_entrypoint(entry_point_result)
85+
86+
@abc.abstractmethod
87+
def _register_entrypoint(self, entry_point_result: Any) -> None:
88+
raise NotImplementedError
89+
90+
91+
@runtime_checkable
2892
class EssenceFSHandler(Protocol):
93+
version: Version
94+
2995
def read(self, stream: BinaryIO) -> EssenceFS:
3096
raise NotImplementedError
3197

3298
def write(self, stream: BinaryIO, essence_fs: EssenceFS) -> int:
3399
raise NotImplementedError
34100

35101

36-
class EssenceFSFactory:
37-
def __init__(self) -> None:
38-
self.handler_map: Dict[Version, EssenceFSHandler] = {}
102+
class EssenceFSFactory(EntrypointRegistry[Version, EssenceFSHandler]):
103+
def _key2entry_point_path(self, key: Version) -> str:
104+
return f"v{key.major}.{key.minor}"
39105

40-
def register_handler(self, version: Version, handler: EssenceFSHandler) -> None:
41-
if version is None:
42-
raise ValueError
43-
if handler is None:
44-
raise ValueError
45-
# self.default_handler = handler
46-
# else:
47-
self.handler_map[version] = handler
106+
def _register_entrypoint(self, entry_point_result: Any) -> None:
107+
if isinstance(entry_point_result, EssenceFSHandler):
108+
self.auto_register(entry_point_result)
109+
elif isinstance(entry_point_result, (list, tuple, Collection)):
110+
version, handler = entry_point_result
111+
if not isinstance(handler, EssenceFSHandler):
112+
handler = handler()
113+
self.register(version, handler)
114+
else:
115+
# Callable; register nested result
116+
self._register_entrypoint(entry_point_result())
117+
118+
def auto_register(self, value: EssenceFSHandler) -> None:
119+
self.register(value.version, value)
120+
121+
def __init__(self, autoload: bool = True) -> None:
122+
super().__init__("relic.sga.handler", autoload)
48123

49124
@staticmethod
50125
def _read_magic_and_version(sga_stream: BinaryIO) -> Version:
51-
sga_stream.seek(0)
52-
MagicWord.read_magic_word(sga_stream)
53-
return Version.unpack(sga_stream)
126+
# sga_stream.seek(0)
127+
jump_back = sga_stream.tell()
128+
_validate_magic_word(MagicWord, sga_stream, advance=True)
129+
version = Version.unpack(sga_stream)
130+
sga_stream.seek(jump_back)
131+
return version
54132

55133
def _get_handler(self, version: Version) -> EssenceFSHandler:
56-
handler = self.handler_map.get(version)
134+
handler = self.get(version)
57135
if handler is None:
58136
# This may raise a 'false positive' if a Null handler is registered
59-
raise VersionNotSupportedError(version, list(self.handler_map.keys()))
137+
raise VersionNotSupportedError(version, list(self._mapping.keys()))
60138
return handler
61139

62140
def _get_handler_from_stream(
@@ -87,6 +165,55 @@ def write(
87165
return handler.write(sga_stream, sga_fs)
88166

89167

168+
registry = EssenceFSFactory(True)
169+
170+
171+
# @fs_registry.install
172+
# Can't use decorator; it breaks subclassing for entrypoints
173+
class EssenceFSOpener(Opener):
174+
def __init__(self, factory: Optional[EssenceFSFactory] = None):
175+
if factory is None:
176+
factory = registry
177+
self.factory = factory
178+
179+
protocols = ["sga"]
180+
181+
def open_fs(
182+
self,
183+
fs_url: str,
184+
parse_result: ParseResult,
185+
writeable: bool,
186+
create: bool,
187+
cwd: str,
188+
) -> FS:
189+
# All EssenceFS should be writable; so we can ignore that
190+
191+
# Resolve Path
192+
if fs_url == "sga://":
193+
if create:
194+
return EssenceFS()
195+
else:
196+
raise fs.opener.errors.OpenerError(
197+
"No path was given and opener not marked for 'create'!"
198+
)
199+
200+
_path = os.path.abspath(os.path.join(cwd, expanduser(parse_result.resource)))
201+
path = os.path.normpath(_path)
202+
203+
# Create will always create a new EssenceFS if needed
204+
try:
205+
with open(path, "rb") as sga_file:
206+
return self.factory.read(sga_file)
207+
except FileNotFoundError as e:
208+
if create:
209+
return EssenceFS()
210+
else:
211+
raise
212+
213+
214+
fs_registry.install(EssenceFSOpener)
215+
216+
90217
class _EssenceFile(_MemoryFile):
91218
... # I plan on allowing lazy file loading from the archive; I'll likely need to implement this to do that
92219

@@ -111,14 +238,30 @@ def to_info(self, namespaces=None):
111238

112239

113240
class _EssenceDriveFS(MemoryFS):
114-
def __init__(self) -> None:
241+
def __init__(self, alias: str) -> None:
115242
super().__init__()
243+
self.alias = alias
116244

117245
def _make_dir_entry(
118246
self, resource_type: ResourceType, name: str
119247
) -> _EssenceDirEntry:
120248
return _EssenceDirEntry(resource_type, name)
121249

250+
def validatepath(self, path: str) -> str:
251+
if ":" in path:
252+
parts = path.split(":", 1)
253+
if parts[0][0] == "/":
254+
parts[0] = parts[0][1:]
255+
if parts[0] != self.alias:
256+
raise fs.errors.InvalidPath(
257+
path,
258+
f"Alias `{parts[0]}` does not math the Drive's Alias `{self.alias}`",
259+
)
260+
fixed_path = parts[1]
261+
else:
262+
fixed_path = path
263+
return super().validatepath(fixed_path)
264+
122265
def setinfo(self, path: str, info: Mapping[str, Mapping[str, object]]) -> None:
123266
_path = self.validatepath(path)
124267
with self._lock:
@@ -171,7 +314,7 @@ def getessence(self, path: str) -> Info:
171314
return self.getinfo(path, [ESSENCE_NAMESPACE])
172315

173316
def create_drive(self, name: str) -> _EssenceDriveFS:
174-
drive = _EssenceDriveFS()
317+
drive = _EssenceDriveFS(name)
175318
self.add_fs(name, drive)
176319
return drive
177320

@@ -227,4 +370,6 @@ def _delegate(self, path):
227370
"_EssenceDirEntry",
228371
"_EssenceDriveFS",
229372
"EssenceFS",
373+
"registry",
374+
"EssenceFSOpener",
230375
]

src/relic/sga/core/serialization.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
from serialization_tools.size import KiB, MiB
2424
from serialization_tools.structx import Struct
2525

26-
from relic.sga.core.definitions import StorageType, Version, MagicWord
26+
from relic.sga.core.definitions import (
27+
StorageType,
28+
Version,
29+
MagicWord,
30+
_validate_magic_word,
31+
)
2732
from relic.sga.core.errors import (
2833
MD5MismatchError,
2934
VersionMismatchError,
@@ -747,7 +752,7 @@ def __init__(
747752

748753
def read(self, stream: BinaryIO) -> EssenceFS:
749754
# Magic & Version; skippable so that we can check for a valid file and read the version elsewhere
750-
MagicWord.assert_magic_word(stream, advance=True)
755+
_validate_magic_word(MagicWord, stream, advance=True)
751756
stream_version = Version.unpack(stream)
752757
if stream_version != self.version:
753758
raise VersionMismatchError(stream_version, self.version)
@@ -776,7 +781,10 @@ def read(self, stream: BinaryIO) -> EssenceFS:
776781
)
777782
essence_fs = EssenceFS()
778783
assembler.assemble(essence_fs)
779-
essence_info: Dict[str, object] = {"name": name}
784+
essence_info: Dict[str, object] = {
785+
"name": name,
786+
"version": {"major": stream_version.major, "minor": stream_version.minor},
787+
}
780788
if metadata is not None:
781789
essence_info.update(metadata)
782790

tests/test_filesystem.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import unittest
22

3+
import fs
34
from fs.test import FSTestCases
45

56
from relic.sga.core.filesystem import EssenceFS, _EssenceDriveFS
@@ -8,10 +9,19 @@
89
class TestEssenceFS(FSTestCases, unittest.TestCase):
910
def make_fs(self):
1011
essence_fs = EssenceFS()
11-
essence_fs.add_fs("data", _EssenceDriveFS(), True)
12+
# EssenceFS shouldn't be writeable by default;
13+
# being an emulator for Window's hard drives.
14+
# With no 'drive' installed, there's nothing to write to!
15+
essence_fs.add_fs("data", _EssenceDriveFS("data"), True)
1216
return essence_fs
1317

1418

1519
class TestEssenceDriveFS(FSTestCases, unittest.TestCase):
1620
def make_fs(self):
17-
return _EssenceDriveFS()
21+
return _EssenceDriveFS("")
22+
23+
24+
class TestOpener:
25+
def test_open_fs(self):
26+
with fs.open_fs("sga://", create=True) as sga:
27+
pass

tests/test_regressions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def test_import_module(submodule: str):
3939
"_EssenceDirEntry",
4040
"_EssenceDriveFS",
4141
"EssenceFS",
42+
"registry",
43+
"EssenceFSOpener",
4244
]
4345
protocols__all__ = ["T", "StreamSerializer"]
4446
serialization__all__ = [

0 commit comments

Comments
 (0)