mrs83 commited on
Commit
e29625a
·
1 Parent(s): b59006e

Add settings module and initial tests.

Browse files
.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" },