Skip to content
Merged
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
102 changes: 102 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
PYTHON_VERSION: "3.13"

jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install just
uses: extractions/setup-just@v2

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}

- name: Install dependencies
run: uv sync --frozen

- name: Check formatting
run: uv run ruff format --check .

- name: Check linting
run: uv run ruff check .

typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install just
uses: extractions/setup-just@v2

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}

- name: Install dependencies
run: uv sync --frozen

- name: Run type checker
run: uv run ty check osa

test:
name: Test
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4

- name: Install just
uses: extractions/setup-just@v2

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}

- name: Install dependencies
run: uv sync --frozen

- name: Run unit tests with coverage
run: uv run pytest tests/unit -v --tb=short --cov=osa --cov-report=xml --cov-report=term-missing
env:
TEST: "1"

- name: Code Coverage Summary
uses: irongut/CodeCoverageSummary@v1.3.0
with:
filename: coverage.xml
badge: true
format: markdown
output: both

- name: Add coverage PR comment
uses: marocchino/sticky-pull-request-comment@v2
if: github.event_name == 'pull_request'
with:
path: code-coverage-results.md
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,9 @@ cython_debug/
.abstra/

# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/

Expand All @@ -213,4 +213,4 @@ marimo/_lsp/
__marimo__/

# Streamlit
.streamlit/secrets.toml
.streamlit/secrets.toml
37 changes: 37 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
repos:
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: end-of-file-fixer
- id: trailing-whitespace

# Ruff - linting and formatting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format

# Local hooks for ty and tests (no official pre-commit hooks yet)
- repo: local
hooks:
- id: ty-check
name: ty type check
entry: uv run ty check osa
language: system
types: [python]
pass_filenames: false

- id: unit-tests
name: unit tests
entry: uv run pytest tests/unit -q --tb=short
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
5 changes: 4 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ up: db-up
down: db-down

# Testing commands
test kind="unit":
@TEST=1 uv run pytest "tests/{{kind}}" -v --tb=short

test-s kind="unit":
@TEST=1 uv run pytest -s -o log_cli=True -o log_cli_level=DEBUG "tests/{{kind}}"

Expand All @@ -96,7 +99,7 @@ fix thing="osa":

lint thing="osa":
uv run ruff check {{thing}}
uv run pyright {{thing}}
uv run ty check {{thing}}

# Docker commands (standalone)
docker-build:
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,4 @@
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
limitations under the License.
4 changes: 1 addition & 3 deletions ingestors/geo_entrez/ingestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,7 @@ async def _search_uids(self, query: str, limit: int | None) -> list[str]:
resp.raise_for_status()

tree = ElementTree.fromstring(resp.text)
return [
id_elem.text for id_elem in tree.findall(".//Id") if id_elem.text
]
return [id_elem.text for id_elem in tree.findall(".//Id") if id_elem.text]

async def _fetch_batch(self, uids: list[str]) -> list[UpstreamRecord]:
"""Fetch metadata for a batch of UIDs via ESummary."""
Expand Down
2 changes: 1 addition & 1 deletion migrations/README
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Generic single-database configuration.
Generic single-database configuration.
3 changes: 2 additions & 1 deletion migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

# add your model's MetaData object here
# for 'autogenerate' support
from osa.infrastructure.persistence.tables import metadata
from osa.infrastructure.persistence.tables import metadata # noqa: E402

target_metadata = metadata

# other values from the config, defined by the needs of env.py,
Expand Down
4 changes: 1 addition & 3 deletions osa/cli/commands/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ def extract_short_id(srn: str) -> str:

