Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
name: Tests

on:
pull_request:

jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit tests
run: pytest -v -m "not integration" --tb=short

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: pip install -r requirements.txt

- name: Load test data into Redis
run: |
python -c "
import asyncio
from node_normalizer.loader import NodeLoader
loader = NodeLoader('tests/data/config.json')
asyncio.run(loader.load(100_000))
"

- name: Run integration tests
run: pytest -v -m "integration" --tb=short
10 changes: 10 additions & 0 deletions docker-compose-redis.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
13 changes: 7 additions & 6 deletions node_normalizer/loader.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
from pathlib import Path
from itertools import islice
from datetime import datetime
from typing import Dict, Any
from typing import Dict, Any, Optional
import json
import hashlib
from itertools import combinations
Expand All@@ -26,8 +26,8 @@ class NodeLoader:
a redis database.
"""

def __init__(self):
self._config = self.get_config()
def __init__(self, config_file: Optional[Path] = None):
self._config = self.get_config(config_file)

self._compendium_directory: Path = Path(self._config["compendium_directory"])
self._conflation_directory: Path = Path(self._config["conflation_directory"])
Expand DownExpand Up@@ -58,11 +58,12 @@ def get_ancestors(self, input_type):
return ancs

@staticmethod
def get_config() -> Dict[str, Any]:
def get_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
"""get configuration file"""
cname = Path(__file__).parents[1] / "config.json"
if config_file is None:
config_file = Path(__file__).parents[1] / "config.json"

with open(cname, "r") as json_file:
with open(config_file, "r") as json_file:
data = json.load(json_file)

return data
Expand Down
15 changes: 14 additions & 1 deletion pytest.ini
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,17 @@ log_cli=false
log_cli_level=DEBUG
log_file=tests.log
log_file_level=DEBUG
asyncio_mode=auto
asyncio_mode=auto

# Custom markers
markers =
integration: marks tests that require real Redis (run with: pytest -m integration)
callback_integration: marks tests requiring the callback-app Docker container

# WARNING: running plain `pytest` without a -m filter may fail or produce
# unpredictable results. test_norm.py and test_setid.py assign directly to
# app.state.* at module-import time (before any fixture runs), which permanently
# mutates the shared FastAPI app singleton. Use explicit marker filters instead:
#
# pytest -m "not integration" — unit tests only (no Redis required)
# pytest -m "integration" — integration tests only (requires Redis + data)
109 changes: 51 additions & 58 deletions requirements.lock
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,79 @@
aioredis==1.3.1
annotated-types==0.7.0
anyio==3.7.1
asgiref==3.8.1
asgiref==3.7.2
async-timeout==4.0.3
attrs==23.2.0
bmt==1.4.3
bmt-lite-v3.1.0==2.2.2
bmt-lite-v3.6.0==2.3.0
certifi==2024.6.2
charset-normalizer==3.3.2
click==8.1.7
coverage==7.5.3
attrs==23.1.0
bmt==1.4.6
certifi==2023.7.22
charset-normalizer==2.1.1
click==8.3.1
coverage==7.3.2
curies==0.7.10
deepdiff==5.8.1
Deprecated==1.2.14
Deprecated==1.3.1
deprecation==2.1.0
docker==7.1.0
fastapi==0.108.0
googleapis-common-protos==1.59.1
grpcio==1.64.1
gunicorn==22.0.0
docker==6.1.3
fastapi==0.83.0
googleapis-common-protos==1.72.0
grpcio==1.78.0
gunicorn==20.1.0
h11==0.12.0
hbreader==0.9.1
hiredis==2.3.2
httpcore==0.15.0
httptools==0.6.1
httpx==0.23.0
idna==3.7
importlib-metadata==6.11.0
hiredis==2.2.3
httpcore==0.13.7
httptools==0.5.0
httpx==0.19.0
idna==3.4
importlib_metadata==8.7.1
iniconfig==2.0.0
isodate==0.6.1
json-flattener==0.1.9
jsonasobj2==1.0.4
jsonschema==4.6.2
linkml-runtime==1.8.0
opentelemetry-api==1.21.0
opentelemetry-exporter-jaeger==1.21.0
opentelemetry-exporter-jaeger-proto-grpc==1.21.0
opentelemetry-exporter-jaeger-thrift==1.21.0
opentelemetry-instrumentation==0.42b0
opentelemetry-instrumentation-asgi==0.42b0
opentelemetry-instrumentation-fastapi==0.42b0
opentelemetry-instrumentation-httpx==0.42b0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
opentelemetry-util-http==0.42b0
linkml-runtime==1.10.0
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-grpc==1.39.1
opentelemetry-instrumentation==0.60b1
opentelemetry-instrumentation-asgi==0.60b1
opentelemetry-instrumentation-fastapi==0.60b1
opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
opentelemetry-util-http==0.60b1
ordered-set==4.1.0
orjson==3.9.15
packaging==24.1
pluggy==1.5.0
orjson==3.8.10
packaging==23.2
pluggy==1.3.0
prefixcommons==0.1.12
prefixmaps==0.2.4
protobuf==4.25.3
prefixmaps==0.2.6
protobuf==6.33.5
py==1.11.0
pydantic==1.10.16
pydantic_core==2.18.4
pyparsing==3.1.2
pyrsistent==0.20.0
pydantic==1.10.13
pyparsing==3.3.2
pyrsistent==0.19.3
pytest==6.2.5
pytest-asyncio==0.18.3
pytest-cov==3.0.0
pytest-logging==2015.11.4
PyTrie==0.4.0
PyYAML==6.0.1
rdflib==7.0.0
reasoner-pydantic==4.1.5
PyYAML==6.0
rdflib==7.6.0
reasoner-pydantic==4.0.8
redis==3.5.3
redis-py-cluster==2.1.3
requests==2.31.0
requests==2.28.1
rfc3986==1.5.0
setuptools==70.1.0
six==1.16.0
sniffio==1.3.1
sniffio==1.3.0
sortedcontainers==2.4.0
starlette==0.32.0.post1
starlette==0.19.1
stringcase==1.2.0
testcontainers==3.6.1
thrift==0.20.0
toml==0.10.2
typing_extensions==4.12.2
urllib3==2.2.2
typing_extensions==4.15.0
urllib3==1.26.17
uvicorn==0.17.6
uvloop==0.19.0
wrapt==1.16.0
zipp==3.19.2
uvloop==0.17.0
websocket-client==1.6.4
wrapt==1.15.0
zipp==3.23.0
44 changes: 23 additions & 21 deletions requirements.txt
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
aioredis~=1.3.1
aioredis<2
click
bmt==1.4.3
deepdiff==8.6.1
fastapi~=0.108.0
bmt
deepdiff
anyio<4
fastapi==0.83.0
httptools
jsonschema~=4.6.0
pytest==6.2.5
pytest-cov==3.0.0
pytest-asyncio==0.18.3
pyyaml~=6.0
reasoner-pydantic==4.1.5
redis~=3.5.3
redis-py-cluster==2.1.3
requests==2.32.4
testcontainers==3.6.1
jsonschema
pytest
pytest-cov
pytest-asyncio
pyyaml
pydantic<2
reasoner-pydantic<5
redis
redis-py-cluster
requests
testcontainers
uvicorn
uvloop
gunicorn==23.0.0
orjson==3.9.15
httpx==0.23.0
gunicorn
orjson
httpx

# To support Open Telemetry
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-grpc==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-instrumentation-httpx==0.48b0
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-grpc
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-httpx
30 changes: 30 additions & 0 deletions tests/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
# Test Suite Notes

## Running tests

```bash
pytest -m "not integration" # unit tests — no Redis needed
pytest -m "integration" # integration tests — requires Redis on localhost:6379
```

Start Redis locally with:
```bash
docker compose -f docker-compose-redis.yml up -d
```

---

## Tests that do not currently pass

### `tests/test_loader.py::test_nn_load`

**Status:** Passes, but emits a `RuntimeWarning: coroutine 'NodeLoader.load_compendium' was never awaited`.

**Root cause:** `load_compendium` is an `async` method but `test_nn_load` calls it
synchronously: `assert node_loader.load_compendium(good_json, 5)`. This passes today
only because the return value of an unawaited coroutine is truthy. The actual loading
logic never executes.

**Estimated fix:** Refactor the test to `await` the call, or add a synchronous wrapper.
Low effort (~5 minutes) but requires understanding whether the test was intentionally
bypassing the async path via `_test_mode = 1`.
Loading