diff --git a/pyproject.toml b/pyproject.toml index 51f2e253..56f96824 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/osw/controller/file/__init__.py b/src/osw/controller/file/__init__.py index e69de29b..53e9c5e1 100644 --- a/src/osw/controller/file/__init__.py +++ b/src/osw/controller/file/__init__.py @@ -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"] diff --git a/src/osw/controller/file/base.py b/src/osw/controller/file/base.py index 3405cf59..22b531f4 100644 --- a/src/osw/controller/file/base.py +++ b/src/osw/controller/file/base.py @@ -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"] diff --git a/src/osw/controller/file/local.py b/src/osw/controller/file/local.py index 17137e46..fe943802 100644 --- a/src/osw/controller/file/local.py +++ b/src/osw/controller/file/local.py @@ -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) diff --git a/src/osw/controller/file/memory.py b/src/osw/controller/file/memory.py index 5bd96583..aea72dc8 100644 --- a/src/osw/controller/file/memory.py +++ b/src/osw/controller/file/memory.py @@ -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"] diff --git a/src/osw/controller/file/remote.py b/src/osw/controller/file/remote.py index 11a592ea..e9bf1ee9 100644 --- a/src/osw/controller/file/remote.py +++ b/src/osw/controller/file/remote.py @@ -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"] diff --git a/src/osw/controller/file/wiki.py b/src/osw/controller/file/wiki.py index 88139d79..7cd0f9e1 100644 --- a/src/osw/controller/file/wiki.py +++ b/src/osw/controller/file/wiki.py @@ -1,10 +1,14 @@ import functools import logging import os -from typing import IO, Any, Dict, List, Optional +from typing import IO, Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse import mwclient.errors +from oold.backend.auth import get_credential as _get_registered_credential +from oold.backend.auth import registered_credentials as _registered_credentials +from osw.auth import CredentialManager from osw.controller.file.base import FileController from osw.controller.file.remote import RemoteFileController from osw.core import OSW, model @@ -79,13 +83,72 @@ def store_params_for_upload( return {**se_params, "overwrite": "replace remote"} +_sessions: Dict[Tuple[str, str], Tuple[Any, OSW]] = {} +"""Sessions by (host, credential IRI), with the credential they were built from. + +The credential is part of the key because two credentials scoped to different +paths of one wiki are two different logins; the host is, because one credential +can match several hosts - oold's lookup treats a stored IRI contained in the +searched one as a match, so a credential for ``example.org`` answers for every +subdomain. The credential itself is kept so a later ``set_credential`` replaces +the session instead of being ignored for the lifetime of the process. +""" + + +def clear_sessions() -> None: + """Drop the cached sessions, so the next access logs in again.""" + _sessions.clear() + + +def host_of(url: str) -> str: + """The wiki host a URL or a bare credential IRI names. + + A credential is registered under whatever its owner chose - a bare host, a + host with a path, a full URL. ``WtSite`` wants the host alone: given a path + it builds an API endpoint that does not exist. + """ + parsed = urlparse(url if "//" in url else f"//{url}") + return parsed.netloc or url.split("/")[0] + + +def credential_for(url: str) -> Any: + """The credential registered for ``url``, or a readable error.""" + try: + return _get_registered_credential(url) + except ValueError as error: + raise ValueError( + f"No credential registered for {host_of(url)}. Pass osw= explicitly " + "or register one with oold.backend.auth.set_credential." + ) from error + + +def session_for(url: str) -> OSW: + """An OSW session for the host of ``url``, from the registered credential. + + The file entity may be stored somewhere other than its bytes, so the + lookup key is the URL the content lives at, not the entity's own IRI. + """ + host = host_of(url) + credential = credential_for(url) + key = (host, credential.iri) + cached = _sessions.get(key) + if cached is not None and cached[0] == credential: + return cached[1] + cred_mngr = CredentialManager() + cred_mngr.add_credential(credential) + session = OSW(site=WtSite(WtSite.WtSiteConfig(iri=host, cred_mngr=cred_mngr))) + _sessions[key] = (credential, session) + return session + + class WikiFileController(model.WikiFile, RemoteFileController): """File controller for wiki files""" label: Optional[List[model.Label]] = [model.Label(text="Unnamed file")] """the label of the file, defaults to 'Unnamed file'""" - osw: OSW - """the OSW instance to connect with""" + osw: Optional[OSW] = None + """the OSW instance to connect with. Left unset, it is created on first + use from the credential registered for download_url / access_url.""" namespace: Optional[str] = "File" """the namespace of the file, defaults to 'File'""" title: Optional[str] = None @@ -98,9 +161,17 @@ class Config: @classmethod def from_other( - cls, other: FileController, osw: OSW, **kwargs: Dict[str, Any] + cls, + other: FileController, + osw: Optional[OSW] = None, + **kwargs: Dict[str, Any], ) -> "WikiFileController": - return super().from_other(other, osw=osw, **kwargs) + # Only pass a session that was given: writing None over the one the + # source already carries would defer the failure to put time, with a + # message about a missing credential rather than a dropped session. + if osw is not None: + kwargs["osw"] = osw + return super().from_other(other, **kwargs) def get(self) -> IO: self._init() @@ -177,6 +248,7 @@ def get_to(self, other: "FileController"): other.put(response.raw) def put(self, file: IO, **kwargs: Dict[str, Any]): + self._ensure_session() # extract file meta information if hasattr(file, "name") and file.name is not None: name = os.path.basename(file.name) @@ -235,6 +307,7 @@ def put_from(self, other: FileController, **kwargs: Dict[str, Any]): super().put_from(other, **kwargs) def delete(self): + self._ensure_session() file_page_name = f"{self.namespace}:{self.title}" file_page = self.osw.site.get_page( WtSite.GetPageParam(titles=[file_page_name]) @@ -244,12 +317,47 @@ def delete(self): @property def url(self): + self._ensure_session() return ( f"{self.osw.mw_site.scheme}://{self.osw.mw_site.host}" f"/wiki/File:{self.title}" ) + def _ensure_session(self) -> OSW: + """The session, connecting on first use when none was passed in. + + A file that is about to be uploaded has no URL yet - that is what the + upload produces - so the URL is only one of the ways to find the wiki. + Without one the registered credentials decide, and they are only + unambiguous when there is exactly one. + """ + if self.osw is None: + self.osw = session_for(self._session_url()) + return self.osw + + def _session_url(self) -> str: + """The wiki to connect to: the file's own URL, else the credential's.""" + try: + return self._url() + except ValueError: + pass + registered = _registered_credentials() + if len(registered) == 1: + return next(iter(registered)) + if not registered: + raise ValueError( + f"{type(self).__name__} has no download_url or access_url and " + "no credential is registered. Pass osw= explicitly or register " + "one with oold.backend.auth.set_credential." + ) + raise ValueError( + f"{type(self).__name__} has no download_url or access_url, and " + f"credentials are registered for {', '.join(sorted(registered))}. " + "Pass osw= explicitly to say which wiki this file belongs to." + ) + def _init(self, name=None, suffix=None): + self._ensure_session() # set the name attribute to the actual file name, e.g., "image.png" if self.name is None: self.name = name diff --git a/tests/integration/store_and_load_test.py b/tests/integration/store_and_load_test.py index 2ba833ef..b38ac5cd 100644 --- a/tests/integration/store_and_load_test.py +++ b/tests/integration/store_and_load_test.py @@ -215,6 +215,7 @@ def test_characteristic_creation(wiki_domain, wiki_username, wiki_password): my_characteristic = model.CharacteristicType( uuid="efad8086-4a76-47ca-b278-f5f944e5754b", name="TestCharacteristic", + subclass_of=["Category:OSW93ccae36243542ceac6c951450a81d47"], # Characteristic label=[model.Label(text="Test Characteristic")], description=[ model.Description(text="A test characteristic for the osw python package") diff --git a/tests/test_in_memory_upload.py b/tests/test_in_memory_upload.py index 9713c06f..6d438a9f 100644 --- a/tests/test_in_memory_upload.py +++ b/tests/test_in_memory_upload.py @@ -20,7 +20,10 @@ def test_controller_accepts_a_caller_supplied_stream(): stream = BytesIO(b"payload") controller = InMemoryController(stream=stream) - assert controller.get() is stream + assert controller.stream is stream + # get() hands out a reader over the buffer, not the buffer itself, so a + # caller closing what it read does not empty the controller + assert controller.get().read() == b"payload" assert controller.get().read() == b"payload" @@ -68,7 +71,7 @@ def _fake_from_other(other, osw, **data): controller = seen["source_file_controller"] assert isinstance(controller, InMemoryController) - assert controller.get() is stream + assert controller.stream is stream def test_upload_rejects_a_source_that_is_not_file_like(): diff --git a/tests/test_wiki_file_session.py b/tests/test_wiki_file_session.py new file mode 100644 index 00000000..9413114b --- /dev/null +++ b/tests/test_wiki_file_session.py @@ -0,0 +1,203 @@ +"""Unit tests for how a wiki file controller finds its session. + +The session is optional: a controller built from a file entity connects on +first use. A file that is about to be uploaded has no URL yet - that is what +the upload produces - so the URL cannot be the only way to find the wiki. + +Offline: the session is never built here, only the inputs that decide which +one would be. +""" + +from io import BytesIO + +import pytest +from oold.backend.auth import UserPwdCredential, set_credential + +import osw.controller.file.wiki as wiki_module +from osw.controller.file.local import LocalFileController +from osw.controller.file.wiki import OSW, WikiFileController, WtSite + + +@pytest.fixture(autouse=True) +def _clean_credentials(monkeypatch): + """Keep the process-wide credential store and session cache per test.""" + import oold.backend.auth as auth + + saved = auth._credentials.get() + auth._credentials.set({}) + wiki_module.clear_sessions() + yield + auth._credentials.set(saved) + wiki_module.clear_sessions() + + +def _credential(iri, username="bot", password="secret"): + return UserPwdCredential(iri=iri, username=username, password=password) + + +def _a_session() -> OSW: + """A stand-in session: only which one travels is under test. + + pydantic v1 copies a model on assignment, so the controller does not hold + the very object that was passed in; the sessions are told apart by the + site they carry. + """ + return OSW.construct(site=WtSite.__new__(WtSite)) + + +def _controller(**kwargs): + return WikiFileController(**kwargs) + + +# -- choosing the wiki -------------------------------------------------------- + + +def test_a_url_decides_the_wiki(): + controller = _controller(download_url="https://wiki.example.org/wiki/File:A.bin") + assert controller._session_url() == "https://wiki.example.org/wiki/File:A.bin" + + +def test_without_a_url_the_single_registered_credential_decides(): + """A fresh upload has no URL, so the credential is the only pointer.""" + set_credential(_credential("wiki.example.org")) + assert _controller()._session_url() == "wiki.example.org" + + +def test_without_a_url_and_without_a_credential_the_error_says_so(): + with pytest.raises(ValueError, match="no credential is registered"): + _controller()._session_url() + + +def test_several_credentials_without_a_url_are_ambiguous(): + set_credential(_credential("wiki.example.org")) + set_credential(_credential("other.example.org")) + with pytest.raises(ValueError, match=r"other\.example\.org"): + _controller()._session_url() + + +def test_a_session_that_was_passed_in_is_kept(): + sentinel = _a_session() + controller = _controller(osw=sentinel) + assert controller._ensure_session().site is sentinel.site + + +# -- the session cache -------------------------------------------------------- + + +@pytest.fixture +def built_sites(monkeypatch): + """Record the WtSite configs built, instead of connecting to a wiki.""" + configs = [] + + def _config(**kwargs): + configs.append(kwargs) + return kwargs + + monkeypatch.setattr( + wiki_module, "OSW", lambda site: f"session{len(configs)}:{site['iri']}" + ) + monkeypatch.setattr(wiki_module, "WtSite", lambda config: config) + monkeypatch.setattr(wiki_module.WtSite, "WtSiteConfig", _config, raising=False) + return configs + + +def test_sessions_are_cached_per_credential(built_sites): + """Two path-scoped credentials of one host are two logins.""" + set_credential(_credential("wiki.example.org/a", username="a")) + set_credential(_credential("wiki.example.org/b", username="b")) + + first = wiki_module.session_for("https://wiki.example.org/a/File:X") + second = wiki_module.session_for("https://wiki.example.org/b/File:X") + again = wiki_module.session_for("https://wiki.example.org/a/File:Y") + + assert first != second + assert again == first + # and every one of them talks to the host, not to host/path + assert {config["iri"] for config in built_sites} == {"wiki.example.org"} + + +def test_one_credential_does_not_collapse_two_hosts(built_sites): + """oold matches a stored IRI inside the searched one, so one credential + answers for every subdomain - that must not share a session.""" + set_credential(_credential("example.org")) + + first = wiki_module.session_for("https://wiki-a.example.org/wiki/File:X") + second = wiki_module.session_for("https://wiki-b.example.org/wiki/File:Y") + + assert first != second + assert [config["iri"] for config in built_sites] == [ + "wiki-a.example.org", + "wiki-b.example.org", + ] + + +def test_a_bare_credential_iri_still_names_the_host(built_sites): + """The no-URL path hands the credential key straight to session_for.""" + set_credential(_credential("wiki.example.org/w")) + + wiki_module.session_for(_controller()._session_url()) + + assert built_sites[0]["iri"] == "wiki.example.org" + + +def test_a_replaced_credential_replaces_the_session(built_sites): + """A later set_credential must not be ignored for the process lifetime.""" + set_credential(_credential("wiki.example.org", password="old")) + first = wiki_module.session_for("https://wiki.example.org/File:X") + + set_credential(_credential("wiki.example.org", password="new")) + second = wiki_module.session_for("https://wiki.example.org/File:X") + + assert second != first + assert len(built_sites) == 2 + + +def test_a_missing_credential_is_reported_with_the_host(): + with pytest.raises(ValueError, match=r"wiki\.example\.org"): + wiki_module.session_for("https://wiki.example.org/File:X") + + +# -- from_other --------------------------------------------------------------- + + +def test_from_other_keeps_a_session_the_source_carries(): + sentinel = _a_session() + source = WikiFileController(osw=sentinel, download_url="https://w.example/x") + + target = WikiFileController.from_other(source) + + assert target.osw.site is sentinel.site + + +def test_from_other_takes_the_session_it_is_given(): + sentinel = _a_session() + other = _a_session() + source = WikiFileController(osw=other, download_url="https://w.example/x") + + target = WikiFileController.from_other(source, osw=sentinel) + + assert target.osw.site is sentinel.site + assert target.osw.site is not other.site + + +# -- the local controller ----------------------------------------------------- + + +def test_local_controller_needs_a_file(): + with pytest.raises(ValueError, match="needs a file"): + LocalFileController() + + +def test_local_controller_accepts_path_or_local_path(tmp_path): + target = tmp_path / "a.bin" + target.write_bytes(b"x") + + assert LocalFileController(path=target).path == target + assert LocalFileController(local_path=str(target)).path == target + + +def test_local_controller_round_trips_a_file(tmp_path): + controller = LocalFileController(path=tmp_path / "a.bin") + controller.put(BytesIO(b"payload")) + with controller.get() as handle: + assert handle.read() == b"payload" diff --git a/uv.lock b/uv.lock index e851018e..13cb36c8 100644 --- a/uv.lock +++ b/uv.lock @@ -1917,7 +1917,7 @@ wheels = [ [[package]] name = "oold" -version = "0.16.2" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "datamodel-code-generator" }, @@ -1929,9 +1929,9 @@ dependencies = [ { name = "sparqlwrapper" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/59/15da5384eab02f8d58cc17be3b820839b0ca76a4dfbdb3225d1cb555f955/oold-0.16.2.tar.gz", hash = "sha256:610192b49c744f5d35f0c9c780019beb2460df55119379e8482d2c484b7bd4e9", size = 702593, upload-time = "2026-05-11T09:44:04.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/6d/b6635ad891cde1a11e9ccef3f46c0f8cbe43c683c5e2d99daec6b3d5e587/oold-2.3.0.tar.gz", hash = "sha256:9d3b4de3ad53ebb046b575c772cd009374fedbb964ab3c6fbbfb2f48ce18f1c5", size = 1429319, upload-time = "2026-10-15T09:56:00.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/7e/ff3368ae3e8efcea19f79ab08c759f6d650c983f8b9b302bfe8d82d3db42/oold-0.16.2-py3-none-any.whl", hash = "sha256:32fd178829fba9a990fd1a1bd34c86378a54d5cdf5f554b10b9fdbdc8bf7715d", size = 189586, upload-time = "2026-05-11T09:44:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/17/fa/91913d09989ad88192d114b47f7743f45c1a18fedfa24097a96917d30040/oold-2.3.0-py3-none-any.whl", hash = "sha256:db9b692142b56471c72b0ee49bd9f814e010f54952fda43d783c9f4fc89ea49f", size = 472728, upload-time = "2026-10-15T09:56:02.23Z" }, ] [[package]] @@ -1975,15 +1975,16 @@ wheels = [ [[package]] name = "opensemantic-core" -version = "0.57.4.post1000002003001" +version = "0.59.0.post1000004007001" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "oold" }, { name = "opensemantic" }, { name = "pydantic", extra = ["email"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/a7/d88f31ed639ce75ba58648b5ebcbab795fe82f93341e1d694b8993522789/opensemantic_core-0.57.4.post1000002003001.tar.gz", hash = "sha256:a25b9caee5e267d17569b5d64d7d6183b122e26f2da6ddc898cc224182ff903f", size = 67852, upload-time = "2026-05-04T06:15:27.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/63/1c0a049a28acce2d74cca95117a596f25b563861dd78099f2a081127562e/opensemantic_core-0.59.0.post1000004007001.tar.gz", hash = "sha256:9846fb4763fd25a8d30e24b29645b4c3ba2d52d10d492e6f073d284e1dd45c8d", size = 79184, upload-time = "2026-10-15T12:03:49.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/c8/ab45630822479696bd4e7650a7e3a547b782ae3a0b30bfcd04a39e6692d3/opensemantic_core-0.57.4.post1000002003001-py3-none-any.whl", hash = "sha256:6cb35e14e011be95e0ded366d1cc2dd8f547209a68665960ffab530ecb6d7ef4", size = 51538, upload-time = "2026-05-04T06:15:25.887Z" }, + { url = "https://files.pythonhosted.org/packages/c2/19/09bb22ad969a704fe9b2bf4991f24ac243cb380da3c5c9e081e8d5048fd3/opensemantic_core-0.59.0.post1000004007001-py3-none-any.whl", hash = "sha256:8e98fd4dbc2335081c3826a30486d698482fb003f3dc5417a536c32ee25a54c2", size = 59833, upload-time = "2026-10-15T12:03:47.692Z" }, ] [[package]] @@ -2190,11 +2191,11 @@ requires-dist = [ { name = "mwclient", specifier = ">=0.11.0" }, { name = "mwparserfromhell", marker = "extra == 'wikitext'" }, { name = "numpy" }, - { name = "oold", specifier = ">=0.15.0" }, + { name = "oold", specifier = ">=2.3.0" }, { name = "openpyxl", marker = "extra == 'dataimport'" }, { name = "opensemantic" }, { name = "opensemantic-base", specifier = ">=0.42.7" }, - { name = "opensemantic-core", specifier = ">=0.57.4" }, + { name = "opensemantic-core", specifier = ">=0.59.0" }, { name = "osw", extras = ["dataimport"], marker = "extra == 'tutorial'" }, { name = "osw", extras = ["dataimport", "db", "ui", "s3", "wikitext", "mcp", "parallel"], marker = "extra == 'all'" }, { name = "osw", extras = ["wikitext"], marker = "extra == 'mcp'" },