Spaces:
Sleeping
Sleeping
Add settings module and initial tests.
Browse files- .github/workflows/python-ci.yaml +23 -0
- blossomtune_gradio/config.py +2 -0
- blossomtune_gradio/settings/__init__.py +116 -0
- blossomtune_gradio/settings/blossomtune.schema.json +112 -0
- blossomtune_gradio/settings/blossomtune.yaml +82 -0
- pyproject.toml +3 -1
- tests/test_settings.py +148 -0
- uv.lock +2 -0
.github/workflows/python-ci.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: python-ci
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
push:
|
| 6 |
+
branches: [main]
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
uv-example:
|
| 10 |
+
name: python
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- name: Install uv
|
| 17 |
+
uses: astral-sh/setup-uv@v6
|
| 18 |
+
|
| 19 |
+
- name: Install the project
|
| 20 |
+
run: uv sync --locked --all-extras --dev --index-strategy=unsafe-best-match
|
| 21 |
+
|
| 22 |
+
- name: Run tests
|
| 23 |
+
run: uv run pytest
|
blossomtune_gradio/config.py
CHANGED
|
@@ -2,6 +2,8 @@ import os
|
|
| 2 |
|
| 3 |
from blossomtune_gradio import util
|
| 4 |
|
|
|
|
|
|
|
| 5 |
|
| 6 |
# HF Space ID
|
| 7 |
SPACE_ID = os.getenv("SPACE_ID", "ethicalabs/BlossomTune-Orchestrator")
|
|
|
|
| 2 |
|
| 3 |
from blossomtune_gradio import util
|
| 4 |
|
| 5 |
+
# BlossomTune yaml config path
|
| 6 |
+
BLOSSOMTUNE_CONFIG = os.getenv("BLOSSOMTUNE_CONFIG", None)
|
| 7 |
|
| 8 |
# HF Space ID
|
| 9 |
SPACE_ID = os.getenv("SPACE_ID", "ethicalabs/BlossomTune-Orchestrator")
|
blossomtune_gradio/settings/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import yaml
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
from jsonschema import validate, ValidationError
|
| 6 |
+
from jinja2 import Template
|
| 7 |
+
|
| 8 |
+
from blossomtune_gradio import config as cfg
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings:
|
| 12 |
+
"""Handles loading and validation of UI text from a YAML file."""
|
| 13 |
+
|
| 14 |
+
_instance = None
|
| 15 |
+
|
| 16 |
+
def __new__(cls, *args, **kwargs):
|
| 17 |
+
if not cls._instance:
|
| 18 |
+
cls._instance = super(Settings, cls).__new__(cls)
|
| 19 |
+
return cls._instance
|
| 20 |
+
|
| 21 |
+
@classmethod
|
| 22 |
+
def _reset_instance_for_testing(cls):
|
| 23 |
+
"""
|
| 24 |
+
Resets the singleton instance.
|
| 25 |
+
This method should ONLY be used in tests.
|
| 26 |
+
"""
|
| 27 |
+
cls._instance = None
|
| 28 |
+
|
| 29 |
+
def __init__(self, config_path: str | None = None, schema_path: str | None = None):
|
| 30 |
+
"""
|
| 31 |
+
Initializes the settings object.
|
| 32 |
+
The initialization logic runs only once per instance lifecycle.
|
| 33 |
+
"""
|
| 34 |
+
# Use an instance-level flag to prevent re-initialization.
|
| 35 |
+
if hasattr(self, "_initialized_instance"):
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
module_dir = os.path.dirname(__file__)
|
| 39 |
+
|
| 40 |
+
# Always initialize attributes to ensure the object is in a consistent state.
|
| 41 |
+
self.templates: Dict[str, Template] = {}
|
| 42 |
+
# Prioritize env var, then passed arg, then default
|
| 43 |
+
self.config_path = (
|
| 44 |
+
cfg.BLOSSOMTUNE_CONFIG
|
| 45 |
+
or config_path
|
| 46 |
+
or os.path.join(module_dir, "blossomtune.yaml")
|
| 47 |
+
)
|
| 48 |
+
self.schema_path = schema_path or os.path.join(
|
| 49 |
+
module_dir, "blossomtune.schema.json"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Load the configuration.
|
| 53 |
+
self._load_config()
|
| 54 |
+
|
| 55 |
+
# Mark this specific instance as initialized.
|
| 56 |
+
self._initialized_instance = True
|
| 57 |
+
|
| 58 |
+
def _load_config(self) -> bool:
|
| 59 |
+
"""
|
| 60 |
+
Loads YAML config, validates it, and compiles Jinja2 templates.
|
| 61 |
+
This method populates self.templates on success or prints errors on failure.
|
| 62 |
+
Returns True on success, False on any failure.
|
| 63 |
+
"""
|
| 64 |
+
# Check for and validate the main config file
|
| 65 |
+
try:
|
| 66 |
+
with open(self.config_path, "r") as f:
|
| 67 |
+
# safe_load can return None for an empty file, which is not an error itself
|
| 68 |
+
config = yaml.safe_load(f)
|
| 69 |
+
# If the file is not empty but safe_load returns None, it's invalid YAML
|
| 70 |
+
if config is None and os.fstat(f.fileno()).st_size > 0:
|
| 71 |
+
raise yaml.YAMLError("Contains non-YAML content.")
|
| 72 |
+
except FileNotFoundError:
|
| 73 |
+
print(f"Error: Configuration file not found at {self.config_path}")
|
| 74 |
+
return False
|
| 75 |
+
except yaml.YAMLError as e:
|
| 76 |
+
print(f"Error parsing YAML file: {e}")
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
# Check for and validate the schema file
|
| 80 |
+
try:
|
| 81 |
+
with open(self.schema_path, "r") as f:
|
| 82 |
+
schema = json.load(f)
|
| 83 |
+
except FileNotFoundError:
|
| 84 |
+
print(f"Error: JSON schema not found at {self.schema_path}")
|
| 85 |
+
return False
|
| 86 |
+
except json.JSONDecodeError as e:
|
| 87 |
+
print(f"Error parsing JSON schema: {e}")
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
# Validate the config against the schema
|
| 91 |
+
try:
|
| 92 |
+
# The config can be None if the YAML file was empty.
|
| 93 |
+
validate(instance=config, schema=schema)
|
| 94 |
+
except ValidationError as e:
|
| 95 |
+
print(f"Error: YAML configuration is invalid. {e.message}")
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
# If everything is valid, compile the templates
|
| 99 |
+
if config:
|
| 100 |
+
ui_config = config.get("ui", {})
|
| 101 |
+
for key, value in ui_config.items():
|
| 102 |
+
if isinstance(value, str):
|
| 103 |
+
self.templates[key] = Template(value)
|
| 104 |
+
|
| 105 |
+
return True
|
| 106 |
+
|
| 107 |
+
def get_text(self, key: str, **kwargs: Any) -> str:
|
| 108 |
+
"""Renders a Jinja2 template with the given context."""
|
| 109 |
+
if key in self.templates:
|
| 110 |
+
return self.templates[key].render(**kwargs)
|
| 111 |
+
return f"Warning: Text for '{key}' not found."
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# Singleton instance that runs on first import.
|
| 115 |
+
# The _reset_instance_for_testing method is for isolating tests from this initial state.
|
| 116 |
+
settings = Settings()
|
blossomtune_gradio/settings/blossomtune.schema.json
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
| 3 |
+
"title": "BlossomTune UI Configuration Schema",
|
| 4 |
+
"description": "Schema for validating the blossomtune.yaml UI text configuration.",
|
| 5 |
+
"type": "object",
|
| 6 |
+
"properties": {
|
| 7 |
+
"ui": {
|
| 8 |
+
"type": "object",
|
| 9 |
+
"description": "Contains all user-interface text templates.",
|
| 10 |
+
"properties": {
|
| 11 |
+
"welcome_message_md": {
|
| 12 |
+
"type": "string",
|
| 13 |
+
"description": "Message shown to welcome the user."
|
| 14 |
+
},
|
| 15 |
+
"error_message_md": {
|
| 16 |
+
"type": "string",
|
| 17 |
+
"description": "Message shown when an error occurs."
|
| 18 |
+
},
|
| 19 |
+
"auth_required_md": {
|
| 20 |
+
"type": "string",
|
| 21 |
+
"description": "Message shown when a user needs to log in."
|
| 22 |
+
},
|
| 23 |
+
"hf_handle_empty_md": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"description": "Error for an empty Hugging Face handle."
|
| 26 |
+
},
|
| 27 |
+
"invalid_email_md": {
|
| 28 |
+
"type": "string",
|
| 29 |
+
"description": "Error for an invalid email format."
|
| 30 |
+
},
|
| 31 |
+
"federation_full_md": {
|
| 32 |
+
"type": "string",
|
| 33 |
+
"description": "Message shown when no new participants can join."
|
| 34 |
+
},
|
| 35 |
+
"activation_invalid_md": {
|
| 36 |
+
"type": "string",
|
| 37 |
+
"description": "Error for an incorrect activation code."
|
| 38 |
+
},
|
| 39 |
+
"registration_submitted_md": {
|
| 40 |
+
"type": "string",
|
| 41 |
+
"description": "Confirmation after a user submits a join request."
|
| 42 |
+
},
|
| 43 |
+
"activation_successful_md": {
|
| 44 |
+
"type": "string",
|
| 45 |
+
"description": "Confirmation after successful email activation."
|
| 46 |
+
},
|
| 47 |
+
"missing_activation_code_md": {
|
| 48 |
+
"type": "string",
|
| 49 |
+
"description": "Message prompting the user to enter their activation code."
|
| 50 |
+
},
|
| 51 |
+
"status_approved_md": {
|
| 52 |
+
"type": "string",
|
| 53 |
+
"description": "Message for an approved participant, including connection details. JINJA2: {{ participant_id }}, {{ partition_id }}, {{ superlink_hostname }}, {{ num_partitions }}"
|
| 54 |
+
},
|
| 55 |
+
"status_pending_md": {
|
| 56 |
+
"type": "string",
|
| 57 |
+
"description": "Status message for a request awaiting admin review."
|
| 58 |
+
},
|
| 59 |
+
"status_denied_md": {
|
| 60 |
+
"type": "string",
|
| 61 |
+
"description": "Message for a denied participant. JINJA2: {{ participant_id }}"
|
| 62 |
+
},
|
| 63 |
+
"participant_not_activated_warning": {
|
| 64 |
+
"type": "string",
|
| 65 |
+
"description": "Admin warning when trying to approve a non-activated user."
|
| 66 |
+
},
|
| 67 |
+
"partition_in_use_warning": {
|
| 68 |
+
"type": "string",
|
| 69 |
+
"description": "Admin warning for an already assigned partition ID. JINJA2: {{ partition_id }}"
|
| 70 |
+
},
|
| 71 |
+
"auth_status_logged_in_owner_md": {
|
| 72 |
+
"type": "string",
|
| 73 |
+
"description": "Auth status for the space owner. JINJA2: {{ profile.username }}"
|
| 74 |
+
},
|
| 75 |
+
"auth_status_logged_in_user_md": {
|
| 76 |
+
"type": "string",
|
| 77 |
+
"description": "Auth status for a regular logged-in user. JINJA2: {{ profile.username }}"
|
| 78 |
+
},
|
| 79 |
+
"auth_status_not_logged_in_md": {
|
| 80 |
+
"type": "string",
|
| 81 |
+
"description": "Auth status when the user is not logged in."
|
| 82 |
+
},
|
| 83 |
+
"auth_status_local_mode_md": {
|
| 84 |
+
"type": "string",
|
| 85 |
+
"description": "Auth status when running the app locally."
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
"required": [
|
| 89 |
+
"welcome_message_md",
|
| 90 |
+
"error_message_md",
|
| 91 |
+
"auth_required_md",
|
| 92 |
+
"hf_handle_empty_md",
|
| 93 |
+
"invalid_email_md",
|
| 94 |
+
"federation_full_md",
|
| 95 |
+
"activation_invalid_md",
|
| 96 |
+
"registration_submitted_md",
|
| 97 |
+
"activation_successful_md",
|
| 98 |
+
"missing_activation_code_md",
|
| 99 |
+
"status_approved_md",
|
| 100 |
+
"status_pending_md",
|
| 101 |
+
"status_denied_md",
|
| 102 |
+
"participant_not_activated_warning",
|
| 103 |
+
"partition_in_use_warning",
|
| 104 |
+
"auth_status_logged_in_owner_md",
|
| 105 |
+
"auth_status_logged_in_user_md",
|
| 106 |
+
"auth_status_not_logged_in_md",
|
| 107 |
+
"auth_status_local_mode_md"
|
| 108 |
+
]
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
"required": ["ui"]
|
| 112 |
+
}
|
blossomtune_gradio/settings/blossomtune.yaml
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Configuration for BlossomTune Orchestrator
|
| 2 |
+
# This file uses Jinja2 templating for dynamic content.
|
| 3 |
+
|
| 4 |
+
ui:
|
| 5 |
+
# --- Join Federation Tab ---
|
| 6 |
+
welcome_message_md: |
|
| 7 |
+
Hello!
|
| 8 |
+
error_message_md: |
|
| 9 |
+
An error occurred!
|
| 10 |
+
auth_required_md: |
|
| 11 |
+
### Authentication Required
|
| 12 |
+
**Please log in with Hugging Face to request to join the federation.**
|
| 13 |
+
|
| 14 |
+
hf_handle_empty_md: |
|
| 15 |
+
### Invalid Input
|
| 16 |
+
Hugging Face handle cannot be empty.
|
| 17 |
+
|
| 18 |
+
invalid_email_md: |
|
| 19 |
+
### Invalid Input
|
| 20 |
+
Please provide a valid email address.
|
| 21 |
+
|
| 22 |
+
federation_full_md: |
|
| 23 |
+
### Federation Full
|
| 24 |
+
**We're sorry, but we cannot accept new participants at this time.**
|
| 25 |
+
|
| 26 |
+
activation_invalid_md: |
|
| 27 |
+
### ❌ Activation Failed
|
| 28 |
+
The activation code is not valid, or the participant has not subscribed yet.
|
| 29 |
+
|
| 30 |
+
registration_submitted_md: |
|
| 31 |
+
### ✅ Registration Submitted!
|
| 32 |
+
Please check your email for an activation code to complete your request.
|
| 33 |
+
|
| 34 |
+
activation_successful_md: |
|
| 35 |
+
### ✅ Activation Successful!
|
| 36 |
+
Your request is now pending review by an administrator.
|
| 37 |
+
|
| 38 |
+
missing_activation_code_md: |
|
| 39 |
+
### ⏳ Missing Activation Code
|
| 40 |
+
Please check your email and enter the activation code you received.
|
| 41 |
+
|
| 42 |
+
status_approved_md: |
|
| 43 |
+
### ✅ Approved
|
| 44 |
+
Your request for ID `{{ participant_id }}` has been approved.
|
| 45 |
+
- **Your Assigned Partition ID:** `{{ partition_id }}`
|
| 46 |
+
- **Superlink Address:** `{{ superlink_hostname }}:9092`
|
| 47 |
+
|
| 48 |
+
**Instructions:** Your Flower client code should use your assigned Partition ID to load the correct data subset.
|
| 49 |
+
The server address is for your client's connection command.
|
| 50 |
+
|
| 51 |
+
*Example `flower-supernode` command:*
|
| 52 |
+
```bash
|
| 53 |
+
flower-supernode --superlink {{ superlink_hostname }}:9092 --insecure --node-config "partition-id={{ partition_id }} num-partitions={{ num_partitions }}"
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
status_pending_md: |
|
| 57 |
+
### ⏳ Pending
|
| 58 |
+
Your request has been activated and is awaiting administrator review.
|
| 59 |
+
|
| 60 |
+
status_denied_md: |
|
| 61 |
+
### ❌ Denied
|
| 62 |
+
Your request for ID `{{ participant_id }}` has been denied.
|
| 63 |
+
|
| 64 |
+
# --- Admin Panel ---
|
| 65 |
+
participant_not_activated_warning: |
|
| 66 |
+
This participant has not activated their email yet. Approval is not allowed.
|
| 67 |
+
|
| 68 |
+
partition_in_use_warning: |
|
| 69 |
+
Partition ID {{ partition_id }} is already assigned. Please choose a different one.
|
| 70 |
+
|
| 71 |
+
# --- Authentication Status ---
|
| 72 |
+
auth_status_logged_in_owner_md: |
|
| 73 |
+
✅ Logged in as **{{ profile.username }}**. You are the space owner.
|
| 74 |
+
|
| 75 |
+
auth_status_logged_in_user_md: |
|
| 76 |
+
Logged in as: **{{ profile.username }}**.
|
| 77 |
+
|
| 78 |
+
auth_status_not_logged_in_md: |
|
| 79 |
+
⚠️ You are not logged in. Please log in with Hugging Face.
|
| 80 |
+
|
| 81 |
+
auth_status_local_mode_md: |
|
| 82 |
+
Running in local mode. Admin controls enabled.
|
pyproject.toml
CHANGED
|
@@ -13,7 +13,8 @@ dependencies = [
|
|
| 13 |
"transformers>=4.56.1",
|
| 14 |
"scikit-learn>=1.7.1",
|
| 15 |
"evaluate>=0.4.5",
|
| 16 |
-
"markupsafe==2.1.3"
|
|
|
|
| 17 |
]
|
| 18 |
|
| 19 |
[tool.uv.sources]
|
|
@@ -75,6 +76,7 @@ pythonpath = "."
|
|
| 75 |
addopts = [
|
| 76 |
"--import-mode=importlib",
|
| 77 |
]
|
|
|
|
| 78 |
|
| 79 |
[build-system]
|
| 80 |
requires = ["setuptools>=61.0", "wheel", "build"]
|
|
|
|
| 13 |
"transformers>=4.56.1",
|
| 14 |
"scikit-learn>=1.7.1",
|
| 15 |
"evaluate>=0.4.5",
|
| 16 |
+
"markupsafe==2.1.3",
|
| 17 |
+
"jinja2>=3.1.6",
|
| 18 |
]
|
| 19 |
|
| 20 |
[tool.uv.sources]
|
|
|
|
| 76 |
addopts = [
|
| 77 |
"--import-mode=importlib",
|
| 78 |
]
|
| 79 |
+
testpaths = ["tests"]
|
| 80 |
|
| 81 |
[build-system]
|
| 82 |
requires = ["setuptools>=61.0", "wheel", "build"]
|
tests/test_settings.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import yaml
|
| 3 |
+
import json
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
# Import the Settings class specifically to allow for instance management in tests
|
| 7 |
+
from blossomtune_gradio.settings import Settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture(autouse=True)
|
| 11 |
+
def reset_settings_singleton():
|
| 12 |
+
"""
|
| 13 |
+
Fixture to automatically reset the Settings singleton before each test.
|
| 14 |
+
This allows creating a new, clean instance for each test scenario by
|
| 15 |
+
directly resetting the internal `_instance` variable.
|
| 16 |
+
"""
|
| 17 |
+
Settings._instance = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def valid_config_files(tmp_path):
|
| 22 |
+
"""Creates a valid config and schema file in a temporary directory."""
|
| 23 |
+
config_dir = tmp_path / "config"
|
| 24 |
+
config_dir.mkdir()
|
| 25 |
+
|
| 26 |
+
config_content = {
|
| 27 |
+
"ui": {
|
| 28 |
+
"welcome_message_md": "Hello, {{ name }}!",
|
| 29 |
+
"error_message_md": "An error occurred.",
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
schema_content = {
|
| 33 |
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
| 34 |
+
"type": "object",
|
| 35 |
+
"properties": {
|
| 36 |
+
"ui": {
|
| 37 |
+
"type": "object",
|
| 38 |
+
"properties": {
|
| 39 |
+
"welcome_message_md": {"type": "string"},
|
| 40 |
+
"error_message_md": {"type": "string"},
|
| 41 |
+
},
|
| 42 |
+
"required": ["welcome_message_md", "error_message_md"],
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
"required": ["ui"],
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
config_path = config_dir / "blossomtune.yaml"
|
| 49 |
+
schema_path = config_dir / "blossomtune.schema.json"
|
| 50 |
+
|
| 51 |
+
with open(config_path, "w") as f:
|
| 52 |
+
yaml.dump(config_content, f)
|
| 53 |
+
with open(schema_path, "w") as f:
|
| 54 |
+
json.dump(schema_content, f)
|
| 55 |
+
|
| 56 |
+
return str(config_path), str(schema_path)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_load_valid_config(valid_config_files):
|
| 60 |
+
"""Tests successful loading of a valid configuration."""
|
| 61 |
+
config_path, schema_path = valid_config_files
|
| 62 |
+
settings = Settings(config_path=config_path, schema_path=schema_path)
|
| 63 |
+
|
| 64 |
+
assert "welcome_message_md" in settings.templates
|
| 65 |
+
assert "error_message_md" in settings.templates
|
| 66 |
+
|
| 67 |
+
rendered_text = settings.get_text("welcome_message_md", name="World")
|
| 68 |
+
assert rendered_text == "Hello, World!"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_missing_config_file(tmp_path, capsys):
|
| 72 |
+
"""Tests handling of a missing configuration file and captures stdout."""
|
| 73 |
+
schema_path = tmp_path / "schema.json"
|
| 74 |
+
schema_path.touch() # Create an empty schema for the test
|
| 75 |
+
settings = Settings(config_path="nonexistent.yaml", schema_path=str(schema_path))
|
| 76 |
+
|
| 77 |
+
assert not settings.templates
|
| 78 |
+
|
| 79 |
+
# Check that an error was printed to the console
|
| 80 |
+
captured = capsys.readouterr()
|
| 81 |
+
assert "Error: Configuration file not found" in captured.out
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_invalid_yaml_file(tmp_path, capsys):
|
| 85 |
+
"""Tests handling of a syntactically incorrect YAML file."""
|
| 86 |
+
config_path = tmp_path / "invalid.yaml"
|
| 87 |
+
schema_path = tmp_path / "schema.json"
|
| 88 |
+
schema_path.touch()
|
| 89 |
+
|
| 90 |
+
with open(config_path, "w") as f:
|
| 91 |
+
f.write("ui: { welcome: 'Hello'") # Malformed YAML
|
| 92 |
+
|
| 93 |
+
settings = Settings(config_path=str(config_path), schema_path=str(schema_path))
|
| 94 |
+
assert not settings.templates
|
| 95 |
+
captured = capsys.readouterr()
|
| 96 |
+
assert "Error parsing YAML file" in captured.out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_schema_validation_failure(tmp_path, capsys):
|
| 100 |
+
"""Tests that validation fails if the config doesn't match the schema."""
|
| 101 |
+
config_dir = tmp_path / "config"
|
| 102 |
+
config_dir.mkdir()
|
| 103 |
+
|
| 104 |
+
# Config is missing the required 'error_message'
|
| 105 |
+
config_content = {"ui": {"welcome_message_md": "Hello!"}}
|
| 106 |
+
schema_content = {
|
| 107 |
+
"type": "object",
|
| 108 |
+
"properties": {"ui": {"type": "object", "required": ["error_message_md"]}},
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
config_path = config_dir / "blossomtune.yaml"
|
| 112 |
+
schema_path = config_dir / "blossomtune.schema.json"
|
| 113 |
+
|
| 114 |
+
with open(config_path, "w") as f:
|
| 115 |
+
yaml.dump(config_content, f)
|
| 116 |
+
with open(schema_path, "w") as f:
|
| 117 |
+
json.dump(schema_content, f)
|
| 118 |
+
|
| 119 |
+
settings = Settings(config_path=str(config_path), schema_path=str(schema_path))
|
| 120 |
+
assert not settings.templates
|
| 121 |
+
captured = capsys.readouterr()
|
| 122 |
+
assert "Error: YAML configuration is invalid" in captured.out
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@patch("blossomtune_gradio.config.BLOSSOMTUNE_CONFIG")
|
| 126 |
+
def test_load_from_env_variable(mock_config_env, valid_config_files):
|
| 127 |
+
"""Tests loading the config path from an environment variable."""
|
| 128 |
+
config_path, schema_path = valid_config_files
|
| 129 |
+
mock_config_env = config_path
|
| 130 |
+
|
| 131 |
+
# By patching the config module, the Settings constructor will pick it up
|
| 132 |
+
with patch("blossomtune_gradio.settings.cfg.BLOSSOMTUNE_CONFIG", mock_config_env):
|
| 133 |
+
settings = Settings(schema_path=schema_path)
|
| 134 |
+
|
| 135 |
+
assert settings.config_path == config_path
|
| 136 |
+
rendered_text = settings.get_text("welcome_message_md", name="From Env")
|
| 137 |
+
assert rendered_text == "Hello, From Env!"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_singleton_pattern(valid_config_files):
|
| 141 |
+
"""Tests that the same instance of Settings is always returned."""
|
| 142 |
+
config_path, schema_path = valid_config_files
|
| 143 |
+
s1 = Settings(config_path=config_path, schema_path=schema_path)
|
| 144 |
+
s2 = Settings() # Should return the same instance as s1
|
| 145 |
+
|
| 146 |
+
assert s1 is s2
|
| 147 |
+
# Verify s2 is configured, proving it's the same instance
|
| 148 |
+
assert "welcome_message_md" in s2.templates
|
uv.lock
CHANGED
|
@@ -231,6 +231,7 @@ dependencies = [
|
|
| 231 |
{ name = "flwr", extra = ["simulation"] },
|
| 232 |
{ name = "flwr-datasets" },
|
| 233 |
{ name = "gradio", extra = ["oauth"] },
|
|
|
|
| 234 |
{ name = "markupsafe" },
|
| 235 |
{ name = "scikit-learn" },
|
| 236 |
{ name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
|
@@ -250,6 +251,7 @@ requires-dist = [
|
|
| 250 |
{ name = "flwr", extras = ["simulation"], specifier = ">=1.21.0" },
|
| 251 |
{ name = "flwr-datasets", specifier = ">=0.5.0" },
|
| 252 |
{ name = "gradio", extras = ["oauth"], specifier = ">=5.44.1" },
|
|
|
|
| 253 |
{ name = "markupsafe", specifier = "==2.1.3" },
|
| 254 |
{ name = "scikit-learn", specifier = ">=1.7.1" },
|
| 255 |
{ name = "torch", specifier = ">=2.8.0", index = "https://download.pytorch.org/whl/cpu" },
|
|
|
|
| 231 |
{ name = "flwr", extra = ["simulation"] },
|
| 232 |
{ name = "flwr-datasets" },
|
| 233 |
{ name = "gradio", extra = ["oauth"] },
|
| 234 |
+
{ name = "jinja2" },
|
| 235 |
{ name = "markupsafe" },
|
| 236 |
{ name = "scikit-learn" },
|
| 237 |
{ name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
|
|
|
| 251 |
{ name = "flwr", extras = ["simulation"], specifier = ">=1.21.0" },
|
| 252 |
{ name = "flwr-datasets", specifier = ">=0.5.0" },
|
| 253 |
{ name = "gradio", extras = ["oauth"], specifier = ">=5.44.1" },
|
| 254 |
+
{ name = "jinja2", specifier = ">=3.1.6" },
|
| 255 |
{ name = "markupsafe", specifier = "==2.1.3" },
|
| 256 |
{ name = "scikit-learn", specifier = ">=1.7.1" },
|
| 257 |
{ name = "torch", specifier = ">=2.8.0", index = "https://download.pytorch.org/whl/cpu" },
|