Skip to content

Commit

Permalink
Merge branch 'main' into fix/117-install-assets
Browse files Browse the repository at this point in the history
  • Loading branch information
ericbuckley committed Nov 6, 2024
2 parents 15c7636 + 3e83d55 commit e3e8d6b
Show file tree
Hide file tree
Showing 16 changed files with 175 additions and 78 deletions.
35 changes: 32 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ name: "release"
# - creation of a draft release
# 2. Pushing a version tag:
# - building and pushing of a new Docker image to ghcr.io
# - building and uploading of the public documentation
# - creation of a published release

on:
Expand All @@ -20,16 +21,14 @@ jobs:
contents: "write"
id-token: "write"
packages: "write"
pages: "write"

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for tags

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Next tag
run: |
# Get the tag that triggered the workflow
Expand All @@ -44,6 +43,10 @@ jobs:
echo "Next tag: $next_tag"
echo "NEXT_TAG=$next_tag" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
if: startsWith(github.ref, 'refs/tags/v')

- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
if: startsWith(github.ref, 'refs/tags/v')
Expand All @@ -68,6 +71,32 @@ jobs:
ghcr.io/${{ env.PACKAGE_NAME }}:latest
ghcr.io/${{ env.PACKAGE_NAME }}:${{ env.NEXT_TAG }}
- name: Set up Python
uses: actions/setup-python@v5
if: startsWith(github.ref, 'refs/tags/v')
with:
python-version: '3.11'
cache: 'pip'

- name: Build public documentation
if: startsWith(github.ref, 'refs/tags/v')
run: |
python -m pip install --upgrade pip
pip install '.[dev]'
export INITIAL_ALGORITHMS=""
export VERSION=${{ env.NEXT_TAG }}
./scripts/build_docs.sh _site
- name: Upload public documentation
uses: actions/upload-pages-artifact@v3
if: startsWith(github.ref, 'refs/tags/v')
with:
path: _site/

- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
if: startsWith(github.ref, 'refs/tags/v')

- name: Optionally delete the existing draft release
run: |
# Get existing draft release (if any)
Expand Down
4 changes: 2 additions & 2 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
site_name: "Record Linker Documentation"
site_name: !ENV [SITE_NAME, 'RecordLinker Documentation']
theme:
name: "material"
icon:
Expand All @@ -24,7 +24,6 @@ repo_name: CDCgov/RecordLinker
repo_url: https://github.com/CDCgov/RecordLinker
edit_uri: edit/main/docs/
docs_dir: "site"
site_dir: "../_site"
nav:
- "Home": "index.md"
- "Getting Started":
Expand All @@ -33,3 +32,4 @@ nav:
- "User Guide":
- Design: "design.md"
- Reference: "reference.md"
- "API Docs": "api-docs.html"
13 changes: 7 additions & 6 deletions docs/site/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ patient data and used during query retrieval. The following blocking key types a
These are the functions that can be used to evaluate the matching results as a collection, thus
determining it the incoming payload is a match or not to an existing Patient record.

`func:recordlinker.linking.matchers.exact_percent_match`
`func:recordlinker.linking.matchers.eval_perfect_match`

: Determines whether a give set of feature comparisons represent a 'perfect' match
(i.e. all features that were compared match in whatever criteria was specified).
Expand Down Expand Up @@ -152,8 +152,9 @@ existing Patient with the FIRST_NAME of ["John", "D"].

`func:recordlinker.linking.matchers.feature_match_log_odds_fuzzy_compare`

: Similar to the above function, but uses a log-odds ratio to determine if the features are a match.
This is useful when comparing features that have a high cardinality and are not easily compared
using a string comparison. Use the `kwargs` parameter to specify the desired log-odds threshold,
including the fuzzy matching thresholds as well.
Example: `{"kwargs": {"thresholds": {"FIRST_NAME": 0.8}, "log_odds": {"FIRST_NAME": 6.8}}}`
: Similar to the above function, but uses a log-odds ratio to determine if the features are a match
probabilistically. This is useful when wanting to more robustly compare features by incorporating
their predictive power (i.e., the log-odds ratio for a feature represents how powerful of a predictor
that feature is in determining whether two patient records are a true match, as opposed to a match
by random chance). Use the kwargs parameter to specify the fuzzy match threshold and log-odds ratio
based on training. Example: `{"kwargs": {"thresholds": {"FIRST_NAME": 0.8}, "log_odds": {"FIRST_NAME": 6.8}}}`
18 changes: 18 additions & 0 deletions scripts/build_docs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh

