final implementation
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
74
.github/workflows/docker-build-push.yaml
vendored
Normal file
74
.github/workflows/docker-build-push.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- '.github/workflows/docker-build-push.yaml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
ACR_NAME: crfhiskybert
|
||||||
|
IMAGE: crfhiskybert.azurecr.io/fida/ki/statistikk-mcp
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get short SHA
|
||||||
|
id: sha
|
||||||
|
run: echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Login to Azure using Federated Identity
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||||
|
allow-no-subscriptions: true
|
||||||
|
|
||||||
|
- name: Login to ACR
|
||||||
|
run: az acr login --name ${{ env.ACR_NAME }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: prod
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trigger GitOps tag update
|
||||||
|
run: |
|
||||||
|
curl -sS -f -L \
|
||||||
|
-X POST \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.GITOPS_PAT }}" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"https://api.github.com/repos/${{ vars.GITOPS_REPO }}/dispatches" \
|
||||||
|
-d '{"event_type":"update_tag","client_payload":{"env":"test","updates":[{"repository":"fida/ki/statistikk-mcp","tag":"${{ steps.sha.outputs.short }}"}]}}'
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.venv
|
||||||
|
.mcp.json
|
||||||
8
.mcp.json.local
Normal file
8
.mcp.json.local
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"fhi-statistikk": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:8000/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.mcp.json.public
Normal file
8
.mcp.json.public
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"fhi-statistikk": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "https://statistikk-mcp.sky.fhi.no/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS prod
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY src/ src/
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["fhi-statistikk-mcp", "--transport", "sse", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
20
Makefile
Normal file
20
Makefile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
ACR := crfhiskybert.azurecr.io
|
||||||
|
IMAGE := $(ACR)/fida/ki/fhi-statistikk-mcp
|
||||||
|
TAG := $(shell git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
.PHONY: docker-build docker-push docker acr-login
|
||||||
|
|
||||||
|
acr-login:
|
||||||
|
az acr login --name crfhiskybert
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build --target prod -t $(IMAGE):$(TAG) -t $(IMAGE):latest .
|
||||||
|
|
||||||
|
docker-push:
|
||||||
|
docker push $(IMAGE):$(TAG)
|
||||||
|
docker push $(IMAGE):latest
|
||||||
|
|
||||||
|
docker: acr-login docker-build docker-push
|
||||||
|
|
||||||
|
run:
|
||||||
|
docker run --rm -p 18000:8000 $(IMAGE):latest
|
||||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "fhi-statistikk-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for FHI Statistikk Open API"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.0.0",
|
||||||
|
"uvicorn>=0.30",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
fhi-statistikk-mcp = "fhi_statistikk_mcp.server:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/fhi_statistikk_mcp"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
1
src/fhi_statistikk_mcp/__init__.py
Normal file
1
src/fhi_statistikk_mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
src/fhi_statistikk_mcp/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/fhi_statistikk_mcp/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/fhi_statistikk_mcp/__pycache__/api_client.cpython-312.pyc
Normal file
BIN
src/fhi_statistikk_mcp/__pycache__/api_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/fhi_statistikk_mcp/__pycache__/cache.cpython-312.pyc
Normal file
BIN
src/fhi_statistikk_mcp/__pycache__/cache.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/fhi_statistikk_mcp/__pycache__/server.cpython-312.pyc
Normal file
BIN
src/fhi_statistikk_mcp/__pycache__/server.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/fhi_statistikk_mcp/__pycache__/transformers.cpython-312.pyc
Normal file
BIN
src/fhi_statistikk_mcp/__pycache__/transformers.cpython-312.pyc
Normal file
Binary file not shown.
253
src/fhi_statistikk_mcp/api_client.py
Normal file
253
src/fhi_statistikk_mcp/api_client.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""Async HTTP client for FHI Statistikk Open API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .cache import TTLCache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://statistikk-data.fhi.no/api/open/v1"
|
||||||
|
|
||||||
|
# Cache TTLs in seconds
|
||||||
|
TTL_SOURCES = 24 * 3600
|
||||||
|
TTL_TABLES = 3600
|
||||||
|
TTL_DIMENSIONS = 6 * 3600
|
||||||
|
TTL_METADATA = 6 * 3600
|
||||||
|
TTL_FLAGS = 6 * 3600
|
||||||
|
TTL_QUERY = 6 * 3600
|
||||||
|
|
||||||
|
MIN_REQUEST_INTERVAL = 0.1 # 100ms between requests
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
def __init__(self, status_code: int, detail: str) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(f"API {status_code}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
"""Async client wrapping the FHI Statistikk REST API with caching and rate limiting."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client = httpx.AsyncClient(base_url=BASE_URL, timeout=30.0)
|
||||||
|
self._cache = TTLCache()
|
||||||
|
self._semaphore = asyncio.Semaphore(5)
|
||||||
|
self._request_lock = asyncio.Lock()
|
||||||
|
self._last_request_time = 0.0
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
accept: str = "application/json",
|
||||||
|
json_body: dict | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""Rate-limited HTTP request with retry on 429/503/timeout."""
|
||||||
|
async with self._semaphore:
|
||||||
|
# Compute wait atomically, release lock before sleeping
|
||||||
|
async with self._request_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
wait = MIN_REQUEST_INTERVAL - (now - self._last_request_time)
|
||||||
|
self._last_request_time = max(
|
||||||
|
now, self._last_request_time + MIN_REQUEST_INTERVAL
|
||||||
|
)
|
||||||
|
if wait > 0:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
resp: httpx.Response | None = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
resp = await self._client.request(
|
||||||
|
method, path, headers={"Accept": accept}, json=json_body,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
last_exc = exc
|
||||||
|
delay = (attempt + 1) * 2
|
||||||
|
logger.warning(
|
||||||
|
"Timeout (attempt %d), retrying in %ds", attempt + 1, delay
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise ApiError(0, f"Network error: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code in (429, 503) and attempt < 2:
|
||||||
|
delay = (attempt + 1) * 2
|
||||||
|
logger.warning(
|
||||||
|
"Got %d, retrying in %ds", resp.status_code, delay
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise ApiError(resp.status_code, _extract_error(resp))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
if last_exc is not None:
|
||||||
|
raise ApiError(
|
||||||
|
0, "API request timed out. Try reducing query scope."
|
||||||
|
) from last_exc
|
||||||
|
if resp is not None:
|
||||||
|
raise ApiError(resp.status_code, _extract_error(resp))
|
||||||
|
raise ApiError(0, "API request failed after retries.")
|
||||||
|
|
||||||
|
async def _get_json(self, path: str) -> dict | list:
|
||||||
|
resp = await self._request("GET", path)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
# --- Cached endpoints ---
|
||||||
|
|
||||||
|
async def get_sources(self) -> list[dict]:
|
||||||
|
cached = self._cache.get("sources")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = await self._get_json("/Common/source")
|
||||||
|
self._cache.set("sources", data, TTL_SOURCES)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_tables(
|
||||||
|
self, source_id: str, modified_after: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
cache_key = f"tables:{source_id}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if cached is not None and modified_after is None:
|
||||||
|
return cached
|
||||||
|
path = f"/{source_id}/Table"
|
||||||
|
if modified_after:
|
||||||
|
path += f"?modifiedAfter={modified_after}"
|
||||||
|
data = await self._get_json(path)
|
||||||
|
if modified_after is None:
|
||||||
|
self._cache.set(cache_key, data, TTL_TABLES)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_table_info(self, source_id: str, table_id: int) -> dict:
|
||||||
|
cache_key = f"table_info:{source_id}:{table_id}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = await self._get_json(f"/{source_id}/Table/{table_id}")
|
||||||
|
self._cache.set(cache_key, data, TTL_METADATA)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_dimensions(self, source_id: str, table_id: int) -> list[dict]:
|
||||||
|
cache_key = f"dims:{source_id}:{table_id}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = await self._get_json(f"/{source_id}/Table/{table_id}/dimension")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get("dimensions", [])
|
||||||
|
self._cache.set(cache_key, data, TTL_DIMENSIONS)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_metadata(self, source_id: str, table_id: int) -> dict:
|
||||||
|
cache_key = f"meta:{source_id}:{table_id}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = await self._get_json(f"/{source_id}/Table/{table_id}/metadata")
|
||||||
|
self._cache.set(cache_key, data, TTL_METADATA)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_flags(self, source_id: str, table_id: int) -> list[dict]:
|
||||||
|
cache_key = f"flags:{source_id}:{table_id}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = await self._get_json(f"/{source_id}/Table/{table_id}/flag")
|
||||||
|
self._cache.set(cache_key, data, TTL_FLAGS)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_query_template(self, source_id: str, table_id: int) -> dict:
|
||||||
|
cache_key = f"query:{source_id}:{table_id}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = await self._get_json(f"/{source_id}/Table/{table_id}/query")
|
||||||
|
self._cache.set(cache_key, data, TTL_QUERY)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def post_data(
|
||||||
|
self,
|
||||||
|
source_id: str,
|
||||||
|
table_id: int,
|
||||||
|
body: dict,
|
||||||
|
max_row_count: int = 50000,
|
||||||
|
) -> str:
|
||||||
|
"""Post a data query, return raw CSV text."""
|
||||||
|
request_body = {**body}
|
||||||
|
request_body["response"] = {
|
||||||
|
"format": "csv2",
|
||||||
|
"maxRowCount": max_row_count,
|
||||||
|
}
|
||||||
|
resp = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/{source_id}/Table/{table_id}/data",
|
||||||
|
accept="text/csv",
|
||||||
|
json_body=request_body,
|
||||||
|
)
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Module-level lifecycle management ---
|
||||||
|
|
||||||
|
_client: ApiClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_client() -> ApiClient:
|
||||||
|
"""Create the shared client. Call from server lifespan."""
|
||||||
|
global _client
|
||||||
|
_client = ApiClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_client() -> None:
|
||||||
|
"""Close the shared client. Call from server lifespan shutdown."""
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.close()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> ApiClient:
|
||||||
|
"""Get (or lazily create) the shared client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = ApiClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_error(resp: httpx.Response) -> str:
|
||||||
|
"""Extract human-readable error from API response (RFC 7807 ProblemDetails)."""
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
if isinstance(body, dict):
|
||||||
|
parts = []
|
||||||
|
title = body.get("title", "")
|
||||||
|
if title:
|
||||||
|
parts.append(title)
|
||||||
|
detail = body.get("detail", "")
|
||||||
|
if detail:
|
||||||
|
parts.append(detail)
|
||||||
|
errors = body.get("errors", {})
|
||||||
|
if isinstance(errors, dict):
|
||||||
|
for msgs in errors.values():
|
||||||
|
if isinstance(msgs, list):
|
||||||
|
parts.extend(str(m) for m in msgs)
|
||||||
|
if parts:
|
||||||
|
return " | ".join(parts)
|
||||||
|
return str(body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
|
||||||
27
src/fhi_statistikk_mcp/cache.py
Normal file
27
src/fhi_statistikk_mcp/cache.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Simple in-memory TTL cache."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class TTLCache:
|
||||||
|
"""Dict-based cache with per-key TTL expiry."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: dict[str, tuple[float, Any]] = {}
|
||||||
|
|
||||||
|
def get(self, key: str) -> Any | None:
|
||||||
|
entry = self._store.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
expires_at, value = entry
|
||||||
|
if time.monotonic() > expires_at:
|
||||||
|
del self._store[key]
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl_seconds: float) -> None:
|
||||||
|
self._store[key] = (time.monotonic() + ttl_seconds, value)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._store.clear()
|
||||||
311
src/fhi_statistikk_mcp/server.py
Normal file
311
src/fhi_statistikk_mcp/server.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""MCP server exposing FHI Statistikk Open API as agent-friendly tools."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
for _name in ("uvicorn", "uvicorn.error", "uvicorn.access", "mcp", "fastmcp"):
|
||||||
|
_log = logging.getLogger(_name)
|
||||||
|
_log.handlers = []
|
||||||
|
_handler = logging.StreamHandler(sys.stderr)
|
||||||
|
_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
||||||
|
_log.addHandler(_handler)
|
||||||
|
_log.propagate = False
|
||||||
|
|
||||||
|
logger = logging.getLogger("fhi_statistikk_mcp")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
_h = logging.StreamHandler(sys.stderr)
|
||||||
|
_h.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
||||||
|
logger.addHandler(_h)
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from .api_client import ApiError, close_client, get_client, init_client
|
||||||
|
from .transformers import (
|
||||||
|
complete_query_dimensions,
|
||||||
|
extract_metadata_fields,
|
||||||
|
matches_search,
|
||||||
|
navigate_hierarchy,
|
||||||
|
parse_csv_to_rows,
|
||||||
|
strip_html,
|
||||||
|
summarize_dimensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool implementations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_sources() -> list[dict]:
|
||||||
|
"""List all available FHI data sources.
|
||||||
|
|
||||||
|
Returns an array of sources with id, title, description, and publisher.
|
||||||
|
This is the entry point for discovering what data is available.
|
||||||
|
|
||||||
|
Sources include public health statistics (Folkehelsestatistikk),
|
||||||
|
vaccination registry (SYSVAK), cause of death registry, and more.
|
||||||
|
All data is open (CC BY 4.0), no authentication required.
|
||||||
|
"""
|
||||||
|
api = get_client()
|
||||||
|
raw = await api.get_sources()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.get("id", ""),
|
||||||
|
"title": s.get("title", ""),
|
||||||
|
"description": strip_html(s.get("description", "")),
|
||||||
|
"published_by": s.get("publishedBy", ""),
|
||||||
|
}
|
||||||
|
for s in raw
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tables(
|
||||||
|
source_id: str,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
modified_after: Optional[str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tables within a data source, with optional keyword search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_id: Source identifier, e.g. "nokkel", "msis", "daar".
|
||||||
|
Use list_sources to find available source IDs.
|
||||||
|
search: Case-insensitive keyword filter on table title.
|
||||||
|
Multiple words must all match. Example: "befolkning vekst"
|
||||||
|
modified_after: ISO-8601 datetime. Only return tables modified after
|
||||||
|
this date. Example: "2025-01-01"
|
||||||
|
|
||||||
|
Returns tables sorted by modification date (newest first).
|
||||||
|
"""
|
||||||
|
api = get_client()
|
||||||
|
raw = await api.get_tables(source_id, modified_after)
|
||||||
|
tables = [
|
||||||
|
{
|
||||||
|
"table_id": t.get("tableId"),
|
||||||
|
"title": t.get("title", ""),
|
||||||
|
"published_at": t.get("publishedAt", ""),
|
||||||
|
"modified_at": t.get("modifiedAt", ""),
|
||||||
|
}
|
||||||
|
for t in raw
|
||||||
|
]
|
||||||
|
|
||||||
|
if search:
|
||||||
|
tables = [t for t in tables if matches_search(t["title"], search)]
|
||||||
|
|
||||||
|
tables.sort(key=lambda t: t["modified_at"] or "", reverse=True)
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
async def describe_table(source_id: str, table_id: int) -> dict:
|
||||||
|
"""Get complete table structure: dimensions, metadata, and flags.
|
||||||
|
|
||||||
|
This is the primary tool for understanding a table before querying data.
|
||||||
|
Returns everything needed to construct a query_data call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_id: Source identifier, e.g. "nokkel"
|
||||||
|
table_id: Numeric table ID from list_tables
|
||||||
|
|
||||||
|
The response includes:
|
||||||
|
- title, dates, description, keywords, update frequency
|
||||||
|
- dimensions with their codes, labels, and available values
|
||||||
|
- flags (symbols for missing/suppressed data)
|
||||||
|
|
||||||
|
Large dimensions (GEO with 400+ entries) show only top-level values.
|
||||||
|
Use get_dimension_values to drill into sub-levels.
|
||||||
|
|
||||||
|
Fixed dimensions (single value, like KJONN="kjønn samlet") are marked
|
||||||
|
with is_fixed=true -- query_data auto-includes these.
|
||||||
|
"""
|
||||||
|
api = get_client()
|
||||||
|
|
||||||
|
info, dims, meta, flags = await asyncio.gather(
|
||||||
|
api.get_table_info(source_id, table_id),
|
||||||
|
api.get_dimensions(source_id, table_id),
|
||||||
|
api.get_metadata(source_id, table_id),
|
||||||
|
api.get_flags(source_id, table_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_fields = extract_metadata_fields(meta)
|
||||||
|
dim_summaries = summarize_dimensions(dims if isinstance(dims, list) else [])
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"title": info.get("title", ""),
|
||||||
|
"published_at": info.get("publishedAt", ""),
|
||||||
|
"modified_at": info.get("modifiedAt", ""),
|
||||||
|
}
|
||||||
|
result.update(meta_fields)
|
||||||
|
result["dimensions"] = dim_summaries
|
||||||
|
result["flags"] = [
|
||||||
|
{"symbol": f.get("symbol", ""), "description": f.get("description", "")}
|
||||||
|
for f in (flags if isinstance(flags, list) else [])
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dimension_values(
|
||||||
|
source_id: str,
|
||||||
|
table_id: int,
|
||||||
|
dimension_code: str,
|
||||||
|
parent_value: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Drill into dimension values, especially for large hierarchical dimensions like GEO.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_id: Source identifier
|
||||||
|
table_id: Table ID
|
||||||
|
dimension_code: Dimension code, e.g. "GEO", "AAR", "ALDER"
|
||||||
|
parent_value: Return only children of this category.
|
||||||
|
Example: "18" for Nordland county municipalities.
|
||||||
|
If omitted, returns top-level categories.
|
||||||
|
search: Case-insensitive search on category labels.
|
||||||
|
Accent-insensitive: "tromso" matches "Tromsø".
|
||||||
|
Example: "bodø", "oslo", "bergen"
|
||||||
|
|
||||||
|
Returns array of {value, label, child_count}.
|
||||||
|
"""
|
||||||
|
api = get_client()
|
||||||
|
dims = await api.get_dimensions(source_id, table_id)
|
||||||
|
|
||||||
|
target = None
|
||||||
|
for d in (dims if isinstance(dims, list) else []):
|
||||||
|
if d.get("code", "").upper() == dimension_code.upper():
|
||||||
|
target = d
|
||||||
|
break
|
||||||
|
|
||||||
|
if target is None:
|
||||||
|
available = [d.get("code", "") for d in (dims if isinstance(dims, list) else [])]
|
||||||
|
raise ValueError(
|
||||||
|
f"Dimension '{dimension_code}' not found. "
|
||||||
|
f"Available: {', '.join(available)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_categories = target.get("categories", [])
|
||||||
|
return navigate_hierarchy(raw_categories, parent_value, search)
|
||||||
|
|
||||||
|
|
||||||
|
async def query_data(
|
||||||
|
source_id: str,
|
||||||
|
table_id: int,
|
||||||
|
dimensions: list[dict],
|
||||||
|
max_rows: int = 1000,
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch statistical data from an FHI table.
|
||||||
|
|
||||||
|
Before calling this, use describe_table to understand the table's
|
||||||
|
dimensions and available values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_id: Source identifier
|
||||||
|
table_id: Table ID
|
||||||
|
dimensions: Array of dimension filters. Each element:
|
||||||
|
- code (str): Dimension code, e.g. "GEO"
|
||||||
|
- filter (str): "item" (exact), "all" (wildcard), "top" (first N), "bottom" (last N)
|
||||||
|
- values (list[str]): Filter values
|
||||||
|
|
||||||
|
You only need to specify dimensions you care about.
|
||||||
|
Fixed dimensions (single-valued) are auto-included.
|
||||||
|
If you omit MEASURE_TYPE, all measures are returned.
|
||||||
|
All other dimensions MUST be specified or a ValueError is raised.
|
||||||
|
|
||||||
|
max_rows: Max rows to return (default 1000, 0 for unlimited)
|
||||||
|
|
||||||
|
Year values: use "2020" (auto-translated to "2020_2020") or full format.
|
||||||
|
|
||||||
|
Returns labeled rows with truncation info. Check "truncated" field.
|
||||||
|
"""
|
||||||
|
api = get_client()
|
||||||
|
|
||||||
|
raw_dims = await api.get_dimensions(source_id, table_id)
|
||||||
|
dim_list = raw_dims if isinstance(raw_dims, list) else []
|
||||||
|
|
||||||
|
query_dims = complete_query_dimensions(dim_list, dimensions)
|
||||||
|
|
||||||
|
body = {"dimensions": query_dims}
|
||||||
|
|
||||||
|
try:
|
||||||
|
csv_text = await api.post_data(source_id, table_id, body)
|
||||||
|
except ApiError as e:
|
||||||
|
raise ValueError(f"API error: {e.detail}") from e
|
||||||
|
|
||||||
|
parsed = parse_csv_to_rows(csv_text, max_rows)
|
||||||
|
parsed["dimensions_used"] = {
|
||||||
|
d["code"]: {"filter": d["filter"], "values": d["values"]}
|
||||||
|
for d in query_dims
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await api.get_table_info(source_id, table_id)
|
||||||
|
parsed["table"] = info.get("title", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
async def get_query_template(source_id: str, table_id: int) -> dict:
|
||||||
|
"""Get the raw query template for a table.
|
||||||
|
|
||||||
|
Returns the exact JSON body the API expects for data queries.
|
||||||
|
Useful when query_data auto-completion isn't behaving as expected,
|
||||||
|
or to see all available values for every dimension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_id: Source identifier
|
||||||
|
table_id: Table ID
|
||||||
|
"""
|
||||||
|
api = get_client()
|
||||||
|
return await api.get_query_template(source_id, table_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server construction and entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_mcp(host: str, port: int) -> FastMCP:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lifespan(_server: FastMCP):
|
||||||
|
init_client()
|
||||||
|
logger.info("HTTP client initialized")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await close_client()
|
||||||
|
logger.info("HTTP client closed")
|
||||||
|
|
||||||
|
server = FastMCP("fhi-statistikk", host=host, port=port, lifespan=_lifespan)
|
||||||
|
|
||||||
|
server.tool(name="list_sources")(list_sources)
|
||||||
|
server.tool(name="list_tables")(list_tables)
|
||||||
|
server.tool(name="describe_table")(describe_table)
|
||||||
|
server.tool(name="get_dimension_values")(get_dimension_values)
|
||||||
|
server.tool(name="query_data")(query_data)
|
||||||
|
server.tool(name="get_query_template")(get_query_template)
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="FHI Statistikk MCP Server")
|
||||||
|
ap.add_argument(
|
||||||
|
"--transport",
|
||||||
|
default="sse",
|
||||||
|
choices=["stdio", "sse", "streamable-http"],
|
||||||
|
)
|
||||||
|
ap.add_argument("--host", default="0.0.0.0")
|
||||||
|
ap.add_argument("--port", type=int, default=8000)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
logger.info("Starting FHI Statistikk MCP server")
|
||||||
|
logger.info(" API: %s", "https://statistikk-data.fhi.no/api/open/v1/")
|
||||||
|
logger.info(" Transport: %s on %s:%d", args.transport, args.host, args.port)
|
||||||
|
|
||||||
|
server = _build_mcp(args.host, args.port)
|
||||||
|
server.run(transport=args.transport)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
347
src/fhi_statistikk_mcp/transformers.py
Normal file
347
src/fhi_statistikk_mcp/transformers.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"""Data transformation utilities for FHI API responses."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTML stripping ---
|
||||||
|
|
||||||
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
_WHITESPACE_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html(text: str) -> str:
|
||||||
|
"""Remove HTML tags, decode entities, collapse whitespace."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
text = _HTML_TAG_RE.sub(" ", text)
|
||||||
|
text = (
|
||||||
|
text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace(""", '"')
|
||||||
|
.replace("'", "'")
|
||||||
|
)
|
||||||
|
return _WHITESPACE_RE.sub(" ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Accent-insensitive search ---
|
||||||
|
|
||||||
|
_NORDIC_MAP = str.maketrans({
|
||||||
|
"æ": "a", "ø": "o", "å": "a",
|
||||||
|
"ü": "u",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_for_search(text: str) -> str:
|
||||||
|
"""Normalize for accent-insensitive comparison.
|
||||||
|
|
||||||
|
Handles Nordic characters (æøå) and combining accents.
|
||||||
|
"tromso" matches "Tromsø", "barum" matches "Bærum".
|
||||||
|
"""
|
||||||
|
lowered = text.lower().translate(_NORDIC_MAP)
|
||||||
|
nfd = unicodedata.normalize("NFD", lowered)
|
||||||
|
return "".join(c for c in nfd if unicodedata.category(c) != "Mn")
|
||||||
|
|
||||||
|
|
||||||
|
def matches_search(text: str, query: str) -> bool:
|
||||||
|
"""Check if all query words appear in text (accent-insensitive)."""
|
||||||
|
normalized = normalize_for_search(text)
|
||||||
|
words = normalize_for_search(query).split()
|
||||||
|
return all(w in normalized for w in words)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Year normalization ---
|
||||||
|
|
||||||
|
def normalize_year_value(value: str) -> str:
|
||||||
|
"""Convert "2020" to "2020_2020" if not already in that format."""
|
||||||
|
if "_" not in value and value.isdigit():
|
||||||
|
return f"{value}_{value}"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# --- Category tree operations ---
|
||||||
|
|
||||||
|
def flatten_categories(categories: list[dict]) -> list[dict]:
|
||||||
|
"""Flatten a nested category tree into a flat list with parent_value refs.
|
||||||
|
|
||||||
|
Input: [{value, label, children: [{...}]}]
|
||||||
|
Output: [{value, label, parent_value: str|None}, ...]
|
||||||
|
"""
|
||||||
|
flat: list[dict] = []
|
||||||
|
|
||||||
|
def _walk(nodes: list[dict], parent: str | None) -> None:
|
||||||
|
for node in nodes:
|
||||||
|
flat.append({
|
||||||
|
"value": node.get("value", ""),
|
||||||
|
"label": node.get("label", ""),
|
||||||
|
"parent_value": parent,
|
||||||
|
})
|
||||||
|
children = node.get("children") or []
|
||||||
|
if children:
|
||||||
|
_walk(children, node.get("value", ""))
|
||||||
|
|
||||||
|
_walk(categories, None)
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
def navigate_hierarchy(
|
||||||
|
raw_categories: list[dict],
|
||||||
|
parent_value: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Navigate a nested category tree. Returns [{value, label, child_count}]."""
|
||||||
|
flat = flatten_categories(raw_categories)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": c["value"],
|
||||||
|
"label": c["label"],
|
||||||
|
"child_count": _count_children(c["value"], flat),
|
||||||
|
}
|
||||||
|
for c in flat
|
||||||
|
if matches_search(c["label"], search)
|
||||||
|
]
|
||||||
|
|
||||||
|
if parent_value is None:
|
||||||
|
targets = [c for c in flat if c["parent_value"] is None]
|
||||||
|
else:
|
||||||
|
targets = [c for c in flat if c["parent_value"] == parent_value]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": c["value"],
|
||||||
|
"label": c["label"],
|
||||||
|
"child_count": _count_children(c["value"], flat),
|
||||||
|
}
|
||||||
|
for c in targets
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dimension summarization ---
|
||||||
|
|
||||||
|
def summarize_dimensions(dimensions: list[dict]) -> list[dict]:
|
||||||
|
"""Transform raw dimension data into agent-friendly summaries."""
|
||||||
|
result = []
|
||||||
|
for dim in dimensions:
|
||||||
|
code = dim.get("code", "")
|
||||||
|
label = dim.get("label", "")
|
||||||
|
raw_categories = dim.get("categories", [])
|
||||||
|
flat = flatten_categories(raw_categories)
|
||||||
|
|
||||||
|
summary: dict = {
|
||||||
|
"code": code,
|
||||||
|
"label": label,
|
||||||
|
"total_categories": len(flat),
|
||||||
|
}
|
||||||
|
|
||||||
|
has_hierarchy = any(c["parent_value"] is not None for c in flat)
|
||||||
|
summary["is_hierarchical"] = has_hierarchy
|
||||||
|
|
||||||
|
if len(flat) == 1:
|
||||||
|
summary["is_fixed"] = True
|
||||||
|
summary["values"] = [
|
||||||
|
{"value": flat[0]["value"], "label": flat[0]["label"]}
|
||||||
|
]
|
||||||
|
summary["note"] = "Single-valued, auto-included in queries"
|
||||||
|
elif has_hierarchy and len(flat) > 20:
|
||||||
|
top_level = [c for c in flat if c["parent_value"] is None]
|
||||||
|
summary["hierarchy_depth"] = _compute_depth(flat)
|
||||||
|
summary["top_level_values"] = [
|
||||||
|
{
|
||||||
|
"value": c["value"],
|
||||||
|
"label": c["label"],
|
||||||
|
"child_count": _count_children(c["value"], flat),
|
||||||
|
}
|
||||||
|
for c in top_level
|
||||||
|
]
|
||||||
|
summary["note"] = "Use get_dimension_values to drill into sub-levels"
|
||||||
|
elif is_year_dimension(code, flat):
|
||||||
|
values = [c["value"] for c in flat]
|
||||||
|
years = _extract_year_range(values)
|
||||||
|
summary["is_hierarchical"] = False
|
||||||
|
summary["value_format"] = "YYYY_YYYY (e.g. 2020_2020)"
|
||||||
|
if years:
|
||||||
|
summary["range"] = f"{years[0]}..{years[-1]}"
|
||||||
|
if len(flat) <= 50:
|
||||||
|
summary["values"] = values
|
||||||
|
else:
|
||||||
|
summary["values"] = [
|
||||||
|
{"value": c["value"], "label": c["label"]}
|
||||||
|
for c in flat
|
||||||
|
]
|
||||||
|
|
||||||
|
result.append(summary)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def extract_metadata_fields(metadata: dict) -> dict:
|
||||||
|
"""Extract key fields from metadata response.
|
||||||
|
|
||||||
|
API returns: {name, isOfficialStatistics, paragraphs: [{header, content}]}
|
||||||
|
"""
|
||||||
|
fields: dict = {}
|
||||||
|
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
if metadata.get("isOfficialStatistics") is not None:
|
||||||
|
fields["is_official_statistics"] = metadata["isOfficialStatistics"]
|
||||||
|
paragraphs = metadata.get("paragraphs", [])
|
||||||
|
elif isinstance(metadata, list):
|
||||||
|
paragraphs = metadata
|
||||||
|
else:
|
||||||
|
paragraphs = []
|
||||||
|
|
||||||
|
for section in paragraphs:
|
||||||
|
header = (section.get("header") or "").lower()
|
||||||
|
content = strip_html(section.get("content") or "")
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "beskrivelse" in header or "description" in header:
|
||||||
|
fields["description"] = content
|
||||||
|
elif "oppdater" in header or "frekvens" in header:
|
||||||
|
fields["update_frequency"] = content
|
||||||
|
elif "nøkkelord" in header or "keyword" in header or "emneord" in header:
|
||||||
|
fields["keywords"] = [k.strip() for k in content.split(",")]
|
||||||
|
elif "kilde" in header or "source" in header or "institusjon" in header:
|
||||||
|
fields["source_institution"] = content
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
# --- Query dimension completion ---
|
||||||
|
|
||||||
|
def complete_query_dimensions(
|
||||||
|
dim_definitions: list[dict],
|
||||||
|
user_dimensions: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build complete query dimension list from user input and table definitions.
|
||||||
|
|
||||||
|
- User-provided dimensions pass through (with year normalization for "item" filter).
|
||||||
|
- Fixed dimensions (1 category) are auto-included.
|
||||||
|
- MEASURE_TYPE defaults to filter="all", values=["*"].
|
||||||
|
- Missing non-fixed dimensions raise ValueError.
|
||||||
|
"""
|
||||||
|
for d in user_dimensions:
|
||||||
|
if "code" not in d:
|
||||||
|
raise ValueError(f"Dimension entry missing 'code' key: {d}")
|
||||||
|
|
||||||
|
provided = {d["code"].upper(): d for d in user_dimensions}
|
||||||
|
query_dims = []
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
for dim_def in dim_definitions:
|
||||||
|
code = dim_def.get("code", "")
|
||||||
|
raw_categories = dim_def.get("categories", [])
|
||||||
|
flat = flatten_categories(raw_categories)
|
||||||
|
upper_code = code.upper()
|
||||||
|
|
||||||
|
if upper_code in provided:
|
||||||
|
d = provided[upper_code]
|
||||||
|
filt = d.get("filter", "item")
|
||||||
|
vals = d.get("values", [])
|
||||||
|
if filt == "item" and is_year_dimension(code, flat):
|
||||||
|
vals = [normalize_year_value(v) for v in vals]
|
||||||
|
query_dims.append({"code": code, "filter": filt, "values": vals})
|
||||||
|
elif len(flat) == 1:
|
||||||
|
query_dims.append({
|
||||||
|
"code": code,
|
||||||
|
"filter": "item",
|
||||||
|
"values": [flat[0]["value"]],
|
||||||
|
})
|
||||||
|
elif upper_code == "MEASURE_TYPE":
|
||||||
|
query_dims.append({
|
||||||
|
"code": code,
|
||||||
|
"filter": "all",
|
||||||
|
"values": ["*"],
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
missing.append(code)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing required dimensions: {', '.join(missing)}. "
|
||||||
|
"Specify these or use filter='all' with values=['*'] to include all."
|
||||||
|
)
|
||||||
|
|
||||||
|
return query_dims
|
||||||
|
|
||||||
|
|
||||||
|
# --- CSV parsing ---
|
||||||
|
|
||||||
|
def parse_csv_to_rows(csv_text: str, max_rows: int = 1000) -> dict:
|
||||||
|
"""Parse semicolon-delimited CSV response into structured rows."""
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text), delimiter=";")
|
||||||
|
rows = []
|
||||||
|
total = 0
|
||||||
|
for row in reader:
|
||||||
|
total += 1
|
||||||
|
if max_rows > 0 and len(rows) >= max_rows:
|
||||||
|
continue # keep counting total
|
||||||
|
cleaned = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if k is None:
|
||||||
|
continue
|
||||||
|
cleaned[k.strip()] = _try_numeric(v.strip() if v else "")
|
||||||
|
rows.append(cleaned)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"total_rows": total,
|
||||||
|
"truncated": total > len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Internal helpers ---
|
||||||
|
|
||||||
|
def _count_children(value: str, flat: list[dict]) -> int:
|
||||||
|
return sum(1 for c in flat if c["parent_value"] == value)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_depth(flat: list[dict]) -> int:
|
||||||
|
"""Compute hierarchy depth with cycle detection."""
|
||||||
|
parent_map = {c["value"]: c["parent_value"] for c in flat}
|
||||||
|
max_depth = 1
|
||||||
|
for val in parent_map:
|
||||||
|
depth = 1
|
||||||
|
current = val
|
||||||
|
seen: set[str] = set()
|
||||||
|
while parent_map.get(current) and current not in seen:
|
||||||
|
seen.add(current)
|
||||||
|
current = parent_map[current]
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
return max_depth
|
||||||
|
|
||||||
|
|
||||||
|
def is_year_dimension(code: str, flat: list[dict]) -> bool:
|
||||||
|
if code.upper() in ("AAR", "YEAR"):
|
||||||
|
return True
|
||||||
|
if flat and re.match(r"^\d{4}_\d{4}$", flat[0]["value"]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_year_range(values: list[str]) -> list[int]:
|
||||||
|
years = []
|
||||||
|
for v in values:
|
||||||
|
m = re.match(r"^(\d{4})(?:_\d{4})?$", v)
|
||||||
|
if m:
|
||||||
|
years.append(int(m.group(1)))
|
||||||
|
return sorted(years)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_numeric(value: str):
|
||||||
|
"""Try to convert a string to int or float. Returns None for missing-data symbols."""
|
||||||
|
if not value or value in ("..", ":", "-"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if "." in value or "," in value:
|
||||||
|
return float(value.replace(",", "."))
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_cache.cpython-312-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_cache.cpython-312-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_transformers.cpython-312-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_transformers.cpython-312-pytest-9.0.2.pyc
Normal file
Binary file not shown.
2
tests/fixtures/data_185.csv
vendored
Normal file
2
tests/fixtures/data_185.csv
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"Geografi";"År";"Kjønn";"Alder";"antall";"prosent vekst";"FLAGG"
|
||||||
|
"Oslo";"2024";"kjønn samlet";"alle aldre";"6580";"0.916804837608505";""
|
||||||
|
2234
tests/fixtures/dimensions_185.json
vendored
Normal file
2234
tests/fixtures/dimensions_185.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
tests/fixtures/flags_185.json
vendored
Normal file
7
tests/fixtures/flags_185.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"symbol": "",
|
||||||
|
"description": "Verdi finnes i tabellen"
|
||||||
|
}
|
||||||
|
]
|
||||||
58
tests/fixtures/metadata_185.json
vendored
Normal file
58
tests/fixtures/metadata_185.json
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "Befolkningsvekst",
|
||||||
|
"isOfficialStatistics": false,
|
||||||
|
"paragraphs": [
|
||||||
|
{
|
||||||
|
"header": "Beskrivelse",
|
||||||
|
"content": "<p>Differansen mellom befolkningsmengden i slutten av året (målt 1. januar året etter) og i begynnelsen av året (1. januar). Statistikken vises for kommune- og fylkesinndeling per 1.1.2024.</p><p></p><p></p><p>To måltall er tilgjengelig:</p><ol><li>Antall</li><li>Prosent vekst = prosentvis vekst i folketallet, i prosent av folketall ved inngangen av året</li></ol>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Begrunnelse for valg av indikatoren",
|
||||||
|
"content": "<p>Mange av indikatorene i statistikkbanken er relatert til befolkningstall og -sammensetning, og befolkningsveksten i en kommune bidrar til informasjon om dette. Omtrent en tredjedel av veksten i Norge skyldes fødselsoverskudd, mens resten skyldes netto innvandring.</p><p></p><p>Kilde: </p><p>FHIs folkehelserapport: <a href=\"https://www.fhi.no/he/folkehelserapporten/samfunn/befolkningen/\" rel=\"noopener noreferrer\" target=\"_blank\">Befolkningen i Norge</a></p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Kildeinstitusjon",
|
||||||
|
"content": "Statistisk sentralbyrå (SSB)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Innsamling",
|
||||||
|
"content": "Statistikken beregnes fra Statistisk sentralbyrås befolkningsstatistikk som bygger på folkeregisteropplysninger."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Tolking og feilkilder",
|
||||||
|
"content": "Det er tatt hensyn til mindre grensejusteringer mellom kommuner i statistikken."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Datakvalitet",
|
||||||
|
"content": "En del feil ved innsamling og bearbeiding av dataene er uunngåelig. Det kan være kodefeil, revisjonsfeil, etc. Det er utført et omfattende arbeid for å minimalisere disse feilene, og disse feiltypene anses for å være relativt ubetydelige."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Oppdateringsfrekvens",
|
||||||
|
"content": "Årlig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Nøkkelord",
|
||||||
|
"content": "Befolkning,Befolkningsvekst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Relatert materiale",
|
||||||
|
"content": "<p>FHIs folkehelserapport: <a href=\"https://www.fhi.no/he/folkehelserapporten/samfunn/befolkningen/\" rel=\"noopener noreferrer\" target=\"_blank\">Befolkningen i Norge</a> </p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Geografi",
|
||||||
|
"content": "<p>Hele landet, fylker og kommuner. Bydeler i Oslo, Bergen, Stavanger og Trondheim. </p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "År",
|
||||||
|
"content": "2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 og 2024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Kjønn",
|
||||||
|
"content": "kjønn samlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Alder",
|
||||||
|
"content": "alle aldre"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
474
tests/fixtures/query_185.json
vendored
Normal file
474
tests/fixtures/query_185.json
vendored
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
{
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"code": "GEO",
|
||||||
|
"filter": "item",
|
||||||
|
"values": [
|
||||||
|
"0",
|
||||||
|
"3436",
|
||||||
|
"460104",
|
||||||
|
"4611",
|
||||||
|
"4617",
|
||||||
|
"4647",
|
||||||
|
"5027",
|
||||||
|
"1851",
|
||||||
|
"1857",
|
||||||
|
"1865",
|
||||||
|
"1866",
|
||||||
|
"1867",
|
||||||
|
"1868",
|
||||||
|
"1870",
|
||||||
|
"1871",
|
||||||
|
"1874",
|
||||||
|
"1875",
|
||||||
|
"5501",
|
||||||
|
"5510",
|
||||||
|
"5512",
|
||||||
|
"5514",
|
||||||
|
"5516",
|
||||||
|
"5518",
|
||||||
|
"5520",
|
||||||
|
"5522",
|
||||||
|
"5524",
|
||||||
|
"5526",
|
||||||
|
"5528",
|
||||||
|
"5530",
|
||||||
|
"5532",
|
||||||
|
"5534",
|
||||||
|
"5536",
|
||||||
|
"5540",
|
||||||
|
"5542",
|
||||||
|
"5544",
|
||||||
|
"5546",
|
||||||
|
"5601",
|
||||||
|
"5603",
|
||||||
|
"5605",
|
||||||
|
"3911",
|
||||||
|
"5026",
|
||||||
|
"5029",
|
||||||
|
"1856",
|
||||||
|
"030105",
|
||||||
|
"030106",
|
||||||
|
"3430",
|
||||||
|
"3336",
|
||||||
|
"3338",
|
||||||
|
"3401",
|
||||||
|
"3403",
|
||||||
|
"3407",
|
||||||
|
"3411",
|
||||||
|
"3412",
|
||||||
|
"3413",
|
||||||
|
"3414",
|
||||||
|
"1806",
|
||||||
|
"1811",
|
||||||
|
"1813",
|
||||||
|
"1815",
|
||||||
|
"1816",
|
||||||
|
"1818",
|
||||||
|
"1820",
|
||||||
|
"1822",
|
||||||
|
"1824",
|
||||||
|
"1825",
|
||||||
|
"1827",
|
||||||
|
"1828",
|
||||||
|
"1832",
|
||||||
|
"1833",
|
||||||
|
"15",
|
||||||
|
"50",
|
||||||
|
"18",
|
||||||
|
"55",
|
||||||
|
"56",
|
||||||
|
"03",
|
||||||
|
"0301",
|
||||||
|
"030108",
|
||||||
|
"030109",
|
||||||
|
"030110",
|
||||||
|
"030111",
|
||||||
|
"030112",
|
||||||
|
"030113",
|
||||||
|
"030114",
|
||||||
|
"030115",
|
||||||
|
"31",
|
||||||
|
"3101",
|
||||||
|
"3103",
|
||||||
|
"32",
|
||||||
|
"33",
|
||||||
|
"34",
|
||||||
|
"39",
|
||||||
|
"40",
|
||||||
|
"42",
|
||||||
|
"11",
|
||||||
|
"110301",
|
||||||
|
"110302",
|
||||||
|
"110304",
|
||||||
|
"110305",
|
||||||
|
"46",
|
||||||
|
"4217",
|
||||||
|
"4218",
|
||||||
|
"1834",
|
||||||
|
"1835",
|
||||||
|
"1836",
|
||||||
|
"1837",
|
||||||
|
"1839",
|
||||||
|
"1840",
|
||||||
|
"1841",
|
||||||
|
"1845",
|
||||||
|
"1848",
|
||||||
|
"5031",
|
||||||
|
"5033",
|
||||||
|
"5034",
|
||||||
|
"5035",
|
||||||
|
"5036",
|
||||||
|
"5037",
|
||||||
|
"5038",
|
||||||
|
"5041",
|
||||||
|
"5042",
|
||||||
|
"5044",
|
||||||
|
"5045",
|
||||||
|
"5046",
|
||||||
|
"5047",
|
||||||
|
"5049",
|
||||||
|
"5052",
|
||||||
|
"5053",
|
||||||
|
"5054",
|
||||||
|
"5055",
|
||||||
|
"5056",
|
||||||
|
"5058",
|
||||||
|
"5059",
|
||||||
|
"5060",
|
||||||
|
"5061",
|
||||||
|
"1804",
|
||||||
|
"030101",
|
||||||
|
"030102",
|
||||||
|
"030103",
|
||||||
|
"030104",
|
||||||
|
"3434",
|
||||||
|
"4204",
|
||||||
|
"110306",
|
||||||
|
"110307",
|
||||||
|
"110308",
|
||||||
|
"110309",
|
||||||
|
"460101",
|
||||||
|
"460102",
|
||||||
|
"460103",
|
||||||
|
"460105",
|
||||||
|
"460106",
|
||||||
|
"460107",
|
||||||
|
"4629",
|
||||||
|
"4650",
|
||||||
|
"5028",
|
||||||
|
"1853",
|
||||||
|
"1859",
|
||||||
|
"5614",
|
||||||
|
"5616",
|
||||||
|
"5618",
|
||||||
|
"5620",
|
||||||
|
"5622",
|
||||||
|
"5624",
|
||||||
|
"5626",
|
||||||
|
"5628",
|
||||||
|
"5630",
|
||||||
|
"5632",
|
||||||
|
"5634",
|
||||||
|
"3105",
|
||||||
|
"3107",
|
||||||
|
"4214",
|
||||||
|
"4215",
|
||||||
|
"4216",
|
||||||
|
"4219",
|
||||||
|
"4220",
|
||||||
|
"4221",
|
||||||
|
"4222",
|
||||||
|
"4224",
|
||||||
|
"4225",
|
||||||
|
"4226",
|
||||||
|
"4227",
|
||||||
|
"4228",
|
||||||
|
"1101",
|
||||||
|
"1103",
|
||||||
|
"1106",
|
||||||
|
"1108",
|
||||||
|
"1111",
|
||||||
|
"1112",
|
||||||
|
"1114",
|
||||||
|
"1120",
|
||||||
|
"1121",
|
||||||
|
"1122",
|
||||||
|
"1124",
|
||||||
|
"1127",
|
||||||
|
"1130",
|
||||||
|
"1133",
|
||||||
|
"1134",
|
||||||
|
"1144",
|
||||||
|
"1145",
|
||||||
|
"1146",
|
||||||
|
"1149",
|
||||||
|
"1151",
|
||||||
|
"1160",
|
||||||
|
"4601",
|
||||||
|
"1505",
|
||||||
|
"1506",
|
||||||
|
"1508",
|
||||||
|
"1511",
|
||||||
|
"1514",
|
||||||
|
"1516",
|
||||||
|
"1517",
|
||||||
|
"1520",
|
||||||
|
"1525",
|
||||||
|
"1528",
|
||||||
|
"1531",
|
||||||
|
"1532",
|
||||||
|
"1535",
|
||||||
|
"1539",
|
||||||
|
"1547",
|
||||||
|
"1560",
|
||||||
|
"1563",
|
||||||
|
"1566",
|
||||||
|
"1573",
|
||||||
|
"1577",
|
||||||
|
"1578",
|
||||||
|
"1579",
|
||||||
|
"030107",
|
||||||
|
"3112",
|
||||||
|
"3205",
|
||||||
|
"3330",
|
||||||
|
"3405",
|
||||||
|
"3419",
|
||||||
|
"3437",
|
||||||
|
"3447",
|
||||||
|
"3901",
|
||||||
|
"4212",
|
||||||
|
"110303",
|
||||||
|
"1119",
|
||||||
|
"1135",
|
||||||
|
"460108",
|
||||||
|
"4637",
|
||||||
|
"1515",
|
||||||
|
"1557",
|
||||||
|
"500101",
|
||||||
|
"500102",
|
||||||
|
"500103",
|
||||||
|
"500104",
|
||||||
|
"5057",
|
||||||
|
"1812",
|
||||||
|
"1826",
|
||||||
|
"1838",
|
||||||
|
"5607",
|
||||||
|
"5612",
|
||||||
|
"5636",
|
||||||
|
"3110",
|
||||||
|
"3226",
|
||||||
|
"3314",
|
||||||
|
"3429",
|
||||||
|
"4012",
|
||||||
|
"4030",
|
||||||
|
"4036",
|
||||||
|
"4223",
|
||||||
|
"4627",
|
||||||
|
"1554",
|
||||||
|
"1576",
|
||||||
|
"5007",
|
||||||
|
"5032",
|
||||||
|
"5043",
|
||||||
|
"1860",
|
||||||
|
"5503",
|
||||||
|
"5538",
|
||||||
|
"5610",
|
||||||
|
"3114",
|
||||||
|
"3116",
|
||||||
|
"3118",
|
||||||
|
"3120",
|
||||||
|
"3122",
|
||||||
|
"3124",
|
||||||
|
"3201",
|
||||||
|
"3203",
|
||||||
|
"3207",
|
||||||
|
"3209",
|
||||||
|
"3212",
|
||||||
|
"3214",
|
||||||
|
"3216",
|
||||||
|
"3218",
|
||||||
|
"3220",
|
||||||
|
"3222",
|
||||||
|
"3224",
|
||||||
|
"3228",
|
||||||
|
"3230",
|
||||||
|
"3232",
|
||||||
|
"3234",
|
||||||
|
"3236",
|
||||||
|
"3238",
|
||||||
|
"3240",
|
||||||
|
"3242",
|
||||||
|
"3301",
|
||||||
|
"3303",
|
||||||
|
"3305",
|
||||||
|
"3310",
|
||||||
|
"3312",
|
||||||
|
"3316",
|
||||||
|
"3318",
|
||||||
|
"3320",
|
||||||
|
"3322",
|
||||||
|
"3324",
|
||||||
|
"3326",
|
||||||
|
"3328",
|
||||||
|
"3332",
|
||||||
|
"3334",
|
||||||
|
"3415",
|
||||||
|
"3416",
|
||||||
|
"3417",
|
||||||
|
"3418",
|
||||||
|
"3420",
|
||||||
|
"3421",
|
||||||
|
"3422",
|
||||||
|
"3423",
|
||||||
|
"3424",
|
||||||
|
"3425",
|
||||||
|
"3426",
|
||||||
|
"3427",
|
||||||
|
"3428",
|
||||||
|
"3431",
|
||||||
|
"3432",
|
||||||
|
"3433",
|
||||||
|
"3435",
|
||||||
|
"3438",
|
||||||
|
"3439",
|
||||||
|
"3440",
|
||||||
|
"3441",
|
||||||
|
"3442",
|
||||||
|
"3443",
|
||||||
|
"3446",
|
||||||
|
"3448",
|
||||||
|
"3449",
|
||||||
|
"3450",
|
||||||
|
"3451",
|
||||||
|
"3452",
|
||||||
|
"3453",
|
||||||
|
"3454",
|
||||||
|
"3903",
|
||||||
|
"3905",
|
||||||
|
"3907",
|
||||||
|
"3909",
|
||||||
|
"4001",
|
||||||
|
"4003",
|
||||||
|
"4005",
|
||||||
|
"4010",
|
||||||
|
"4014",
|
||||||
|
"4016",
|
||||||
|
"4018",
|
||||||
|
"4020",
|
||||||
|
"4022",
|
||||||
|
"4024",
|
||||||
|
"4026",
|
||||||
|
"4028",
|
||||||
|
"4032",
|
||||||
|
"4034",
|
||||||
|
"4201",
|
||||||
|
"4202",
|
||||||
|
"4203",
|
||||||
|
"4205",
|
||||||
|
"4206",
|
||||||
|
"4207",
|
||||||
|
"4211",
|
||||||
|
"4213",
|
||||||
|
"4602",
|
||||||
|
"4612",
|
||||||
|
"4613",
|
||||||
|
"4614",
|
||||||
|
"4615",
|
||||||
|
"4616",
|
||||||
|
"4618",
|
||||||
|
"4619",
|
||||||
|
"4620",
|
||||||
|
"4621",
|
||||||
|
"4622",
|
||||||
|
"4623",
|
||||||
|
"4624",
|
||||||
|
"4625",
|
||||||
|
"4626",
|
||||||
|
"4628",
|
||||||
|
"4630",
|
||||||
|
"4631",
|
||||||
|
"4632",
|
||||||
|
"4633",
|
||||||
|
"4634",
|
||||||
|
"4635",
|
||||||
|
"4636",
|
||||||
|
"4638",
|
||||||
|
"4639",
|
||||||
|
"4640",
|
||||||
|
"4641",
|
||||||
|
"4642",
|
||||||
|
"4643",
|
||||||
|
"4644",
|
||||||
|
"4645",
|
||||||
|
"4646",
|
||||||
|
"4648",
|
||||||
|
"4649",
|
||||||
|
"4651",
|
||||||
|
"1580",
|
||||||
|
"5001",
|
||||||
|
"5006",
|
||||||
|
"5014",
|
||||||
|
"5020",
|
||||||
|
"5021",
|
||||||
|
"5022",
|
||||||
|
"5025"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AAR",
|
||||||
|
"filter": "item",
|
||||||
|
"values": [
|
||||||
|
"2002_2002",
|
||||||
|
"2003_2003",
|
||||||
|
"2004_2004",
|
||||||
|
"2005_2005",
|
||||||
|
"2006_2006",
|
||||||
|
"2007_2007",
|
||||||
|
"2008_2008",
|
||||||
|
"2009_2009",
|
||||||
|
"2010_2010",
|
||||||
|
"2011_2011",
|
||||||
|
"2012_2012",
|
||||||
|
"2013_2013",
|
||||||
|
"2014_2014",
|
||||||
|
"2015_2015",
|
||||||
|
"2016_2016",
|
||||||
|
"2017_2017",
|
||||||
|
"2018_2018",
|
||||||
|
"2019_2019",
|
||||||
|
"2020_2020",
|
||||||
|
"2021_2021",
|
||||||
|
"2022_2022",
|
||||||
|
"2023_2023",
|
||||||
|
"2024_2024"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KJONN",
|
||||||
|
"filter": "item",
|
||||||
|
"values": [
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ALDER",
|
||||||
|
"filter": "item",
|
||||||
|
"values": [
|
||||||
|
"0_120"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MEASURE_TYPE",
|
||||||
|
"filter": "item",
|
||||||
|
"values": [
|
||||||
|
"TELLER",
|
||||||
|
"RATE"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response": {
|
||||||
|
"format": "json-stat2",
|
||||||
|
"maxRowCount": 50000
|
||||||
|
}
|
||||||
|
}
|
||||||
93
tests/fixtures/sources.json
vendored
Normal file
93
tests/fixtures/sources.json
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "nokkel",
|
||||||
|
"title": "Folkehelsestatistikk",
|
||||||
|
"description": "Statistikk om befolkning, oppvekst og levekår, miljø, skader, helserelatert atferd og helsetilstand.",
|
||||||
|
"aboutUrl": "https://www.helsedirektoratet.no/forebygging-diagnose-og-behandling/forebygging-og-levevaner/folkehelsestatistikk-og-profiler",
|
||||||
|
"publishedBy": "Helsedirektoratet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ngs",
|
||||||
|
"title": "Mikrobiologisk genomovervåkning",
|
||||||
|
"description": "Data fra helgenomsekvesering og andre genetiske analyser av bakterier og virus utført ved FHIs referanselaboratorier.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/hd/laboratorie-analyser",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mfr",
|
||||||
|
"title": "Medisinsk fødselsregister",
|
||||||
|
"description": "Om svangerskap, fødsler og nyfødte i Norge fra 1967 til i dag.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/op/mfr",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "abr",
|
||||||
|
"title": "Abortregisteret",
|
||||||
|
"description": "Om svangerskapsbrudd i Norge fra 1979 til i dag.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/op/abortregisteret",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sysvak",
|
||||||
|
"title": "Nasjonalt vaksinasjonsregister SYSVAK",
|
||||||
|
"description": "Data for influensa- og koronavaksinasjoner og for Barnevaksinasjonsprogrammet (dekningsstatistikk).",
|
||||||
|
"aboutUrl": "https://www.fhi.no/va/sysvak/",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daar",
|
||||||
|
"title": "Dødsårsakregisteret",
|
||||||
|
"description": "Om dødsfall og dødsårsaker i Norge fra 1951 til i dag.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/op/dodsarsaksregisteret/",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msis",
|
||||||
|
"title": "Meldingssystem for smittsomme sykdommer (MSIS) ",
|
||||||
|
"description": "Oversikt over meldingspliktige sykdommer fra 1977 til i dag.",
|
||||||
|
"aboutUrl": "https://allvis.fhi.no/msis",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lmr",
|
||||||
|
"title": "Legemiddelregisteret",
|
||||||
|
"description": "Her finner du oversikt over legemidler som er utlevert på resept i Norge (fra og med 2004).",
|
||||||
|
"aboutUrl": "https://www.fhi.no/he/legemiddelregisteret/",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gs",
|
||||||
|
"title": "Grossiststatistikk",
|
||||||
|
"description": "Salg av reseptbelagte og reseptfrie legemidler fra grossister til blant annet apotek, institusjoner og dagligvare.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/he/legemiddelbruk/om-den-grossistbaserte-legemiddelfo/#statistikkbanker-med-data-om-legemiddelforbruk",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "npr",
|
||||||
|
"title": "Norsk pasientregister",
|
||||||
|
"description": "Statistikk fra somatiske fagområder, psykisk helsevern og tverrfaglig spesialisert rusbehandling.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/he/npr",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kpr",
|
||||||
|
"title": "Kommunalt pasient- og brukerregister",
|
||||||
|
"description": "Statistikk om bruk av helse- og omsorgstjenester i kommunene.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/he/kpr/statistikk-og-rapporter",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hkr",
|
||||||
|
"title": "Hjerte- og karsykdommer",
|
||||||
|
"description": "Opplysninger fra 2012 til i dag om personer med sykdommer i hjertet og blodårene, og om behandlingen av disse sykdommene.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/is/hjertekar2/",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "skast",
|
||||||
|
"title": "Skadedyrstatistikk",
|
||||||
|
"description": "Skadedyrstatistikken gir oversikt over hvor ofte utvalgte skadedyr bekjempes i Norge. Statistikken bygger på månedlige rapporter fra skadedyrfirmaer og viser utvikling mellom år og sesongvariasjoner.",
|
||||||
|
"aboutUrl": "https://www.fhi.no/sk/skadedyrbekjempelse/statistikk-om-skadedyr",
|
||||||
|
"publishedBy": "Folkehelseinstituttet"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
tests/fixtures/table_185.json
vendored
Normal file
6
tests/fixtures/table_185.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tableId": 185,
|
||||||
|
"title": "Befolkningsvekst",
|
||||||
|
"publishedAt": "2025-10-21T08:56:39.806397Z",
|
||||||
|
"modifiedAt": "2025-10-21T08:56:39.806397Z"
|
||||||
|
}
|
||||||
728
tests/fixtures/tables_nokkel.json
vendored
Normal file
728
tests/fixtures/tables_nokkel.json
vendored
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"tableId": 334,
|
||||||
|
"title": "Antibiotikaresepter",
|
||||||
|
"publishedAt": "2024-11-04T19:48:29.225776Z",
|
||||||
|
"modifiedAt": "2024-11-04T19:48:29.225776Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 364,
|
||||||
|
"title": "Hjerte- og karregisteret_3aarigLFKB",
|
||||||
|
"publishedAt": "2024-10-29T12:57:13.733322Z",
|
||||||
|
"modifiedAt": "2024-10-29T12:57:13.733322Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 365,
|
||||||
|
"title": "Hjerte- og karregisteret_1aarigLHF",
|
||||||
|
"publishedAt": "2024-12-18T12:13:02.962319Z",
|
||||||
|
"modifiedAt": "2025-01-09T06:58:42.47032Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 385,
|
||||||
|
"title": "Legemidler til behandling av type-2 diabetes_3aarigLFK",
|
||||||
|
"publishedAt": "2024-10-29T13:09:17.201712Z",
|
||||||
|
"modifiedAt": "2024-10-29T13:09:17.201712Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 403,
|
||||||
|
"title": "Vedvarende_lavinntekt_kommunegrense",
|
||||||
|
"publishedAt": "2025-04-24T12:16:59.538284Z",
|
||||||
|
"modifiedAt": "2025-04-24T12:16:59.538285Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 601,
|
||||||
|
"title": "Mediebruk_DataTVspill_Ungdata_KH",
|
||||||
|
"publishedAt": "2025-05-21T11:28:47.455265Z",
|
||||||
|
"modifiedAt": "2025-05-21T11:43:20.96496Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 602,
|
||||||
|
"title": "Mediebruk_SOME_Ungdata_KH",
|
||||||
|
"publishedAt": "2025-05-21T11:45:16.001373Z",
|
||||||
|
"modifiedAt": "2025-05-21T11:45:16.001374Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 171,
|
||||||
|
"title": "Befolkningsframskriving",
|
||||||
|
"publishedAt": "2025-02-25T14:42:59.238949Z",
|
||||||
|
"modifiedAt": "2025-07-27T07:07:07.001823Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 358,
|
||||||
|
"title": "Gjeld",
|
||||||
|
"publishedAt": "2025-02-26T11:51:11.007021Z",
|
||||||
|
"modifiedAt": "2025-02-26T11:51:11.007021Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 362,
|
||||||
|
"title": "Grunnskolepoeng_UTDANN",
|
||||||
|
"publishedAt": "2026-01-08T15:38:55.208332Z",
|
||||||
|
"modifiedAt": "2026-01-08T15:38:55.208332Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 377,
|
||||||
|
"title": "Mobbing, 7. og 10. klasse, 3-årige tall",
|
||||||
|
"publishedAt": "2025-03-07T13:27:29.115965Z",
|
||||||
|
"modifiedAt": "2025-03-07T13:27:29.115965Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 336,
|
||||||
|
"title": "Barnehagekvalitet_bemanning",
|
||||||
|
"publishedAt": "2025-04-01T10:00:58.015678Z",
|
||||||
|
"modifiedAt": "2025-04-01T10:00:58.015678Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 355,
|
||||||
|
"title": "Fremtidsoptimisme_Ungdata_KH",
|
||||||
|
"publishedAt": "2025-04-23T08:48:45.229583Z",
|
||||||
|
"modifiedAt": "2025-04-23T08:48:45.229583Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 392,
|
||||||
|
"title": "Stønad_livsopphold",
|
||||||
|
"publishedAt": "2025-05-09T12:50:19.326568Z",
|
||||||
|
"modifiedAt": "2025-05-13T14:39:57.70227Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 664,
|
||||||
|
"title": "Sosialhjelpsmottakere",
|
||||||
|
"publishedAt": "2025-06-06T06:36:12.451102Z",
|
||||||
|
"modifiedAt": "2025-06-06T06:36:12.451103Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 667,
|
||||||
|
"title": "Mediebruk_underhold_ungdata",
|
||||||
|
"publishedAt": "2025-05-22T10:24:12.167179Z",
|
||||||
|
"modifiedAt": "2026-02-11T10:21:53.318047Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 685,
|
||||||
|
"title": "Regneferd_UTDANN_3",
|
||||||
|
"publishedAt": "2026-02-12T14:31:30.453553Z",
|
||||||
|
"modifiedAt": "2026-02-12T14:31:30.453553Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 688,
|
||||||
|
"title": "Forventede funksjonsfriske leveår_7",
|
||||||
|
"publishedAt": "2025-06-23T09:07:30.416427Z",
|
||||||
|
"modifiedAt": "2025-06-23T09:14:39.112096Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 606,
|
||||||
|
"title": "Tannhelse_DMFT=0_MED_DEKNING",
|
||||||
|
"publishedAt": "2025-09-04T14:44:50.755457Z",
|
||||||
|
"modifiedAt": "2025-09-04T14:44:50.755457Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 401,
|
||||||
|
"title": "Valgdeltakelse",
|
||||||
|
"publishedAt": "2025-09-26T06:51:55.50102Z",
|
||||||
|
"modifiedAt": "2025-09-26T06:51:55.50102Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 338,
|
||||||
|
"title": "Befolkningssammensetning_antall_andel",
|
||||||
|
"publishedAt": "2025-10-21T08:50:38.184798Z",
|
||||||
|
"modifiedAt": "2025-10-21T08:50:38.184798Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 185,
|
||||||
|
"title": "Befolkningsvekst",
|
||||||
|
"publishedAt": "2025-10-21T08:56:39.806397Z",
|
||||||
|
"modifiedAt": "2025-10-21T08:56:39.806397Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 367,
|
||||||
|
"title": "Overvekt, kvinner, MFR",
|
||||||
|
"publishedAt": "2025-10-21T09:02:08.952188Z",
|
||||||
|
"modifiedAt": "2025-10-21T09:02:08.952188Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 699,
|
||||||
|
"title": "NPR_1",
|
||||||
|
"publishedAt": "2025-10-21T09:15:04.540704Z",
|
||||||
|
"modifiedAt": "2025-10-21T09:15:04.540704Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 714,
|
||||||
|
"title": "NPR_3",
|
||||||
|
"publishedAt": "2025-10-21T10:06:51.087779Z",
|
||||||
|
"modifiedAt": "2026-01-26T16:14:30.406547Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 752,
|
||||||
|
"title": "Sosialhjelpsmottakere, ettårig",
|
||||||
|
"publishedAt": "2025-12-02T13:27:15.205756Z",
|
||||||
|
"modifiedAt": "2025-12-02T13:27:15.205756Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 369,
|
||||||
|
"title": "KPR_3",
|
||||||
|
"publishedAt": "2025-11-05T12:47:10.174154Z",
|
||||||
|
"modifiedAt": "2026-02-17T07:03:21.906223Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 370,
|
||||||
|
"title": "KPR_1",
|
||||||
|
"publishedAt": "2025-11-05T12:46:34.40957Z",
|
||||||
|
"modifiedAt": "2026-02-17T07:19:49.707508Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 187,
|
||||||
|
"title": "Personer som bor alene",
|
||||||
|
"publishedAt": "2025-11-18T09:20:23.692617Z",
|
||||||
|
"modifiedAt": "2025-12-08T09:10:42.87317Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 181,
|
||||||
|
"title": "Utdanningsnivå",
|
||||||
|
"publishedAt": "2025-11-18T12:35:21.349944Z",
|
||||||
|
"modifiedAt": "2025-11-18T12:35:21.349944Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 511,
|
||||||
|
"title": "Utdanningsforskjeller i forventet levealder_7aarigLF",
|
||||||
|
"publishedAt": "2025-12-02T20:40:11.468561Z",
|
||||||
|
"modifiedAt": "2025-12-03T09:43:16.729127Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 510,
|
||||||
|
"title": "Utdanningsforskjeller i forventet levealder_15aarigLFKB",
|
||||||
|
"publishedAt": "2025-12-02T20:42:56.504929Z",
|
||||||
|
"modifiedAt": "2025-12-03T09:55:11.706007Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 509,
|
||||||
|
"title": "Forventet levealder_årligetall_ettårALDER_UTD",
|
||||||
|
"publishedAt": "2025-12-02T20:56:33.43415Z",
|
||||||
|
"modifiedAt": "2025-12-03T09:35:51.995297Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 507,
|
||||||
|
"title": "Forventet_levealder_TOT_og_utdn_7aarigLF",
|
||||||
|
"publishedAt": "2025-12-02T21:06:52.621887Z",
|
||||||
|
"modifiedAt": "2025-12-03T09:38:09.598497Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 508,
|
||||||
|
"title": "Forventet levealder etter utdn_15aarigLFKB",
|
||||||
|
"publishedAt": "2025-12-02T21:13:26.471895Z",
|
||||||
|
"modifiedAt": "2025-12-03T09:39:34.354203Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 660,
|
||||||
|
"title": "Forventet levealder totalt og etter utdanning, 25-årig",
|
||||||
|
"publishedAt": "2025-12-02T21:17:47.139173Z",
|
||||||
|
"modifiedAt": "2025-12-03T09:41:53.907439Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 186,
|
||||||
|
"title": "Eierstatus",
|
||||||
|
"publishedAt": "2025-12-08T09:24:12.660856Z",
|
||||||
|
"modifiedAt": "2025-12-08T09:24:12.660858Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 359,
|
||||||
|
"title": "Gjennomforing i videregående skole_innvand_3",
|
||||||
|
"publishedAt": "2025-12-15T10:25:56.152534Z",
|
||||||
|
"modifiedAt": "2025-12-15T10:25:56.152534Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 677,
|
||||||
|
"title": "Gjennomforing i videregående skole_innvand_1",
|
||||||
|
"publishedAt": "2025-12-15T10:20:14.864234Z",
|
||||||
|
"modifiedAt": "2025-12-15T10:20:14.864236Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 342,
|
||||||
|
"title": "Dødsårsaker tiårig",
|
||||||
|
"publishedAt": "2026-01-12T09:40:25.065037Z",
|
||||||
|
"modifiedAt": "2026-01-12T09:40:25.065038Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 344,
|
||||||
|
"title": "Selvmord femårig",
|
||||||
|
"publishedAt": "2026-01-12T09:39:58.096599Z",
|
||||||
|
"modifiedAt": "2026-01-12T09:39:58.096601Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 345,
|
||||||
|
"title": "Trafikkulykker, femårige tall",
|
||||||
|
"publishedAt": "2026-01-12T09:39:32.447777Z",
|
||||||
|
"modifiedAt": "2026-01-12T09:39:32.447779Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 343,
|
||||||
|
"title": "Dødsårsaker-nøkkeltall-1990-ettårig",
|
||||||
|
"publishedAt": "2026-01-12T09:40:55.35502Z",
|
||||||
|
"modifiedAt": "2026-01-12T09:40:55.35502Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 623,
|
||||||
|
"title": " Gjennomforing_VGO_utdann_1",
|
||||||
|
"publishedAt": "2026-01-09T11:02:05.09823Z",
|
||||||
|
"modifiedAt": "2026-01-09T11:02:05.098231Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 360,
|
||||||
|
"title": "Gjennomforing i videregående skole_utdann_3",
|
||||||
|
"publishedAt": "2026-01-09T11:02:53.166341Z",
|
||||||
|
"modifiedAt": "2026-01-09T11:02:53.166342Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 361,
|
||||||
|
"title": "Grunnskolepoeng_INNVKAT",
|
||||||
|
"publishedAt": "2026-01-08T15:38:28.022599Z",
|
||||||
|
"modifiedAt": "2026-01-08T15:38:28.022599Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 341,
|
||||||
|
"title": "Drikkevannsforsyning",
|
||||||
|
"publishedAt": "2026-01-12T08:32:55.070565Z",
|
||||||
|
"modifiedAt": "2026-01-12T08:32:55.070566Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 619,
|
||||||
|
"title": "Kreft, nye tilfeller_ettårig_LFKB",
|
||||||
|
"publishedAt": "2026-01-18T13:01:46.594781Z",
|
||||||
|
"modifiedAt": "2026-01-18T13:01:46.594781Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 368,
|
||||||
|
"title": "Kreft, nye tilfeller_10aarigLFKB",
|
||||||
|
"publishedAt": "2026-01-18T13:01:08.097792Z",
|
||||||
|
"modifiedAt": "2026-01-18T13:01:08.097792Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 396,
|
||||||
|
"title": "Vaks_dekning_Influensa",
|
||||||
|
"publishedAt": "2026-01-13T10:35:14.248503Z",
|
||||||
|
"modifiedAt": "2026-01-13T10:51:57.052147Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 394,
|
||||||
|
"title": "Vaksinasjonsdekning_1",
|
||||||
|
"publishedAt": "2026-01-14T14:23:21.353955Z",
|
||||||
|
"modifiedAt": "2026-01-14T14:23:21.353955Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 395,
|
||||||
|
"title": "Vaksinasjonsdekning_5aar",
|
||||||
|
"publishedAt": "2026-01-14T14:28:11.323274Z",
|
||||||
|
"modifiedAt": "2026-01-14T14:28:11.323274Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 795,
|
||||||
|
"title": "Trangbodd_INNVAND",
|
||||||
|
"publishedAt": "2026-01-20T09:35:28.257245Z",
|
||||||
|
"modifiedAt": "2026-01-20T09:35:28.257246Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 388,
|
||||||
|
"title": "Overvekt_verneplikt_4",
|
||||||
|
"publishedAt": "2026-01-16T14:53:42.245577Z",
|
||||||
|
"modifiedAt": "2026-01-16T14:53:42.245577Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 387,
|
||||||
|
"title": "Overvekt_verneplikt_1",
|
||||||
|
"publishedAt": "2026-01-16T14:54:33.539299Z",
|
||||||
|
"modifiedAt": "2026-01-19T13:19:13.71667Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 794,
|
||||||
|
"title": "Trangbodd_UTDANN",
|
||||||
|
"publishedAt": "2026-01-19T11:04:07.466643Z",
|
||||||
|
"modifiedAt": "2026-01-19T11:04:07.466643Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 353,
|
||||||
|
"title": "Trening_forsvaret_sesjon1_1",
|
||||||
|
"publishedAt": "2026-01-20T09:19:15.088638Z",
|
||||||
|
"modifiedAt": "2026-01-20T09:19:15.088638Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 352,
|
||||||
|
"title": "Trening_forsvaret_sesjon1_3",
|
||||||
|
"publishedAt": "2026-01-20T09:18:53.707281Z",
|
||||||
|
"modifiedAt": "2026-01-20T09:18:53.707282Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 332,
|
||||||
|
"title": "Alkohol_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-20T21:37:20.273043Z",
|
||||||
|
"modifiedAt": "2026-01-21T13:13:23.231866Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 339,
|
||||||
|
"title": "Depressive symptomer_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-20T21:41:49.907708Z",
|
||||||
|
"modifiedAt": "2026-01-21T13:16:36.980776Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 348,
|
||||||
|
"title": "Ensomhet_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-20T21:45:04.111339Z",
|
||||||
|
"modifiedAt": "2026-01-21T13:17:10.836688Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 349,
|
||||||
|
"title": "Fornoyd_helsa_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-20T21:47:33.009601Z",
|
||||||
|
"modifiedAt": "2026-01-21T13:17:36.956513Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 354,
|
||||||
|
"title": "FORTROLIGVENN_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-20T21:50:04.241821Z",
|
||||||
|
"modifiedAt": "2026-01-21T13:18:06.442229Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 356,
|
||||||
|
"title": "Fritidsorg_deltak_ungdata",
|
||||||
|
"publishedAt": "2026-01-21T13:20:34.839291Z",
|
||||||
|
"modifiedAt": "2026-01-21T14:32:48.194358Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 357,
|
||||||
|
"title": "Fysisk_inakt_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-21T14:31:57.063384Z",
|
||||||
|
"modifiedAt": "2026-01-21T14:31:57.063385Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 363,
|
||||||
|
"title": "Hasjbruk_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-01-21T14:44:06.197311Z",
|
||||||
|
"modifiedAt": "2026-02-10T14:25:43.456118Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 335,
|
||||||
|
"title": "Barn av sosialhjelpsmottakere",
|
||||||
|
"publishedAt": "2026-02-10T09:44:35.054662Z",
|
||||||
|
"modifiedAt": "2026-02-10T09:44:35.054664Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 800,
|
||||||
|
"title": "Luftforurensning, grenseverdi",
|
||||||
|
"publishedAt": "2026-02-05T07:45:33.161833Z",
|
||||||
|
"modifiedAt": "2026-02-05T07:45:33.161834Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 787,
|
||||||
|
"title": "Uføre_UTDANN_1",
|
||||||
|
"publishedAt": "2026-01-27T14:35:55.553669Z",
|
||||||
|
"modifiedAt": "2026-02-17T13:02:41.116533Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 788,
|
||||||
|
"title": "Uføre_UTDANN_3",
|
||||||
|
"publishedAt": "2026-01-27T14:40:35.018853Z",
|
||||||
|
"modifiedAt": "2026-02-17T13:02:26.012611Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 790,
|
||||||
|
"title": "AAP_UTDANN_1",
|
||||||
|
"publishedAt": "2026-01-27T14:44:59.329574Z",
|
||||||
|
"modifiedAt": "2026-01-27T14:44:59.329574Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 791,
|
||||||
|
"title": "AAP_UTDANN_3",
|
||||||
|
"publishedAt": "2026-01-27T14:47:49.169494Z",
|
||||||
|
"modifiedAt": "2026-01-30T15:54:29.736474Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 803,
|
||||||
|
"title": "Luftforurensning, PWC (ny)",
|
||||||
|
"publishedAt": "2026-02-05T07:45:49.979013Z",
|
||||||
|
"modifiedAt": "2026-02-05T07:45:49.979015Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 397,
|
||||||
|
"title": "TRIVSEL_1",
|
||||||
|
"publishedAt": "2026-02-03T14:50:45.287376Z",
|
||||||
|
"modifiedAt": "2026-02-04T11:35:45.312498Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 378,
|
||||||
|
"title": "Mobbing, 7. og 10. klasse_1",
|
||||||
|
"publishedAt": "2026-02-03T15:08:19.411738Z",
|
||||||
|
"modifiedAt": "2026-02-03T15:08:19.411738Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 805,
|
||||||
|
"title": "TRIVSEL_3",
|
||||||
|
"publishedAt": "2026-02-04T11:35:10.518412Z",
|
||||||
|
"modifiedAt": "2026-02-10T14:13:24.307206Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 806,
|
||||||
|
"title": "MOBBING_3",
|
||||||
|
"publishedAt": "2026-02-04T11:50:48.897641Z",
|
||||||
|
"modifiedAt": "2026-02-04T11:50:48.897642Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 373,
|
||||||
|
"title": "Livskvalitet_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-02-04T12:25:46.313579Z",
|
||||||
|
"modifiedAt": "2026-02-04T12:25:46.313579Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 366,
|
||||||
|
"title": "Inntektsulikhet",
|
||||||
|
"publishedAt": "2026-02-06T09:20:40.904633Z",
|
||||||
|
"modifiedAt": "2026-02-06T09:20:40.904633Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 804,
|
||||||
|
"title": "Sysselsatte_UTDANN_ettårig",
|
||||||
|
"publishedAt": "2026-02-06T10:22:45.587531Z",
|
||||||
|
"modifiedAt": "2026-02-06T10:22:45.587531Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 807,
|
||||||
|
"title": "Sysselsatte_INNVKAT_ettårig",
|
||||||
|
"publishedAt": "2026-02-06T10:22:38.256208Z",
|
||||||
|
"modifiedAt": "2026-02-06T10:22:38.256208Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 175,
|
||||||
|
"title": "Innvandrere og norskfødte med innv.foreldre _LANDBAK",
|
||||||
|
"publishedAt": "2026-02-06T14:57:46.6586Z",
|
||||||
|
"modifiedAt": "2026-02-06T14:57:46.6586Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 374,
|
||||||
|
"title": "Nærmiljø_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-02-09T11:18:59.078553Z",
|
||||||
|
"modifiedAt": "2026-02-09T11:18:59.078555Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 375,
|
||||||
|
"title": "Treffsteder for unge_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-02-09T11:26:27.884848Z",
|
||||||
|
"modifiedAt": "2026-02-09T11:26:27.88485Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 380,
|
||||||
|
"title": "Regelbrudd_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-02-09T12:34:43.499082Z",
|
||||||
|
"modifiedAt": "2026-02-09T12:34:43.499083Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 389,
|
||||||
|
"title": "Skjermtid_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-02-09T12:38:50.920167Z",
|
||||||
|
"modifiedAt": "2026-02-09T12:38:50.920169Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 390,
|
||||||
|
"title": "Smertestillende_ungdata",
|
||||||
|
"publishedAt": "2026-02-09T12:42:44.0123Z",
|
||||||
|
"modifiedAt": "2026-02-09T12:42:44.012302Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 399,
|
||||||
|
"title": "Trygghet_ungdata",
|
||||||
|
"publishedAt": "2026-02-09T12:47:30.435923Z",
|
||||||
|
"modifiedAt": "2026-02-09T12:47:30.435925Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 650,
|
||||||
|
"title": "INNVAND_INNVKAT",
|
||||||
|
"publishedAt": "2026-02-10T09:09:07.520257Z",
|
||||||
|
"modifiedAt": "2026-02-10T09:09:07.520259Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 371,
|
||||||
|
"title": "Leseferdighet_UTDANN_1",
|
||||||
|
"publishedAt": "2026-02-10T10:35:48.263727Z",
|
||||||
|
"modifiedAt": "2026-02-10T11:17:59.825692Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 670,
|
||||||
|
"title": "Leseferdighet_utdann_3",
|
||||||
|
"publishedAt": "2026-02-10T10:42:04.095025Z",
|
||||||
|
"modifiedAt": "2026-02-10T10:42:04.095025Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 689,
|
||||||
|
"title": "Regneferd_INNVKAT_1",
|
||||||
|
"publishedAt": "2026-02-10T11:17:08.620557Z",
|
||||||
|
"modifiedAt": "2026-02-10T11:17:08.620557Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 641,
|
||||||
|
"title": "Regneferd_INNVKAT_3",
|
||||||
|
"publishedAt": "2026-02-10T11:25:22.449729Z",
|
||||||
|
"modifiedAt": "2026-02-10T11:25:22.449729Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 809,
|
||||||
|
"title": "NEET_UTDANN",
|
||||||
|
"publishedAt": "2026-02-10T19:20:19.064Z",
|
||||||
|
"modifiedAt": "2026-02-17T13:02:00.715683Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 810,
|
||||||
|
"title": "Andrenarko_Ungdata",
|
||||||
|
"publishedAt": "2026-03-09T15:21:38.643178Z",
|
||||||
|
"modifiedAt": "2026-03-10T13:02:04.859057Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 811,
|
||||||
|
"title": "Hasjtilbud_Ungdata",
|
||||||
|
"publishedAt": "2026-03-09T15:21:24.178186Z",
|
||||||
|
"modifiedAt": "2026-03-09T15:21:24.178187Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 600,
|
||||||
|
"title": "Kollektivtilbud, ungdom",
|
||||||
|
"publishedAt": "2026-02-10T16:22:04.374466Z",
|
||||||
|
"modifiedAt": "2026-02-11T09:51:32.652556Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 812,
|
||||||
|
"title": "Røyk_Ungdata",
|
||||||
|
"publishedAt": "2026-03-09T15:21:08.614525Z",
|
||||||
|
"modifiedAt": "2026-03-09T15:21:08.614526Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 813,
|
||||||
|
"title": "Skulketskolen_Ungdata",
|
||||||
|
"publishedAt": "2026-03-09T13:40:23.13494Z",
|
||||||
|
"modifiedAt": "2026-03-09T13:40:23.134986Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 814,
|
||||||
|
"title": "Snus_Ungdata",
|
||||||
|
"publishedAt": "2026-03-09T15:20:44.524352Z",
|
||||||
|
"modifiedAt": "2026-03-09T15:20:44.524353Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 815,
|
||||||
|
"title": "Vape_Ungdata",
|
||||||
|
"publishedAt": "2026-03-09T15:20:20.568002Z",
|
||||||
|
"modifiedAt": "2026-03-09T15:20:20.568003Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 607,
|
||||||
|
"title": "Venner_inne_Ungdata",
|
||||||
|
"publishedAt": "2026-02-10T20:23:12.81944Z",
|
||||||
|
"modifiedAt": "2026-02-11T09:52:40.767342Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 379,
|
||||||
|
"title": "NEET_INNVKAT",
|
||||||
|
"publishedAt": "2026-02-10T19:44:15.283028Z",
|
||||||
|
"modifiedAt": "2026-02-10T19:44:15.283028Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 608,
|
||||||
|
"title": "Venner_ute_Ungdata",
|
||||||
|
"publishedAt": "2026-02-10T20:26:35.178965Z",
|
||||||
|
"modifiedAt": "2026-02-11T09:56:08.051697Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 672,
|
||||||
|
"title": "Regneferd_UTDANN_1",
|
||||||
|
"publishedAt": "2026-02-12T14:28:00.419856Z",
|
||||||
|
"modifiedAt": "2026-02-12T14:28:00.419856Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 350,
|
||||||
|
"title": "Forsvaret_Svomming_3",
|
||||||
|
"publishedAt": "2026-02-12T13:26:54.37389Z",
|
||||||
|
"modifiedAt": "2026-02-12T13:26:54.37389Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 671,
|
||||||
|
"title": "Leseferdighet_innvkat_1",
|
||||||
|
"publishedAt": "2026-02-16T12:22:31.91486Z",
|
||||||
|
"modifiedAt": "2026-02-16T12:22:31.91486Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 351,
|
||||||
|
"title": "Forsvaret_Svomming_1",
|
||||||
|
"publishedAt": "2026-02-16T13:58:22.362566Z",
|
||||||
|
"modifiedAt": "2026-02-16T13:58:22.362566Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 372,
|
||||||
|
"title": "Leseferdighet_INNVKAT_3",
|
||||||
|
"publishedAt": "2026-02-17T12:15:42.934827Z",
|
||||||
|
"modifiedAt": "2026-02-17T12:15:42.934827Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 826,
|
||||||
|
"title": "SYKEFRAV_SSB",
|
||||||
|
"publishedAt": "2026-02-23T07:05:48.431171Z",
|
||||||
|
"modifiedAt": "2026-02-23T07:49:54.660396Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 583,
|
||||||
|
"title": "Vedvarende lavinntekt INNVKAT",
|
||||||
|
"publishedAt": "2026-02-23T08:44:50.032799Z",
|
||||||
|
"modifiedAt": "2026-02-23T08:44:50.032808Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 839,
|
||||||
|
"title": "ROYK_STATBANK",
|
||||||
|
"publishedAt": "2026-03-04T12:48:25.930929Z",
|
||||||
|
"modifiedAt": "2026-03-04T12:48:25.93093Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 346,
|
||||||
|
"title": "Barn av enslige forsørgere_3aarigLFKB",
|
||||||
|
"publishedAt": "2026-03-09T08:48:17.960139Z",
|
||||||
|
"modifiedAt": "2026-03-09T08:48:17.960139Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 391,
|
||||||
|
"title": "Søvnproblemer_Ungdata_KH",
|
||||||
|
"publishedAt": "2026-03-11T11:11:19.625366Z",
|
||||||
|
"modifiedAt": "2026-03-11T11:11:19.625366Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 852,
|
||||||
|
"title": "Andrenarko_Ungdata_VGO",
|
||||||
|
"publishedAt": "2026-03-23T10:36:27.084294Z",
|
||||||
|
"modifiedAt": "2026-03-23T10:36:27.084296Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 859,
|
||||||
|
"title": "Hasjbruk_Ungdata_VGO",
|
||||||
|
"publishedAt": "2026-03-23T10:35:53.428246Z",
|
||||||
|
"modifiedAt": "2026-03-23T10:35:53.428247Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 860,
|
||||||
|
"title": "Røyk_Ungdata_VGO",
|
||||||
|
"publishedAt": "2026-03-23T10:39:47.298722Z",
|
||||||
|
"modifiedAt": "2026-03-23T10:39:47.298723Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 862,
|
||||||
|
"title": "Snus_Ungdata_VGO",
|
||||||
|
"publishedAt": "2026-03-23T10:34:30.503178Z",
|
||||||
|
"modifiedAt": "2026-03-23T10:34:30.503179Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 863,
|
||||||
|
"title": "Vape_Ungdata_VGO",
|
||||||
|
"publishedAt": "2026-03-23T10:37:45.600809Z",
|
||||||
|
"modifiedAt": "2026-03-23T10:37:45.600809Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 337,
|
||||||
|
"title": "Barnevern_tiltak",
|
||||||
|
"publishedAt": "2026-03-24T09:13:05.651799Z",
|
||||||
|
"modifiedAt": "2026-03-24T09:13:05.651799Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableId": 393,
|
||||||
|
"title": "Røyking_MFR",
|
||||||
|
"publishedAt": "2026-03-24T09:23:03.575448Z",
|
||||||
|
"modifiedAt": "2026-03-24T09:23:03.575448Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
48
tests/test_cache.py
Normal file
48
tests/test_cache.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Tests for cache module."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fhi_statistikk_mcp.cache import TTLCache
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_and_get():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("key", "value", 60)
|
||||||
|
assert cache.get("key") == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_key():
|
||||||
|
cache = TTLCache()
|
||||||
|
assert cache.get("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_expiry():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("key", "value", 0.1)
|
||||||
|
time.sleep(0.15)
|
||||||
|
assert cache.get("key") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("a", 1, 60)
|
||||||
|
cache.set("b", 2, 60)
|
||||||
|
cache.clear()
|
||||||
|
assert cache.get("a") is None
|
||||||
|
assert cache.get("b") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("key", "old", 60)
|
||||||
|
cache.set("key", "new", 60)
|
||||||
|
assert cache.get("key") == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_ttls():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("short", "value", 0.1)
|
||||||
|
cache.set("long", "value", 60)
|
||||||
|
time.sleep(0.15)
|
||||||
|
assert cache.get("short") is None
|
||||||
|
assert cache.get("long") == "value"
|
||||||
366
tests/test_transformers.py
Normal file
366
tests/test_transformers.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"""Tests for transformers module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fhi_statistikk_mcp.transformers import (
|
||||||
|
complete_query_dimensions,
|
||||||
|
extract_metadata_fields,
|
||||||
|
flatten_categories,
|
||||||
|
is_year_dimension,
|
||||||
|
matches_search,
|
||||||
|
navigate_hierarchy,
|
||||||
|
normalize_for_search,
|
||||||
|
normalize_year_value,
|
||||||
|
parse_csv_to_rows,
|
||||||
|
strip_html,
|
||||||
|
summarize_dimensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- strip_html ---
|
||||||
|
|
||||||
|
def test_strip_html_removes_tags():
|
||||||
|
assert strip_html("<p>Hello <b>world</b></p>") == "Hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_html_preserves_plain_text():
|
||||||
|
assert strip_html("No tags here") == "No tags here"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_html_handles_empty():
|
||||||
|
assert strip_html("") == ""
|
||||||
|
assert strip_html(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_html_handles_links():
|
||||||
|
assert strip_html('<a href="http://example.com">link</a>') == "link"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_html_decodes_entities():
|
||||||
|
assert strip_html("& <b> ") == "& <b>"
|
||||||
|
|
||||||
|
|
||||||
|
# --- normalize_for_search / matches_search ---
|
||||||
|
|
||||||
|
def test_normalize_strips_accents():
|
||||||
|
assert normalize_for_search("Tromsø") == "tromso"
|
||||||
|
assert normalize_for_search("Bærum") == "barum"
|
||||||
|
assert normalize_for_search("Ålesund") == "alesund"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_lowercases():
|
||||||
|
assert normalize_for_search("OSLO") == "oslo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_search_single_word():
|
||||||
|
assert matches_search("Befolkningsvekst", "befolkning")
|
||||||
|
assert not matches_search("Befolkningsvekst", "helse")
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_search_multiple_words():
|
||||||
|
assert matches_search("Befolkningsvekst Oslo", "befolkning oslo")
|
||||||
|
assert not matches_search("Befolkningsvekst", "befolkning oslo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_search_accent_insensitive():
|
||||||
|
assert matches_search("Tromsø kommune", "tromso")
|
||||||
|
assert matches_search("Bærum", "barum")
|
||||||
|
|
||||||
|
|
||||||
|
# --- normalize_year_value ---
|
||||||
|
|
||||||
|
def test_normalize_year_short():
|
||||||
|
assert normalize_year_value("2020") == "2020_2020"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_year_already_full():
|
||||||
|
assert normalize_year_value("2020_2020") == "2020_2020"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_year_non_numeric():
|
||||||
|
assert normalize_year_value("all") == "all"
|
||||||
|
|
||||||
|
|
||||||
|
# --- flatten_categories ---
|
||||||
|
|
||||||
|
NESTED_TREE = [
|
||||||
|
{
|
||||||
|
"value": "0",
|
||||||
|
"label": "Hele landet",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"value": "03",
|
||||||
|
"label": "Oslo (fylke)",
|
||||||
|
"children": [
|
||||||
|
{"value": "0301", "label": "Oslo", "children": []},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "18",
|
||||||
|
"label": "Nordland",
|
||||||
|
"children": [
|
||||||
|
{"value": "1804", "label": "Bodø", "children": []},
|
||||||
|
{"value": "1806", "label": "Narvik", "children": []},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_flatten_categories_count():
|
||||||
|
flat = flatten_categories(NESTED_TREE)
|
||||||
|
assert len(flat) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_flatten_categories_parent_values():
|
||||||
|
flat = flatten_categories(NESTED_TREE)
|
||||||
|
by_value = {c["value"]: c for c in flat}
|
||||||
|
assert by_value["0"]["parent_value"] is None
|
||||||
|
assert by_value["03"]["parent_value"] == "0"
|
||||||
|
assert by_value["0301"]["parent_value"] == "03"
|
||||||
|
assert by_value["1804"]["parent_value"] == "18"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flatten_categories_empty():
|
||||||
|
assert flatten_categories([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- navigate_hierarchy ---
|
||||||
|
|
||||||
|
def test_navigate_top_level():
|
||||||
|
result = navigate_hierarchy(NESTED_TREE)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["value"] == "0"
|
||||||
|
assert result[0]["child_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigate_children():
|
||||||
|
result = navigate_hierarchy(NESTED_TREE, parent_value="18")
|
||||||
|
assert len(result) == 2
|
||||||
|
values = {r["value"] for r in result}
|
||||||
|
assert values == {"1804", "1806"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigate_search():
|
||||||
|
result = navigate_hierarchy(NESTED_TREE, search="bodø")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["value"] == "1804"
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigate_search_accent_insensitive():
|
||||||
|
result = navigate_hierarchy(NESTED_TREE, search="bodo")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["label"] == "Bodø"
|
||||||
|
|
||||||
|
|
||||||
|
# --- summarize_dimensions ---
|
||||||
|
|
||||||
|
def test_summarize_fixed_dimension():
|
||||||
|
dims = [{"code": "KJONN", "label": "Kjønn", "categories": [
|
||||||
|
{"value": "0", "label": "kjønn samlet", "children": []}
|
||||||
|
]}]
|
||||||
|
result = summarize_dimensions(dims)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["is_fixed"] is True
|
||||||
|
assert result[0]["total_categories"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_year_dimension():
|
||||||
|
cats = [{"value": f"{y}_{y}", "label": str(y), "children": []}
|
||||||
|
for y in range(2020, 2025)]
|
||||||
|
dims = [{"code": "AAR", "label": "År", "categories": cats}]
|
||||||
|
result = summarize_dimensions(dims)
|
||||||
|
assert result[0]["value_format"] == "YYYY_YYYY (e.g. 2020_2020)"
|
||||||
|
assert result[0]["range"] == "2020..2024"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_hierarchical_large():
|
||||||
|
children = [{"value": str(i), "label": f"Municipality {i}", "children": []}
|
||||||
|
for i in range(1, 30)]
|
||||||
|
cats = [{"value": "0", "label": "Hele landet", "children": children}]
|
||||||
|
dims = [{"code": "GEO", "label": "Geografi", "categories": cats}]
|
||||||
|
result = summarize_dimensions(dims)
|
||||||
|
assert result[0]["is_hierarchical"] is True
|
||||||
|
assert "top_level_values" in result[0]
|
||||||
|
assert result[0]["top_level_values"][0]["child_count"] == 29
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_small_dimension():
|
||||||
|
cats = [
|
||||||
|
{"value": "TELLER", "label": "antall", "children": []},
|
||||||
|
{"value": "RATE", "label": "prosent", "children": []},
|
||||||
|
]
|
||||||
|
dims = [{"code": "MEASURE_TYPE", "label": "Måltall", "categories": cats}]
|
||||||
|
result = summarize_dimensions(dims)
|
||||||
|
assert len(result[0]["values"]) == 2
|
||||||
|
assert result[0]["values"][0] == {"value": "TELLER", "label": "antall"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- extract_metadata_fields ---
|
||||||
|
|
||||||
|
def test_extract_metadata_dict():
|
||||||
|
meta = {
|
||||||
|
"name": "Test",
|
||||||
|
"isOfficialStatistics": True,
|
||||||
|
"paragraphs": [
|
||||||
|
{"header": "Beskrivelse", "content": "<p>Some description</p>"},
|
||||||
|
{"header": "Oppdateringsfrekvens", "content": "Årlig"},
|
||||||
|
{"header": "Nøkkelord", "content": "Helse,Data"},
|
||||||
|
{"header": "Kildeinstitusjon", "content": "FHI"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fields = extract_metadata_fields(meta)
|
||||||
|
assert fields["is_official_statistics"] is True
|
||||||
|
assert fields["description"] == "Some description"
|
||||||
|
assert fields["update_frequency"] == "Årlig"
|
||||||
|
assert fields["keywords"] == ["Helse", "Data"]
|
||||||
|
assert fields["source_institution"] == "FHI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_metadata_strips_html():
|
||||||
|
meta = {
|
||||||
|
"paragraphs": [
|
||||||
|
{"header": "Beskrivelse", "content": "<p>Text with <a href='#'>link</a></p>"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
fields = extract_metadata_fields(meta)
|
||||||
|
assert fields["description"] == "Text with link"
|
||||||
|
|
||||||
|
|
||||||
|
# --- parse_csv_to_rows ---
|
||||||
|
|
||||||
|
def test_parse_csv_basic():
|
||||||
|
csv_text = '"Col A";"Col B"\n"Oslo";"123"\n"Bergen";"456"\n'
|
||||||
|
result = parse_csv_to_rows(csv_text)
|
||||||
|
assert result["total_rows"] == 2
|
||||||
|
assert result["truncated"] is False
|
||||||
|
assert result["rows"][0]["Col A"] == "Oslo"
|
||||||
|
assert result["rows"][0]["Col B"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_csv_truncation():
|
||||||
|
csv_text = '"X"\n"a"\n"b"\n"c"\n'
|
||||||
|
result = parse_csv_to_rows(csv_text, max_rows=2)
|
||||||
|
assert result["total_rows"] == 3
|
||||||
|
assert result["truncated"] is True
|
||||||
|
assert len(result["rows"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_csv_numeric_conversion():
|
||||||
|
csv_text = '"int";"float";"missing";"text"\n"42";"3.14";"..";"hello"\n'
|
||||||
|
result = parse_csv_to_rows(csv_text)
|
||||||
|
row = result["rows"][0]
|
||||||
|
assert row["int"] == 42
|
||||||
|
assert row["float"] == 3.14
|
||||||
|
assert row["missing"] is None
|
||||||
|
assert row["text"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_csv_comma_decimal():
|
||||||
|
csv_text = '"val"\n"1,5"\n'
|
||||||
|
result = parse_csv_to_rows(csv_text)
|
||||||
|
assert result["rows"][0]["val"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
# --- is_year_dimension ---
|
||||||
|
|
||||||
|
def test_is_year_by_code():
|
||||||
|
assert is_year_dimension("AAR", []) is True
|
||||||
|
assert is_year_dimension("YEAR", []) is True
|
||||||
|
assert is_year_dimension("GEO", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_year_by_value_format():
|
||||||
|
flat = [{"value": "2020_2020", "label": "2020", "parent_value": None}]
|
||||||
|
assert is_year_dimension("CUSTOM", flat) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- complete_query_dimensions ---
|
||||||
|
|
||||||
|
SAMPLE_DIMS = [
|
||||||
|
{"code": "GEO", "label": "Geografi", "categories": NESTED_TREE},
|
||||||
|
{"code": "AAR", "label": "År", "categories": [
|
||||||
|
{"value": "2023_2023", "label": "2023", "children": []},
|
||||||
|
{"value": "2024_2024", "label": "2024", "children": []},
|
||||||
|
]},
|
||||||
|
{"code": "KJONN", "label": "Kjønn", "categories": [
|
||||||
|
{"value": "0", "label": "kjønn samlet", "children": []},
|
||||||
|
]},
|
||||||
|
{"code": "ALDER", "label": "Alder", "categories": [
|
||||||
|
{"value": "0_120", "label": "alle aldre", "children": []},
|
||||||
|
]},
|
||||||
|
{"code": "MEASURE_TYPE", "label": "Måltall", "categories": [
|
||||||
|
{"value": "TELLER", "label": "antall", "children": []},
|
||||||
|
{"value": "RATE", "label": "prosent", "children": []},
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_fixed_auto_included():
|
||||||
|
user_dims = [
|
||||||
|
{"code": "GEO", "filter": "item", "values": ["0301"]},
|
||||||
|
{"code": "AAR", "filter": "bottom", "values": ["1"]},
|
||||||
|
]
|
||||||
|
result = complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
codes = {d["code"] for d in result}
|
||||||
|
assert "KJONN" in codes
|
||||||
|
assert "ALDER" in codes
|
||||||
|
kjonn = next(d for d in result if d["code"] == "KJONN")
|
||||||
|
assert kjonn["values"] == ["0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_measure_type_defaults_to_all():
|
||||||
|
user_dims = [
|
||||||
|
{"code": "GEO", "filter": "item", "values": ["0"]},
|
||||||
|
{"code": "AAR", "filter": "item", "values": ["2024"]},
|
||||||
|
]
|
||||||
|
result = complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
mt = next(d for d in result if d["code"] == "MEASURE_TYPE")
|
||||||
|
assert mt["filter"] == "all"
|
||||||
|
assert mt["values"] == ["*"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_year_normalization():
|
||||||
|
user_dims = [
|
||||||
|
{"code": "GEO", "filter": "item", "values": ["0"]},
|
||||||
|
{"code": "AAR", "filter": "item", "values": ["2024"]},
|
||||||
|
]
|
||||||
|
result = complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
aar = next(d for d in result if d["code"] == "AAR")
|
||||||
|
assert aar["values"] == ["2024_2024"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_missing_required_raises():
|
||||||
|
user_dims = [
|
||||||
|
{"code": "AAR", "filter": "item", "values": ["2024"]},
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="Missing required dimensions.*GEO"):
|
||||||
|
complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_missing_code_key_raises():
|
||||||
|
user_dims = [{"filter": "item", "values": ["0"]}]
|
||||||
|
with pytest.raises(ValueError, match="missing 'code' key"):
|
||||||
|
complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_case_insensitive():
|
||||||
|
user_dims = [
|
||||||
|
{"code": "geo", "filter": "item", "values": ["0"]},
|
||||||
|
{"code": "aar", "filter": "item", "values": ["2024"]},
|
||||||
|
]
|
||||||
|
result = complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
codes = [d["code"] for d in result]
|
||||||
|
assert "GEO" in codes
|
||||||
|
assert "AAR" in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_dims_no_year_normalization_for_top_filter():
|
||||||
|
user_dims = [
|
||||||
|
{"code": "GEO", "filter": "item", "values": ["0"]},
|
||||||
|
{"code": "AAR", "filter": "top", "values": ["3"]},
|
||||||
|
]
|
||||||
|
result = complete_query_dimensions(SAMPLE_DIMS, user_dims)
|
||||||
|
aar = next(d for d in result if d["code"] == "AAR")
|
||||||
|
assert aar["values"] == ["3"] # not "3_3"
|
||||||
Reference in New Issue
Block a user