dimu 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dimu-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nedomkull Mathematical Modeling
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dimu-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: dimu
3
+ Version: 0.2.0
4
+ Summary: Transparent Python client and AI-friendly API for DigitaltMuseum
5
+ Author-email: Henrik Blidh <henrik.blidh@nedomkull.com>
6
+ Maintainer-email: Henrik Blidh <henrik.blidh@nedomkull.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: httpx<1,>=0.28
12
+ Requires-Dist: pydantic<3,>=2.12
13
+ Provides-Extra: api
14
+ Requires-Dist: fastapi<1,>=0.115; extra == "api"
15
+ Requires-Dist: uvicorn<1,>=0.34; extra == "api"
16
+ Provides-Extra: docs
17
+ Requires-Dist: fastapi<1,>=0.115; extra == "docs"
18
+ Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
19
+ Requires-Dist: mkdocs-material<10,>=9.6; extra == "docs"
20
+ Requires-Dist: mkdocs-static-i18n<2,>=1.3.1; extra == "docs"
21
+ Requires-Dist: mkdocstrings[python]<1.1,>=0.29; extra == "docs"
22
+ Provides-Extra: test
23
+ Requires-Dist: fastapi<1,>=0.115; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # dimu - Digitalt Museum-klient och FastAPI-proxy
27
+
28
+ `dimu` är en transparent Python-klient och en valfri FastAPI-proxy för
29
+ [DigitaltMuseums publika API](https://store-search.dimu.org/docs). Indata
30
+ valideras med Pydantic, men lyckade sök- och objektsvar lämnas orörda.
31
+
32
+ ```python
33
+ from dimu import DimuClient, SearchRequest, SearchTerm
34
+
35
+ with DimuClient() as client: # använder demo och varnar om DIMU_API_KEY saknas
36
+ response = client.search(SearchRequest(query="Uppsala slott", limit=5))
37
+ response.raise_for_status()
38
+ print(response.json())
39
+ ```
40
+
41
+ Installera biblioteket med `pip install dimu`. För HTTP-tjänsten används
42
+ `pip install "dimu[api]"` och följande miljövariabler:
43
+
44
+ ```text
45
+ DIMU_API_KEY=<valfri produktionsnyckel från DigitaltMuseum>
46
+ DIMU_SERVICE_TOKEN=<lång slumpmässig token>
47
+ ```
48
+
49
+ Starta sedan tjänsten:
50
+
51
+ ```console
52
+ uvicorn dimu.api:app --host 127.0.0.1 --port 8000
53
+ ```
54
+
55
+ För en Ubuntu-server med Supervisor, Nginx och Let's Encrypt:
56
+
57
+ ```console
58
+ sudo DIMU_SERVICE_TOKEN='byt-mig' DIMU_API_KEY='demo' \
59
+ bash deploy/ubuntu.sh api.example.se admin@example.se
60
+ ```
61
+
62
+ En container kan byggas med `docker build -t dimu .` och köras med
63
+ `docker run --rm -p 8000:8000 -e DIMU_SERVICE_TOKEN='byt-mig' dimu`.
64
+ GitHub Actions publicerar Git-taggen och `latest` till GitHub Container
65
+ Registry och skapar en GitHub Release när en Git-tagg pushas.
66
+
67
+ Fullständig dokumentation på svenska och norskt bokmål finns i katalogen
68
+ `docs/` och byggs med `mkdocs build --strict`. Tester körs med
69
+ `python -m unittest discover -s tests -v`.
70
+
71
+ Programvaran är licensierad under MIT. Metadata och bilder får återanvändas
72
+ enligt licensen i respektive post. Paketet är ingen lokal spegel och lagrar
73
+ inte DigitaltMuseums data.
dimu-0.2.0/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # dimu - Digitalt Museum-klient och FastAPI-proxy
2
+
3
+ `dimu` är en transparent Python-klient och en valfri FastAPI-proxy för
4
+ [DigitaltMuseums publika API](https://store-search.dimu.org/docs). Indata
5
+ valideras med Pydantic, men lyckade sök- och objektsvar lämnas orörda.
6
+
7
+ ```python
8
+ from dimu import DimuClient, SearchRequest, SearchTerm
9
+
10
+ with DimuClient() as client: # använder demo och varnar om DIMU_API_KEY saknas
11
+ response = client.search(SearchRequest(query="Uppsala slott", limit=5))
12
+ response.raise_for_status()
13
+ print(response.json())
14
+ ```
15
+
16
+ Installera biblioteket med `pip install dimu`. För HTTP-tjänsten används
17
+ `pip install "dimu[api]"` och följande miljövariabler:
18
+
19
+ ```text
20
+ DIMU_API_KEY=<valfri produktionsnyckel från DigitaltMuseum>
21
+ DIMU_SERVICE_TOKEN=<lång slumpmässig token>
22
+ ```
23
+
24
+ Starta sedan tjänsten:
25
+
26
+ ```console
27
+ uvicorn dimu.api:app --host 127.0.0.1 --port 8000
28
+ ```
29
+
30
+ För en Ubuntu-server med Supervisor, Nginx och Let's Encrypt:
31
+
32
+ ```console
33
+ sudo DIMU_SERVICE_TOKEN='byt-mig' DIMU_API_KEY='demo' \
34
+ bash deploy/ubuntu.sh api.example.se admin@example.se
35
+ ```
36
+
37
+ En container kan byggas med `docker build -t dimu .` och köras med
38
+ `docker run --rm -p 8000:8000 -e DIMU_SERVICE_TOKEN='byt-mig' dimu`.
39
+ GitHub Actions publicerar Git-taggen och `latest` till GitHub Container
40
+ Registry och skapar en GitHub Release när en Git-tagg pushas.
41
+
42
+ Fullständig dokumentation på svenska och norskt bokmål finns i katalogen
43
+ `docs/` och byggs med `mkdocs build --strict`. Tester körs med
44
+ `python -m unittest discover -s tests -v`.
45
+
46
+ Programvaran är licensierad under MIT. Metadata och bilder får återanvändas
47
+ enligt licensen i respektive post. Paketet är ingen lokal spegel och lagrar
48
+ inte DigitaltMuseums data.
@@ -0,0 +1,48 @@
1
+ """Transparent client for the DigitaltMuseum public API."""
2
+
3
+ from .client import API_URL, DimuClient
4
+ from .models import (
5
+ ArtifactFormat,
6
+ ArtifactRequest,
7
+ ArtifactType,
8
+ Collection,
9
+ CollectionsRequest,
10
+ Country,
11
+ FacetField,
12
+ MatchMode,
13
+ Occurrence,
14
+ SearchField,
15
+ SearchMode,
16
+ SearchRequest,
17
+ SearchTerm,
18
+ SortOrder,
19
+ StoredField,
20
+ )
21
+
22
+ __all__ = [
23
+ "API_URL",
24
+ "ArtifactFormat",
25
+ "ArtifactRequest",
26
+ "ArtifactType",
27
+ "Collection",
28
+ "CollectionsRequest",
29
+ "Country",
30
+ "DimuClient",
31
+ "FacetField",
32
+ "MatchMode",
33
+ "Occurrence",
34
+ "SearchField",
35
+ "SearchMode",
36
+ "SearchRequest",
37
+ "SearchTerm",
38
+ "SortOrder",
39
+ "StoredField",
40
+ "main",
41
+ ]
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ """Run the command-line client."""
46
+ from .cli import main as cli_main
47
+
48
+ return cli_main(argv)
@@ -0,0 +1,4 @@
1
+ from . import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
dimu-0.2.0/dimu/api.py ADDED
@@ -0,0 +1,205 @@
1
+ """FastAPI-proxy som validerar frågor och bevarar DigitaltMuseums svar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hmac
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from typing import Annotated, Any, Callable
10
+ from xml.etree import ElementTree
11
+
12
+ import httpx
13
+ from fastapi import Depends, FastAPI, HTTPException, Path, Query, Request
14
+ from fastapi.responses import JSONResponse, Response
15
+
16
+ from .client import DimuClient
17
+ from .models import (
18
+ ArtifactFormat,
19
+ ArtifactRequest,
20
+ Collection,
21
+ CollectionsRequest,
22
+ Country,
23
+ SearchRequest,
24
+ )
25
+
26
+
27
+ def _strict_schema(model: type[SearchRequest] | type[ArtifactRequest] | type[CollectionsRequest]) -> dict[str, Any]:
28
+ """Konvertera ett Pydantic-schema till OpenAI:s strict-format."""
29
+ schema = copy.deepcopy(model.model_json_schema())
30
+
31
+ def visit(value: Any) -> None:
32
+ if isinstance(value, dict):
33
+ value.pop("default", None)
34
+ if isinstance(value.get("format"), str):
35
+ value.pop("format")
36
+ properties = value.get("properties")
37
+ if isinstance(properties, dict):
38
+ value["additionalProperties"] = False
39
+ value["required"] = list(properties)
40
+ for child in value.values():
41
+ visit(child)
42
+ elif isinstance(value, list):
43
+ for child in value:
44
+ visit(child)
45
+
46
+ visit(schema)
47
+ return schema
48
+
49
+
50
+ def openai_tools() -> list[dict[str, Any]]:
51
+ """Returnera verktygsdefinitioner för OpenAI Responses API."""
52
+ return [
53
+ {
54
+ "type": "function",
55
+ "name": "search_digitaltmuseum",
56
+ "description": (
57
+ "Sök oförändrade poster i DigitaltMuseum. Använd strukturerade fält i första hand; "
58
+ "raw_query och raw_filter_queries finns när exakt Solr-syntax behövs. Svaret är "
59
+ "DigitaltMuseums fullständiga råa JSON och ska inte antas ha en förenklad form."
60
+ ),
61
+ "parameters": _strict_schema(SearchRequest),
62
+ "strict": True,
63
+ },
64
+ {
65
+ "type": "function",
66
+ "name": "get_digitaltmuseum_artifact",
67
+ "description": (
68
+ "Hämta den fullständiga, oförändrade posten för ett unique_id eller uuid från en sökträff. "
69
+ "Använd simple_json för chatbotläsning och ABM eller ESE endast när originalformatet behövs."
70
+ ),
71
+ "parameters": _strict_schema(ArtifactRequest),
72
+ "strict": True,
73
+ },
74
+ {
75
+ "type": "function",
76
+ "name": "list_digitaltmuseum_collections",
77
+ "description": (
78
+ "Lista svenska och/eller norska museer och samlingar samt deras identifierare, så att en "
79
+ "senare sökning kan begränsas med collection_ids."
80
+ ),
81
+ "parameters": _strict_schema(CollectionsRequest),
82
+ "strict": True,
83
+ },
84
+ ]
85
+
86
+
87
+ def _upstream(call: Callable[[], httpx.Response]) -> httpx.Response:
88
+ try:
89
+ response = call()
90
+ except httpx.TimeoutException as error:
91
+ raise HTTPException(status_code=504, detail="DigitaltMuseum svarade inte inom tidsgränsen.") from error
92
+ except httpx.RequestError as error:
93
+ raise HTTPException(status_code=502, detail="Det gick inte att kontakta DigitaltMuseum.") from error
94
+ if not response.is_success:
95
+ raise HTTPException(
96
+ status_code=502,
97
+ detail=f"DigitaltMuseum svarade med HTTP {response.status_code}.",
98
+ )
99
+ return response
100
+
101
+
102
+ def _verbatim(response: httpx.Response) -> Response:
103
+ headers = {}
104
+ if content_type := response.headers.get("content-type"):
105
+ headers["content-type"] = content_type
106
+ return Response(content=response.content, status_code=response.status_code, headers=headers)
107
+
108
+
109
+ def create_app(
110
+ *,
111
+ client: DimuClient | None = None,
112
+ api_key: str | None = None,
113
+ service_token: str | None = None,
114
+ ) -> FastAPI:
115
+ """Skapa API:t; miljökonfigurationen kontrolleras vid start."""
116
+
117
+ @asynccontextmanager
118
+ async def lifespan(app: FastAPI):
119
+ token = service_token or os.getenv("DIMU_SERVICE_TOKEN")
120
+ if not token:
121
+ raise RuntimeError("DIMU_SERVICE_TOKEN krävs")
122
+
123
+ app.state.service_token = token
124
+ app.state.dimu = client or DimuClient(api_key or os.getenv("DIMU_API_KEY"))
125
+ try:
126
+ yield
127
+ finally:
128
+ if client is None:
129
+ app.state.dimu.close()
130
+
131
+ app = FastAPI(
132
+ title="dimu",
133
+ description="Tunn, validerande proxy som lämnar DigitaltMuseums publicerade data oförändrad.",
134
+ version="0.2.0",
135
+ lifespan=lifespan,
136
+ )
137
+
138
+ def authenticate(request: Request) -> None:
139
+ supplied = request.headers.get("authorization", "")
140
+ expected = f"Bearer {request.app.state.service_token}"
141
+ if not hmac.compare_digest(supplied.encode(), expected.encode()):
142
+ raise HTTPException(
143
+ status_code=401,
144
+ detail="Ogiltig Bearer-token.",
145
+ headers={"WWW-Authenticate": "Bearer"},
146
+ )
147
+
148
+ def dimu(request: Request) -> DimuClient:
149
+ return request.app.state.dimu
150
+
151
+ secured = [Depends(authenticate)]
152
+
153
+ @app.get("/health", dependencies=secured)
154
+ def health() -> dict[str, str]:
155
+ return {"status": "ok"}
156
+
157
+ @app.get("/v1/collections", dependencies=secured)
158
+ def collections(
159
+ countries: list[Country] = Query(
160
+ default=[Country.SWEDEN, Country.NORWAY], min_length=1, max_length=2
161
+ ),
162
+ dimu_client: DimuClient = Depends(dimu),
163
+ ) -> list[Collection]:
164
+ result: list[Collection] = []
165
+ for country in dict.fromkeys(countries):
166
+ response = _upstream(lambda country=country: dimu_client.collections_raw(country))
167
+ try:
168
+ root = ElementTree.fromstring(response.content)
169
+ except ElementTree.ParseError as error:
170
+ raise HTTPException(status_code=502, detail="DigitaltMuseum returnerade ogiltig XML.") from error
171
+ result.extend(
172
+ Collection(
173
+ identifier=owner.findtext("identifier", ""),
174
+ name=owner.findtext("name", ""),
175
+ parent=owner.findtext("parent"),
176
+ country=country,
177
+ )
178
+ for owner in root.findall("owner")
179
+ )
180
+ return result
181
+
182
+ @app.get("/v1/collections/{country}/raw", dependencies=secured)
183
+ def collections_raw(country: Country, dimu_client: DimuClient = Depends(dimu)) -> Response:
184
+ return _verbatim(_upstream(lambda: dimu_client.collections_raw(country)))
185
+
186
+ @app.post("/v1/search", dependencies=secured)
187
+ def search(search_request: SearchRequest, dimu_client: DimuClient = Depends(dimu)) -> Response:
188
+ return _verbatim(_upstream(lambda: dimu_client.search(search_request)))
189
+
190
+ @app.get("/v1/artifacts/{unique_id}", dependencies=secured)
191
+ def artifact(
192
+ unique_id: Annotated[str, Path(min_length=1, pattern=r".*\S.*")],
193
+ format: ArtifactFormat = ArtifactFormat.SIMPLE_JSON,
194
+ dimu_client: DimuClient = Depends(dimu),
195
+ ) -> Response:
196
+ return _verbatim(_upstream(lambda: dimu_client.artifact(unique_id, format)))
197
+
198
+ @app.get("/v1/openai-tools", dependencies=secured)
199
+ def tools() -> JSONResponse:
200
+ return JSONResponse(openai_tools())
201
+
202
+ return app
203
+
204
+
205
+ app = create_app()
dimu-0.2.0/dimu/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """Command-line interface using the public client and request model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from xml.etree.ElementTree import ParseError
9
+
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from .client import DimuClient
14
+ from .models import ArtifactFormat, Country, SearchRequest
15
+
16
+
17
+ def _positive_int(value: str) -> int:
18
+ number = int(value)
19
+ if number < 1:
20
+ raise argparse.ArgumentTypeError("must be at least 1")
21
+ return number
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(description="Query the DigitaltMuseum API")
26
+ commands = parser.add_subparsers(dest="command", required=True)
27
+
28
+ owner_parser = commands.add_parser("owners", help="list museums/collections")
29
+ owner_parser.add_argument("--country", default="se", choices=("se", "no"))
30
+
31
+ search_parser = commands.add_parser("search", help="search published records")
32
+ search_parser.add_argument("query")
33
+ search_parser.add_argument("--owner", action="append", default=[], help="owner code; may be repeated")
34
+ search_parser.add_argument("--rows", type=_positive_int, default=10)
35
+ search_parser.add_argument("--pictures", action="store_true")
36
+
37
+ artifact_parser = commands.add_parser("artifact", help="fetch one full record")
38
+ artifact_parser.add_argument("unique_id")
39
+ artifact_parser.add_argument("--format", default="simple_json", choices=[item.value for item in ArtifactFormat])
40
+ return parser
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ """Run the CLI and return a process exit code."""
45
+ args = _parser().parse_args(argv)
46
+ try:
47
+ with DimuClient() as client:
48
+ if args.command == "owners":
49
+ result = [item.model_dump(mode="json") for item in client.collections(Country(args.country))]
50
+ print(json.dumps(result, ensure_ascii=False, indent=2))
51
+ return 0
52
+ if args.command == "search":
53
+ response = client.search(
54
+ SearchRequest(
55
+ query=args.query,
56
+ collection_ids=args.owner,
57
+ limit=args.rows,
58
+ has_pictures=True if args.pictures else None,
59
+ )
60
+ )
61
+ else:
62
+ response = client.artifact(args.unique_id, args.format)
63
+ response.raise_for_status()
64
+ sys.stdout.buffer.write(response.content)
65
+ if not response.content.endswith(b"\n"):
66
+ sys.stdout.buffer.write(b"\n")
67
+ return 0
68
+ except (httpx.HTTPError, ValidationError, ValueError, ParseError) as error:
69
+ print(f"DigitaltMuseum request failed: {error}", file=sys.stderr)
70
+ return 1
@@ -0,0 +1,89 @@
1
+ """HTTP-klient för DigitaltMuseum utan normalisering av svar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import warnings
7
+ from xml.etree import ElementTree
8
+
9
+ import httpx
10
+
11
+ from .models import ArtifactFormat, ArtifactRequest, Collection, Country, SearchRequest
12
+
13
+ API_URL = "https://api.dimu.org"
14
+
15
+
16
+ class DimuClient:
17
+ """Återanvändbar synkron klient för DigitaltMuseums publika API."""
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: str | None = None,
22
+ *,
23
+ base_url: str = API_URL,
24
+ timeout: float = 30.0,
25
+ transport: httpx.BaseTransport | None = None,
26
+ ) -> None:
27
+ self.api_key = api_key or os.getenv("DIMU_API_KEY") or "demo"
28
+ if self.api_key == "demo":
29
+ warnings.warn(
30
+ "dimu använder DigitaltMuseums demo-nyckel; sätt DIMU_API_KEY för produktion",
31
+ RuntimeWarning,
32
+ stacklevel=2,
33
+ )
34
+ self._client = httpx.Client(
35
+ base_url=base_url.rstrip("/"),
36
+ timeout=timeout,
37
+ transport=transport,
38
+ follow_redirects=True,
39
+ headers={"User-Agent": "dimu/0.2"},
40
+ )
41
+
42
+ def __enter__(self) -> "DimuClient":
43
+ return self
44
+
45
+ def __exit__(self, *args: object) -> None:
46
+ self.close()
47
+
48
+ def close(self) -> None:
49
+ """Stäng poolade nätverksanslutningar."""
50
+ self._client.close()
51
+
52
+ def _get(self, path: str, params: list[tuple[str, str]]) -> httpx.Response:
53
+ return self._client.get(path, params=[*params, ("api.key", self.api_key)])
54
+
55
+ def search(self, request: SearchRequest) -> httpx.Response:
56
+ """Kör en sökning och returnera DigitaltMuseums otolkade svar."""
57
+ return self._get("/api/solr/select", request.to_params())
58
+
59
+ def artifact(
60
+ self,
61
+ unique_id: str,
62
+ format: ArtifactFormat | str = ArtifactFormat.SIMPLE_JSON,
63
+ ) -> httpx.Response:
64
+ """Hämta ett fullständigt objekt och returnera det otolkade svaret."""
65
+ request = ArtifactRequest(unique_id=unique_id, format=format)
66
+ return self._get(
67
+ "/api/artifact",
68
+ [("unique_id", request.unique_id), ("mapping", request.format.value)],
69
+ )
70
+
71
+ def collections_raw(self, country: Country | str) -> httpx.Response:
72
+ """Hämta den ursprungliga XML-listan över publicister för ett land."""
73
+ return self._get("/api/owners", [("country", Country(country).value)])
74
+
75
+ def collections(self, country: Country | str) -> list[Collection]:
76
+ """Tolka publicistlistan till den lilla JSON-form som chatboten använder."""
77
+ selected_country = Country(country)
78
+ response = self.collections_raw(selected_country)
79
+ response.raise_for_status()
80
+ root = ElementTree.fromstring(response.content)
81
+ return [
82
+ Collection(
83
+ identifier=owner.findtext("identifier", ""),
84
+ name=owner.findtext("name", ""),
85
+ parent=owner.findtext("parent"),
86
+ country=selected_country,
87
+ )
88
+ for owner in root.findall("owner")
89
+ ]