# This script builds the public documentation for this repository.
#
# Usage: build_docs.sh
# Requires: npx

set -e

cd "$(dirname "$0")/.."

OUT=${1:-_site}
VERSION=${VERSION:-$(python -c "from recordlinker._version import __version__; print(f'v{__version__}');")}
SITE_NAME="RecordLinker Documentation (${VERSION})"

SITE_NAME=${SITE_NAME} mkdocs build --config-file docs/mkdocs.yml -d "../${OUT}"
python -m recordlinker.utils.openapi_schema > ${OUT}/openapi.json
npx @redocly/cli build-docs -o "${OUT}/api-docs.html" "${OUT}/openapi.json"
2 changes: 1 addition & 1 deletion src/recordlinker/linking/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
TRACER = trace.get_tracer(__name__)
except ImportError:
# OpenTelemetry is an optional dependency, if its not installed use a mock tracer
from recordlinker.utils import MockTracer
from recordlinker.utils.mock import MockTracer

TRACER = MockTracer()

Expand Down
5 changes: 3 additions & 2 deletions src/recordlinker/models/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from recordlinker import utils
from recordlinker.config import ConfigurationError
from recordlinker.config import settings
from recordlinker.utils import functools as func_utils

from .base import Base

Expand Down Expand Up @@ -105,7 +106,7 @@ def bound_evaluators(self) -> dict[str, typing.Callable]:
Get the evaluators for this algorithm pass, bound to the algorithm.
"""
if not hasattr(self, "_bound_evaluators"):
self._bound_evaluators = utils.bind_functions(self.evaluators)
self._bound_evaluators = func_utils.bind_functions(self.evaluators)
return self._bound_evaluators

@property
Expand All @@ -129,7 +130,7 @@ def bound_rule(self) -> typing.Callable:
Get the rule for this algorithm pass, bound to the algorithm.
"""
if not hasattr(self, "_bound_rule"):
self._bound_rule = utils.str_to_callable(self.rule)
self._bound_rule = func_utils.str_to_callable(self.rule)
return self._bound_rule


