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 +21 -0
- dimu-0.2.0/PKG-INFO +73 -0
- dimu-0.2.0/README.md +48 -0
- dimu-0.2.0/dimu/__init__.py +48 -0
- dimu-0.2.0/dimu/__main__.py +4 -0
- dimu-0.2.0/dimu/api.py +205 -0
- dimu-0.2.0/dimu/cli.py +70 -0
- dimu-0.2.0/dimu/client.py +89 -0
- dimu-0.2.0/dimu/models.py +374 -0
- dimu-0.2.0/dimu.egg-info/PKG-INFO +73 -0
- dimu-0.2.0/dimu.egg-info/SOURCES.txt +17 -0
- dimu-0.2.0/dimu.egg-info/dependency_links.txt +1 -0
- dimu-0.2.0/dimu.egg-info/entry_points.txt +2 -0
- dimu-0.2.0/dimu.egg-info/requires.txt +16 -0
- dimu-0.2.0/dimu.egg-info/top_level.txt +1 -0
- dimu-0.2.0/pyproject.toml +40 -0
- dimu-0.2.0/setup.cfg +4 -0
- dimu-0.2.0/tests/test_api.py +141 -0
- dimu-0.2.0/tests/test_dimu.py +165 -0
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)
|
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
|
+
]
|