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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: astral-sh/ruff-action@v3
- uses: astral-sh/ruff-action@v4.0.0
- run: ruff check
- run: ruff format --check
28 changes: 28 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: Python tests

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

jobs:
linux-test:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pipx install uv
uv sync --extra server --extra client
- name: Full Python tests
run: uv run pytest -v -m "not container"
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ ignore_missing_imports = false

[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "session"
markers = [
"container: marks tests which need a container runtime",
]

# [tool.uv.sources]
# tiqi-zedboard = { path = "../tiqi-zedboard", editable = true }
Expand Down
48 changes: 40 additions & 8 deletions src/icon/config/config.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import logging
import os
from pathlib import Path, PosixPath

import yaml
from confz import BaseConfig, FileSource

from icon.config import v1
from icon.config import v2 as latest
from icon.config.config_path import get_config_path
from icon.config import latest, v1
Comment thread
aferk marked this conversation as resolved.
from icon.config.migrations import migration_by_version

_ENV_KEY = "ICON_CONFIG"

logger = logging.getLogger("config")

VERSIONS: dict[int, BaseConfig] = {1: v1.ServiceConfigV1, 2: latest.ServiceConfig}
VERSIONS: dict[int, BaseConfig] = {
cfg.__version__: cfg.ServiceConfig for cfg in (v1, latest)
}


# https://github.com/yaml/pyyaml/issues/617#issuecomment-1039273397
Expand All @@ -27,11 +30,11 @@ def get_config() -> latest.ServiceConfig:

if not source.is_file():
source.parent.mkdir(parents=True, exist_ok=True)
with source.open("a") as file:
file.write(yaml.dump(latest.ServiceConfig().model_dump()))
with source.open("a") as f:
f.write(yaml.dump(latest.ServiceConfig().model_dump()))

with source.open("r") as file:
content = yaml.safe_load(file)
with source.open("r") as f:
content = yaml.safe_load(f)
config_version = content.get("version", None)

schema = VERSIONS.get(config_version)
Expand All @@ -51,3 +54,32 @@ def get_config() -> latest.ServiceConfig:
file.write(yaml.dump(config.model_dump()))

return config


def save_config(config: latest.ServiceConfig) -> None:
"""Save the configuration to the source YAML file.

Serializes the updated configuration and writes it back to the file.

Args:
config:
The validated configuration instance.
"""
with get_config_path().open("w") as f:
f.write(yaml.dump(config.model_dump()))


def _normalize(p: str | Path) -> Path:
return Path(p).expanduser().resolve()


def set_config_path(p: Path) -> None:
"""Set once at startup; children inherit via environment."""
os.environ[_ENV_KEY] = str(_normalize(p))


def get_config_path() -> Path:
"""Read from env, else default."""
if env := os.environ.get(_ENV_KEY):
return _normalize(env)
return _normalize(Path.home() / ".config/icon/config.yaml")
22 changes: 0 additions & 22 deletions src/icon/config/config_path.py

This file was deleted.

73 changes: 73 additions & 0 deletions src/icon/config/latest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from pathlib import Path
from typing import Any

from confz import BaseConfig
from pydantic import BaseModel

__version__ = 2


class HealthCheckConfig(BaseModel):
interval_seconds: float = 10.0


class DataConfiguration(BaseModel):
results_dir: str = str(Path.cwd() / "output")


class ExperimentLibraryConfig(BaseModel):
module: str = "icon.server.data_access.pycrystal_experiment_library_client"
client_class: str = "AsyncPyCrystalClient"
client_args: dict[str, Any] = {}
update_interval: int = 30


class InfluxDBv1Config(BaseModel):
host: str = "localhost"
port: int = 8087
username: str = "admin"
password: str = "admin" # noqa: S105
database: str = "testing"
measurement: str = "Experiment Parameters"
ssl: bool = False
verify_ssl: bool = False
headers: dict[str, str] = {}


class SQLiteConfig(BaseModel):
file: str = str(Path.cwd() / "icon.db")


class DatabaseConfig(BaseModel):
influxdbv1: InfluxDBv1Config = InfluxDBv1Config()
sqlite: SQLiteConfig = SQLiteConfig()


class DateConfig(BaseModel):
timezone: str = "Europe/Zurich"


class PreProcessingConfig(BaseModel):
workers: int = 2


class ServerConfig(BaseModel):
port: int = 8004
host: str = "0.0.0.0"
pre_processing: PreProcessingConfig = PreProcessingConfig()


class HardwareConfig(BaseModel):
host: str = "localhost"
port: int = 6007


class ServiceConfig(BaseConfig): # type: ignore[misc]
version: int = __version__
experiment_library: ExperimentLibraryConfig = ExperimentLibraryConfig()
databases: DatabaseConfig = DatabaseConfig()
date: DateConfig = DateConfig()
server: ServerConfig = ServerConfig()
hardware: HardwareConfig = HardwareConfig()
health_check: HealthCheckConfig = HealthCheckConfig()
data: DataConfiguration = DataConfiguration()
11 changes: 5 additions & 6 deletions src/icon/config/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
from collections.abc import Callable
from typing import Any

from icon.config.v1 import ServiceConfigV1
from icon.config.v2 import ExperimentLibraryConfig
from icon.config.v2 import ServiceConfig as ServiceConfigV2
from icon.config import latest as v2
from icon.config import v1

migration_by_version = {}

Expand All @@ -19,10 +18,10 @@ def decorate(f: Callable[[Any], Any]) -> Callable[[Any], Any]:


@migration(version=1)
def migrate_v1_to_v2(old_config: ServiceConfigV1) -> ServiceConfigV2:
def migrate_v1_to_v2(old_config: v1.ServiceConfig) -> v2.ServiceConfig:
exp_lib = old_config.experiment_library
return ServiceConfigV2(
experiment_library=ExperimentLibraryConfig(
return v2.ServiceConfig(
experiment_library=v2.ExperimentLibraryConfig(
update_interval=exp_lib.update_interval,
client_args={
"checkout_path": exp_lib.dir,
Expand Down
44 changes: 44 additions & 0 deletions src/icon/config/reloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from icon.config.config import get_config

if TYPE_CHECKING:
from icon.config.latest import ServiceConfig

logger = logging.getLogger(__name__)


class ReloadError(Exception):
pass


class Reloader:
def __init__(
self,
obj_factory: Callable[..., Any],
fallback_obj: Any,
subconfig: "Callable[[ServiceConfig], dict[str, Any]]",
) -> None:
self.current_config: dict[str, Any] = {}
self.obj_factory = staticmethod(obj_factory)
self.obj = fallback_obj
self.fallback_obj = fallback_obj
self.subconfig = staticmethod(subconfig)

def reload(self) -> Any:
new_config = self.subconfig(get_config())
if new_config == self.current_config:
return self.obj
try:
self.obj = self.obj_factory(**new_config)
except ReloadError as e:
logger.warning(format(e))
self.obj = self.fallback_obj

self.current_config = new_config
return self.obj

def is_configured(self) -> bool:
return self.obj is not self.fallback_obj
65 changes: 12 additions & 53 deletions src/icon/config/v1.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,27 @@
from pathlib import Path

from confz import BaseConfig
from pydantic import BaseModel

__version__ = 1


class HealthCheckConfig(BaseModel):
interval_seconds: float = 10.0

from icon.config.latest import (
DatabaseConfig,
DataConfiguration,
DateConfig,
HardwareConfig,
HealthCheckConfig,
ServerConfig,
)

class DataConfiguration(BaseModel):
results_dir: str = str(Path.cwd() / "output")
__version__ = 1


class ExperimentLibraryConfigV1(BaseModel):
class ExperimentLibraryConfig(BaseModel):
dir: str | None = None
git_repository: str = "https://..."
update_interval: int = 30


class InfluxDBv1Config(BaseModel):
host: str = "localhost"
port: int = 8087
username: str = "admin"
password: str = "admin" # noqa: S105
database: str = "testing"
measurement: str = "Experiment Parameters"
ssl: bool = False
verify_ssl: bool = False
headers: dict[str, str] = {}


class SQLiteConfig(BaseModel):
file: str = str(Path.cwd() / "icon.db")


class DatabaseConfig(BaseModel):
influxdbv1: InfluxDBv1Config = InfluxDBv1Config()
sqlite: SQLiteConfig = SQLiteConfig()


class DateConfig(BaseModel):
timezone: str = "Europe/Zurich"


class PreProcessingConfig(BaseModel):
workers: int = 2


class ServerConfig(BaseModel):
port: int = 8004
host: str = "0.0.0.0"
pre_processing: PreProcessingConfig = PreProcessingConfig()


class HardwareConfig(BaseModel):
host: str = "localhost"
port: int = 6007


class ServiceConfigV1(BaseConfig): # type: ignore[misc]
class ServiceConfig(BaseConfig): # type: ignore[misc]
version: int = __version__
experiment_library: ExperimentLibraryConfigV1 = ExperimentLibraryConfigV1()
experiment_library: ExperimentLibraryConfig = ExperimentLibraryConfig()
databases: DatabaseConfig = DatabaseConfig()
date: DateConfig = DateConfig()
server: ServerConfig = ServerConfig()
Expand Down
Loading