Expand Down
4 changes: 2 additions & 2 deletions src/recordlinker/routes/link_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async def link_piirecord(
db_session: orm.Session = fastapi.Depends(get_session),
) -> schemas.LinkResponse:
"""
Compare a PII Reocrd with records in the Master Patient Index (MPI) to
Compare a PII Record with records in the Master Patient Index (MPI) to
check for matches with existing patient records If matches are found,
returns the patient and person reference id's
"""
Expand Down Expand Up @@ -187,4 +187,4 @@ async def link_fhir(

except ValueError:
response.status_code = fastapi.status.HTTP_400_BAD_REQUEST
raise fastapi.HTTPException(status_code=400, detail="Error: Bad request")
raise fastapi.HTTPException(status_code=400, detail="Error: Bad request")
5 changes: 3 additions & 2 deletions src/recordlinker/schemas/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@

import pydantic

from recordlinker import utils
from recordlinker.linking import matchers
from recordlinker.models.mpi import BlockingKey
from recordlinker.schemas.pii import Feature
from recordlinker.utils import functools as utils


class AlgorithmPass(pydantic.BaseModel):
"""
The schema for an algorithm pass record.
"""

model_config = pydantic.ConfigDict(from_attributes=True)

blocking_keys: list[str]
Expand Down Expand Up @@ -88,7 +89,7 @@ class AlgorithmSummary(Algorithm):
passes: typing.Sequence[AlgorithmPass] = pydantic.Field(exclude=True)

# mypy doesn't support decorators on properties; https://github.com/python/mypy/issues/1362
@pydantic.computed_field # type: ignore[misc]
@pydantic.computed_field # type: ignore[misc]
@property
def pass_count(self) -> int:
"""
Expand Down
25 changes: 25 additions & 0 deletions src/recordlinker/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import json
import pathlib


def project_root() -> pathlib.Path:
"""
Returns the path to the project root directory.
"""
root = pathlib.Path(__file__).resolve()
while root.name != "recordlinker":
if root.parent == root:
raise FileNotFoundError("recordlinker project root not found.")
root = root.parent
return root


def read_json(path: str) -> dict:
"""
Loads a JSON file.
"""
if not pathlib.Path(path).is_absolute():
# if path is relative, append to the project root
path = str(pathlib.Path(project_root(), path))
with open(path, "r") as fobj:
return json.load(fobj)
47 changes: 0 additions & 47 deletions src/recordlinker/utils.py → src/recordlinker/utils/functools.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,9 @@
import copy
import importlib
import inspect
import json
import pathlib
import typing


def project_root() -> pathlib.Path:
"""
Returns the path to the project root directory.
"""
root = pathlib.Path(__file__).resolve()
while root.name != "recordlinker":
if root.parent == root:
raise FileNotFoundError("recordlinker project root not found.")
root = root.parent
return root


def read_json(path: str) -> dict:
"""
Loads a JSON file.
"""
if not pathlib.Path(path).is_absolute():
# if path is relative, append to the project root
path = str(pathlib.Path(project_root(), path))
with open(path, "r") as fobj:
return json.load(fobj)


def bind_functions(data: dict) -> dict:
"""
Binds the functions in the data to the functions in the module.
Expand Down Expand Up @@ -125,25 +100,3 @@ def _compare_types(actual_type, expected_type):

# Compare return type
return _compare_types(fn_signature.return_annotation, expected_return)


class MockTracer:
"""
A no-op OTel tracer that can be used in place of a real tracer. This is useful
for situations where users decide to not install the otelemetry package.
"""
def start_as_current_span(self, name, **kwargs):
"""Returns a no-op span"""
return self

def __enter__(self):
"""No-op for context manager entry"""
pass

def __exit__(self, exc_type, exc_val, exc_tb):
"""No-op for context manager exit"""
pass

def start_span(self, name, **kwargs):
"""Returns a no-op span"""
return self
21 changes: 21 additions & 0 deletions src/recordlinker/utils/mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class MockTracer:
"""
A no-op OTel tracer that can be used in place of a real tracer. This is useful
for situations where users decide to not install the otelemetry package.
"""

def start_as_current_span(self, name, **kwargs):
"""Returns a no-op span"""
return self

def __enter__(self):
"""No-op for context manager entry"""
pass

def __exit__(self, exc_type, exc_val, exc_tb):
"""No-op for context manager exit"""
pass

def start_span(self, name, **kwargs):
"""Returns a no-op span"""
return self
36 changes: 36 additions & 0 deletions src/recordlinker/utils/openapi_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
recordlinker.openapi_schema
~~~~~~~~~~~~~~~~~~~~~~~~~~~
This module exports the OpenAPI schema for the Record Linker service.
"""

import json
import sys
import typing

from fastapi.openapi.utils import get_openapi

from recordlinker import main


def export_json(file: typing.TextIO):
"""
Export the OpenAPI schema to a JSON file.
"""
json.dump(
get_openapi(
title=main.app.title,
version=main.app.version,
openapi_version=main.app.openapi_version,
description=main.app.description,
routes=main.app.routes,
license_info=main.app.license_info,
contact=main.app.contact,
),
file,
)


if __name__ == "__main__":
export_json(sys.stdout)
Empty file added tests/unit/utils/__init__.py
Empty file.
14 changes: 1 addition & 13 deletions tests/unit/test_utils.py → tests/unit/utils/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import pytest

from recordlinker import utils
from recordlinker.linking import matchers
from recordlinker.utils import functools as utils


def test_project_root():
Expand Down Expand Up @@ -113,15 +113,3 @@ def test_check_signature(self):
assert not utils.check_signature(self.func2, typing.Callable[[int, list[int]], None])
assert utils.check_signature(self.func2, typing.Callable[[int, list[int]], float])
assert not utils.check_signature("a", typing.Callable[[str], None])


class TestMockTracer:
def test_start_span(self):
tracer = utils.MockTracer()
with tracer.start_span("test_span") as span:
assert span is None

def test_start_as_current_span(self):
tracer = utils.MockTracer()
with tracer.start_as_current_span("test.span") as span:
assert span is None
13 changes: 13 additions & 0 deletions tests/unit/utils/test_mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from recordlinker.utils import mock as utils


class TestMockTracer:
def test_start_span(self):
tracer = utils.MockTracer()
with tracer.start_span("test_span") as span:
assert span is None

def test_start_as_current_span(self):
tracer = utils.MockTracer()
with tracer.start_as_current_span("test.span") as span:
assert span is None
Loading

0 comments on commit e3e8d6b

Please sign in to comment.