Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70a867e4a1 | ||
| 16b1c131a4 | |||
| 809ce9bdd9 |
@@ -13,4 +13,3 @@ dist/
|
|||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.env
|
||||||
*.sock
|
*.sock
|
||||||
.venv
|
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
fetch-tags: true # Fetch all tags (refs/tags)
|
fetch-tags: true # Fetch all tags (refs/tags)
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
run: uv python install
|
run: uv python install
|
||||||
with:
|
with:
|
||||||
@@ -75,18 +75,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git config user.name "Gitea CI"
|
git config user.name "Gitea CI"
|
||||||
git config user.email "ci@git.theprivateserver.de"
|
git config user.email "ci@git.theprivateserver.de"
|
||||||
|
|
||||||
- name: Determine branch to push to
|
|
||||||
id: push_branch
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
||||||
# workflow_dispatch runs on a branch ref like refs/heads/main
|
|
||||||
BRANCH=${GITHUB_REF#refs/heads/}
|
|
||||||
else
|
|
||||||
# for a merged PR use the PR base ref (target branch)
|
|
||||||
BRANCH=${{ github.event.pull_request.base.ref }}
|
|
||||||
fi
|
|
||||||
echo "PUSH_BRANCH=$BRANCH" >> $GITHUB_ENV
|
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
id: bump
|
id: bump
|
||||||
run: |
|
run: |
|
||||||
@@ -145,31 +133,7 @@ jobs:
|
|||||||
uses: ad-m/github-push-action@master
|
uses: ad-m/github-push-action@master
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
branch: main
|
branch: ${{ github.ref }}
|
||||||
- name: Update `dev` branch with new version
|
|
||||||
run: |
|
|
||||||
# ensure we have the latest remote refs
|
|
||||||
git fetch origin dev || true
|
|
||||||
|
|
||||||
# switch to dev if it exists remotely, otherwise create it
|
|
||||||
if git rev-parse --verify origin/dev >/dev/null 2>&1; then
|
|
||||||
git checkout dev
|
|
||||||
git pull origin dev
|
|
||||||
else
|
|
||||||
git checkout -b dev
|
|
||||||
fi
|
|
||||||
|
|
||||||
# replace the version line inside pyproject.toml
|
|
||||||
sed -E -i "s/^(version\s*=\s*)\".*\"/\1\"${{ env.VERSION }}\"/" pyproject.toml || true
|
|
||||||
|
|
||||||
git add pyproject.toml || true
|
|
||||||
git commit -m "chore: bump pyproject version for dev -> v${{ env.VERSION }}" || echo "no changes to commit"
|
|
||||||
|
|
||||||
- name: Push dev changes
|
|
||||||
uses: ad-m/github-push-action@master
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
branch: dev
|
|
||||||
- name: Build Changelog
|
- name: Build Changelog
|
||||||
id: build_changelog
|
id: build_changelog
|
||||||
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
|
uses: https://github.com/mikepenz/release-changelog-builder-action@v5
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
name: PR tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, edited, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
|
|
||||||
- name: (optional) Prepare dependencies
|
|
||||||
run: |
|
|
||||||
uv python install --python-version-file pyproject.toml || true
|
|
||||||
uv sync --all-groups || true
|
|
||||||
uv add pip || true
|
|
||||||
uv export --format requirements.txt -o requirements.txt || true
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
run: |
|
|
||||||
docker build -t semapform-api:test-pr .
|
|
||||||
|
|
||||||
- name: Save image artifact
|
|
||||||
run: |
|
|
||||||
docker save semapform-api:test-pr -o semapform-api.tar
|
|
||||||
|
|
||||||
- name: Upload image artifact
|
|
||||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: semapform-image
|
|
||||||
path: semapform-api.tar
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
smoke-tests:
|
|
||||||
needs: build-image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download image artifact
|
|
||||||
uses: christopherhx/gitea-download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: semapform-image
|
|
||||||
|
|
||||||
- name: Restore image
|
|
||||||
run: |
|
|
||||||
docker load -i semapform-api.tar
|
|
||||||
|
|
||||||
- name: Start container (background)
|
|
||||||
run: |
|
|
||||||
docker run -d --name semapform-test semapform-api:test-pr sleep infinity
|
|
||||||
|
|
||||||
- name: Start server in container and smoke test HTTP (in-container)
|
|
||||||
run: |
|
|
||||||
set -x
|
|
||||||
# start the server inside the container (detached)
|
|
||||||
docker exec -d semapform-test python api_service.py || true
|
|
||||||
|
|
||||||
# show container status to aid debugging
|
|
||||||
docker ps -a --filter name=semapform-test || true
|
|
||||||
|
|
||||||
echo "waiting for service to become ready inside container"
|
|
||||||
set -e
|
|
||||||
READY=0
|
|
||||||
for i in $(seq 1 20); do
|
|
||||||
echo "ready attempt $i"
|
|
||||||
if docker exec semapform-test python -c 'import urllib.request,sys; urllib.request.urlopen("http://127.0.0.1:8001/health", timeout=1); print("ok")' ; then
|
|
||||||
READY=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if [ "$READY" -ne 1 ]; then
|
|
||||||
echo "service did not become ready"
|
|
||||||
docker logs semapform-test --tail 200 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "running test_api.py inside container"
|
|
||||||
docker exec semapform-test python test_api.py || true
|
|
||||||
|
|
||||||
docker logs semapform-test --tail 200 || true
|
|
||||||
|
|
||||||
- name: Cleanup container
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker rm -f semapform-test || true
|
|
||||||
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"cSpell.words": [
|
|
||||||
"buildx",
|
|
||||||
"Buildx",
|
|
||||||
"elif",
|
|
||||||
"gitea",
|
|
||||||
"Gitea",
|
|
||||||
"github",
|
|
||||||
"linux",
|
|
||||||
"pyproject",
|
|
||||||
"semapform"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
44
Dockerfile
44
Dockerfile
@@ -1,13 +1,43 @@
|
|||||||
FROM python:3.13.9-slim
|
FROM python:3.13-slim AS builder
|
||||||
|
|
||||||
RUN apt update
|
# Prevent Python from writing .pyc files and enable unbuffered stdout/stderr
|
||||||
RUN apt upgrade -y
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
# Install build deps required to build wheels
|
||||||
RUN pip install --no-cache-dir -r requirements.txt --extra-index-url https://git.theprivateserver.de/api/packages/PHB/pypi/simple/
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY . .
|
# Upgrade packaging tools and install runtime dependencies into a separate prefix
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --upgrade pip setuptools wheel \
|
||||||
|
&& pip install --prefix=/install --no-cache-dir \
|
||||||
|
--extra-index-url https://git.theprivateserver.de/api/packages/PHB/pypi/simple/ \
|
||||||
|
-r requirements.txt \
|
||||||
|
&& rm -rf /root/.cache/pip
|
||||||
|
|
||||||
ENTRYPOINT [ "python", "api_service.py" ]
|
# Copy application source (only used to include app files in final image)
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
API_PORT=8001
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy installed packages from the builder image into the final image
|
||||||
|
COPY --from=builder /install /usr/local
|
||||||
|
# Copy application sources
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Run using uvicorn; the app is defined in `api_service.py` as `app`
|
||||||
|
CMD ["uvicorn", "api_service:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
|
|||||||
208
api_service.py
208
api_service.py
@@ -1,91 +1,17 @@
|
|||||||
"""Lightweight Python API service for signature validation.
|
"""
|
||||||
|
Lightweight Python API service for signature validation
|
||||||
This can run independently to support the PHP application.
|
This can run independently to support the PHP application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
from bibapi import catalogue
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from fastapi import FastAPI, Query
|
||||||
|
|
||||||
# Avoid importing heavy modules at top-level to keep `import api_service` lightweight
|
|
||||||
from fastapi import FastAPI, Query, Request, Response
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL", str(72 * 3600)))
|
app = FastAPI(title="Signature Validation API")
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
|
||||||
redis_client = None
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
||||||
"""Lifespan handler: connect to Redis on startup and close on shutdown."""
|
|
||||||
global redis_client # type: ignore[PLW0603]
|
|
||||||
if REDIS_URL:
|
|
||||||
try:
|
|
||||||
import redis.asyncio as aioredis
|
|
||||||
|
|
||||||
redis_client = aioredis.from_url(REDIS_URL)
|
|
||||||
try:
|
|
||||||
pong = redis_client.ping()
|
|
||||||
if asyncio.iscoroutine(pong) or asyncio.isfuture(pong):
|
|
||||||
pong = await pong
|
|
||||||
if not pong:
|
|
||||||
logging.exception("redis ping failed")
|
|
||||||
redis_client = None
|
|
||||||
except Exception:
|
|
||||||
logging.exception("redis ping failed")
|
|
||||||
redis_client = None
|
|
||||||
except Exception:
|
|
||||||
logging.exception("failed to create redis client")
|
|
||||||
redis_client = None
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
if redis_client is not None:
|
|
||||||
try:
|
|
||||||
await redis_client.close()
|
|
||||||
except Exception:
|
|
||||||
logging.exception("failed to close redis client")
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Signature Validation API", lifespan=_lifespan)
|
|
||||||
|
|
||||||
# Optional path prefix support: when behind a reverse-proxy that uses a
|
|
||||||
# URL prefix (eg. `https://api.example.tld/library/...`) set `API_PREFIX` to
|
|
||||||
# that prefix (example: `/library`) so incoming requests are rewritten to the
|
|
||||||
# application root. This keeps route definitions unchanged while supporting
|
|
||||||
# both proxied and direct deployments.
|
|
||||||
_api_prefix_raw = os.getenv("API_PREFIX", "").strip()
|
|
||||||
api_prefix = ""
|
|
||||||
if _api_prefix_raw:
|
|
||||||
if not _api_prefix_raw.startswith("/"):
|
|
||||||
_api_prefix_raw = "/" + _api_prefix_raw
|
|
||||||
api_prefix = _api_prefix_raw.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def _strip_api_prefix(
|
|
||||||
request: Request,
|
|
||||||
call_next: Callable[[Request], Awaitable[Response]],
|
|
||||||
) -> Response:
|
|
||||||
if api_prefix and request.url.path.startswith(api_prefix):
|
|
||||||
new_path = request.url.path[len(api_prefix) :]
|
|
||||||
request.scope["path"] = new_path or "/"
|
|
||||||
request.scope["root_path"] = api_prefix
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
|
|
||||||
# Allow PHP application to call this API
|
# Allow PHP application to call this API
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -96,82 +22,15 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Catalogue is expensive to initialize at import time; instantiate lazily
|
# Initialize catalogue for signature validation
|
||||||
cat = None
|
cat = catalogue.Catalogue()
|
||||||
|
|
||||||
|
|
||||||
def _get_catalogue() -> Any:
|
|
||||||
global cat
|
|
||||||
if cat is None:
|
|
||||||
# import inside function to avoid expensive work during module import
|
|
||||||
from bibapi import catalogue as _catalogue
|
|
||||||
|
|
||||||
cat = _catalogue.Catalogue()
|
|
||||||
return cat
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Caching support ----------------------------------------------
|
|
||||||
# Uses an async Redis client when `REDIS_URL` is set, otherwise falls
|
|
||||||
# back to a small in-memory store with TTL. Cache TTL defaults to 72h.
|
|
||||||
CacheValue = dict[str, Any]
|
|
||||||
|
|
||||||
_in_memory_cache: dict[str, tuple[float, CacheValue]] = {}
|
|
||||||
_in_memory_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
async def _cache_get(key: str) -> CacheValue | None:
|
|
||||||
if redis_client:
|
|
||||||
try:
|
|
||||||
val = await redis_client.get(key)
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
return json.loads(val)
|
|
||||||
except Exception:
|
|
||||||
logging.exception("redis get failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# fallback in-memory
|
|
||||||
async with _in_memory_lock:
|
|
||||||
entry = _in_memory_cache.get(key)
|
|
||||||
if not entry:
|
|
||||||
return None
|
|
||||||
expires_at, value = entry
|
|
||||||
if time.time() >= expires_at:
|
|
||||||
del _in_memory_cache[key]
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
async def _cache_set(key: str, value: CacheValue, ttl: int = CACHE_TTL_SECONDS) -> None:
|
|
||||||
if redis_client:
|
|
||||||
try:
|
|
||||||
await redis_client.set(key, json.dumps(value), ex=ttl)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
logging.exception("redis set failed")
|
|
||||||
|
|
||||||
async with _in_memory_lock:
|
|
||||||
_in_memory_cache[key] = (time.time() + ttl, value)
|
|
||||||
|
|
||||||
|
|
||||||
# Redis lifecycle is handled by the lifespan context manager defined earlier
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/validate-signature")
|
@app.get("/api/validate-signature")
|
||||||
async def validate_signature(signature: str = Query(...)) -> JSONResponse:
|
async def validate_signature(signature: str = Query(...)):
|
||||||
"""Validate a book signature and return total pages."""
|
"""Validate a book signature and return total pages"""
|
||||||
# check cache first
|
|
||||||
# ensure signature is stripped of leading/trailing whitespace
|
|
||||||
signature = signature.strip()
|
|
||||||
# enforce url quotes
|
|
||||||
signature = urllib.parse.quote(signature)
|
|
||||||
cache_key = f"signature:{signature}"
|
|
||||||
cached = await _cache_get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return JSONResponse(cached)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
book_result = _get_catalogue().get_book_with_data(signature)
|
book_result = cat.get_book_with_data(signature)
|
||||||
if book_result and hasattr(book_result, "pages") and book_result.pages:
|
if book_result and hasattr(book_result, "pages") and book_result.pages:
|
||||||
# Try to extract numeric page count
|
# Try to extract numeric page count
|
||||||
pages_str = str(book_result.pages)
|
pages_str = str(book_result.pages)
|
||||||
@@ -179,36 +38,31 @@ async def validate_signature(signature: str = Query(...)) -> JSONResponse:
|
|||||||
match = re.search(r"(\d+)", pages_str)
|
match = re.search(r"(\d+)", pages_str)
|
||||||
if match:
|
if match:
|
||||||
total_pages = int(match.group(1))
|
total_pages = int(match.group(1))
|
||||||
result: CacheValue = {
|
return JSONResponse(
|
||||||
"valid": True,
|
{"valid": True, "total_pages": total_pages, "signature": signature}
|
||||||
"total_pages": total_pages,
|
)
|
||||||
"signature": signature,
|
|
||||||
}
|
|
||||||
await _cache_set(cache_key, result)
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
result: CacheValue = {
|
return JSONResponse(
|
||||||
"valid": False,
|
{
|
||||||
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
|
"valid": False,
|
||||||
"signature": signature,
|
"error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar",
|
||||||
}
|
"signature": signature,
|
||||||
await _cache_set(cache_key, result)
|
}
|
||||||
return JSONResponse(result)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception("validate_signature failure")
|
return JSONResponse(
|
||||||
result: CacheValue = {
|
{
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"error": f"Fehler bei der Validierung: {e!s}",
|
"error": f"Fehler bei der Validierung: {str(e)}",
|
||||||
"signature": signature,
|
"signature": signature,
|
||||||
}
|
},
|
||||||
# store a failed response in cache as well so we avoid replaying errors
|
status_code=500,
|
||||||
await _cache_set(cache_key, result)
|
)
|
||||||
return JSONResponse(result, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check() -> dict[str, str]:
|
async def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint"""
|
||||||
return {"status": "ok", "service": "signature-validation"}
|
return {"status": "ok", "service": "signature-validation"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "semapform-api"
|
name = "semapform-api"
|
||||||
version = "0.2.0"
|
version = "0.1.1"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bibapi[catalogue]>=0.0.6",
|
"bibapi[catalogue]>=0.0.5",
|
||||||
"fastapi>=0.122.0",
|
"fastapi>=0.122.0",
|
||||||
"uvicorn>=0.38.0",
|
"pip>=25.3",
|
||||||
"redis>=4.6.0",
|
|
||||||
]
|
]
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "gitea"
|
name = "gitea"
|
||||||
url = "https://git.theprivateserver.de/api/packages/PHB/pypi/simple/"
|
url = "https://git.theprivateserver.de/api/packages/PHB/pypi/simple/"
|
||||||
|
|
||||||
[tool.bumpversion]
|
[tool.bumpversion]
|
||||||
current_version = "0.1.3"
|
current_version = "0.1.1"
|
||||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||||
serialize = ["{major}.{minor}.{patch}"]
|
serialize = ["{major}.{minor}.{patch}"]
|
||||||
search = "{current_version}"
|
search = "{current_version}"
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# test api endpoint with a signature, print the response
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
response = urllib.request.urlopen(
|
|
||||||
"http://localhost:8001/api/validate-signature?signature=ST%20250%20U42%20%2815%29",
|
|
||||||
)
|
|
||||||
|
|
||||||
print(response.read().decode("utf-8"))
|
|
||||||
Reference in New Issue
Block a user