SRN format: urn:osa:{domain}:{type}:{uuid}[@{version}]
"""
match = re.search(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", srn
)
match = re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", srn)
if match:
return match.group(0)[:6]
return srn[:6]
Expand Down
1 change: 1 addition & 0 deletions osa/cli/commands/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def _resolve_config(paths: OSAPaths, config: Path | None = None) -> Path:
)
sys.exit(1)

assert config is not None # Guaranteed by logic above
if not config.exists():
console.error(f"Config file not found: {config}")
sys.exit(1)
Expand Down
7 changes: 3 additions & 4 deletions osa/cli/commands/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ def show(ref: str, /) -> None:
if cache is None:
console.error(
"No search results cached",
hint="Run a search first: osa search vector \"your query\"",
hint='Run a search first: osa search vector "your query"',
)
sys.exit(1)
return # Unreachable, but helps type checker

# Try to interpret ref as a number first
result: SearchHit | None = None
Expand All @@ -51,9 +52,7 @@ def show(ref: str, /) -> None:
console.error(f"Ambiguous short ID '{ref}'")
console.print("[dim]Matches:[/dim]")
for m in matches:
console.print(
f" [cyan]{m.short_id}[/cyan] {m.metadata.title[:50]}"
)
console.print(f" [cyan]{m.short_id}[/cyan] {m.metadata.title[:50]}")
sys.exit(1)
else:
console.error(
Expand Down
4 changes: 1 addition & 3 deletions osa/cli/util/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,7 @@ def start(
if process.poll() is not None:
# Process exited immediately - error
self._paths.remove_server_state()
raise RuntimeError(
f"Server failed to start. Check logs at {self._paths.server_log}"
)
raise RuntimeError(f"Server failed to start. Check logs at {self._paths.server_log}")

return ServerInfo(
status=ServerStatus.RUNNING,
Expand Down
4 changes: 1 addition & 3 deletions osa/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,7 @@ def name(self) -> str:
class YamlConfigSettingsSource(PydanticBaseSettingsSource):
"""Load settings from YAML file specified by OSA_CONFIG_FILE env var."""

def get_field_value(
self, field: Any, field_name: str
) -> tuple[Any, str, bool]:
def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
"""Get the value for a field from the YAML config."""
yaml_data = self._load_yaml_config()
field_value = yaml_data.get(field_name)
Expand Down
8 changes: 2 additions & 6 deletions osa/domain/curation/listener/auto_approve_curation_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,13 @@ async def handle(self, event: ValidationCompleted) -> None:
"""Emit DepositionApproved if validation passed and no curation required."""
# Only auto-approve if validation passed
if event.status != RunStatus.COMPLETED:
logger.warning(
f"Validation failed for {event.deposition_srn}, skipping auto-approve"
)
logger.warning(f"Validation failed for {event.deposition_srn}, skipping auto-approve")
return

# TODO: Load curation config to check if manual curation is required
curation_required = False # False for v1
if curation_required:
logger.info(
f"Curation required for {event.deposition_srn}, not auto-approving"
)
logger.info(f"Curation required for {event.deposition_srn}, not auto-approving")
return

logger.debug(f"Auto-approving deposition: {event.deposition_srn}")
Expand Down
4 changes: 1 addition & 3 deletions osa/domain/deposition/command/delete_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ class DepositionFilesDeleted(Result):
pass


class DeleteDepositionFilesHandler(
CommandHandler[DeleteDepositionFiles, DepositionFilesDeleted]
):
class DeleteDepositionFilesHandler(CommandHandler[DeleteDepositionFiles, DepositionFilesDeleted]):
repository: DepositionRepository
storage: StoragePort

Expand Down
2 changes: 1 addition & 1 deletion osa/domain/index/listener/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from osa.domain.index.listener.index_projector import ProjectNewRecordToIndexes

__all__ = ["IndexProjector"]
__all__ = ["ProjectNewRecordToIndexes"]
28 changes: 11 additions & 17 deletions osa/domain/index/listener/index_projector.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
"""IndexProjector - indexes published records into storage backends."""

import logging

from osa.domain.index.model.registry import IndexRegistry
from osa.domain.index.service import IndexService
from osa.domain.record.event.record_published import RecordPublished
from osa.domain.shared.event import EventListener

logger = logging.getLogger(__name__)


class ProjectNewRecordToIndexes(EventListener[RecordPublished]):
"""Projects published records into index backends."""
"""Projects published records into index backends.

indexes: IndexRegistry
This listener delegates to IndexService for all business logic.
"""

async def handle(self, event: RecordPublished) -> None:
"""Index record into all configured backends."""
srn_str = str(event.record_srn)
service: IndexService

# Index into all configured backends
for name, backend in self.indexes.items():
try:
await backend.ingest(srn_str, event.metadata)
logger.debug(f"Indexed {srn_str} into backend '{name}'")
except Exception as e:
logger.error(f"Failed to index {srn_str} into '{name}': {e}")
async def handle(self, event: RecordPublished) -> None:
"""Delegate to IndexService to index the record."""
await self.service.index_record(
record_srn=event.record_srn,
metadata=event.metadata,
)
5 changes: 5 additions & 0 deletions osa/domain/index/service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Index service module."""

from osa.domain.index.service.index import IndexService

__all__ = ["IndexService"]
Loading
Loading