Skip to content
Draft
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
54 changes: 54 additions & 0 deletions PLUGIN_RULES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Schema Plugin Rules (Draft)

This document defines the current extension contract used by Schema Studio to load and edit schema repositories.

## 1) Profile Contract (Light Mode)
A schema profile must define:
- `key`: profile identifier (e.g. `nomad`, `bam`)
- `package_import`: top-level Python import package
- `package_dist`: pip distribution name
- `default_branch`: branch used by Light Mode
- `default_remote_repo`: canonical git URL used for `schema/update`
- `default_base_namespace`: namespace root for package discovery
- `default_package`: default module for graph extraction

Current implementation lives in `api/light_mode/schema_source.py`.

## 2) Namespace-to-Repo Mapping (Dev Mode)
A schema namespace is mapped to a git repository via:
- default mapping in `api/settings.py`
- optional env override: `SCHEMA_UML_REPO_MAP`

Format:
- `SCHEMA_UML_REPO_MAP="ns.prefix=/path/or/url,other.prefix=/path/or/url"`

Matching rule:
- longest namespace prefix wins.

## 3) Extractor Entity Contract
The graph extractor (`extractor/graph_builder.py`) currently supports two shapes:

### NOMAD-like entities
Class must expose at least one of:
- `m_def`
- `quantities`
- `sub_sections`

### BAM-like entities
Class must inherit from BAM metadata base classes:
- `bam_masterdata.metadata.entities.ObjectType` / `CollectionType` / `DatasetType`
- `bam_masterdata.metadata.entities.VocabularyType`

Quantities are extracted from:
- `PropertyTypeAssignment` class attributes (object/dataset/collection types)
- `VocabularyTerm` class attributes (vocabulary types)

## 4) Editing Contract (Current)
Current CRUD in Schema Studio is graph-level and persisted as custom edits.
- persisted edits are replayed into graph responses
- this is not yet a full source-code writer/committer for schema files

## 5) Planned Pluginization (TODO)
- Externalize extractor adapters into a registry (instead of built-in NOMAD/BAM checks).
- Define adapter hooks (section detection, quantity extraction, dtype/cardinality mapping).
- Add optional code-writer plugin hooks for repository write-back and commit/push workflow.
10 changes: 7 additions & 3 deletions api/light_mode/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
from extractor.graph_builder import _root_namespace, build_graph, list_sections
from extractor.usage_index import get_usage_for_section
from .schema_source import (
DEFAULT_BASE_NAMESPACE as LIGHT_DEFAULT_BASE_NS,
DEFAULT_BRANCH as LIGHT_MODE_BRANCH,
DEFAULT_PACKAGE as LIGHT_DEFAULT_PACKAGE,
LIGHT_PROFILE_KEY,
SchemaUnavailable,
current_schema_info,
list_modules_for_base,
Expand All @@ -36,8 +39,8 @@
SEND_ENDPOINT = os.getenv("SCHEMA_STUDIO_SEND_ENDPOINT")
DEFAULT_PORT = int(os.getenv("SCHEMA_STUDIO_PORT", "5179"))
DEFAULT_HOST = os.getenv("SCHEMA_STUDIO_HOST", "127.0.0.1")
DEFAULT_PACKAGE = os.getenv("SCHEMA_STUDIO_DEFAULT_PACKAGE", "nomad_simulations.schema_packages.model_method")
DEFAULT_BASE_NS = os.getenv("SCHEMA_STUDIO_DEFAULT_NAMESPACE", "nomad_simulations.schema_packages")
DEFAULT_PACKAGE = os.getenv("SCHEMA_STUDIO_DEFAULT_PACKAGE", LIGHT_DEFAULT_PACKAGE)
DEFAULT_BASE_NS = os.getenv("SCHEMA_STUDIO_DEFAULT_NAMESPACE", LIGHT_DEFAULT_BASE_NS)
AUTO_BOOTSTRAP_SCHEMA = os.getenv("SCHEMA_STUDIO_AUTO_BOOTSTRAP_SCHEMA", "1").lower() not in {"0", "false", "no"}

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -424,7 +427,7 @@ async def _bootstrap_schema_on_startup():
@app.get("/schema/version")
async def schema_version():
info = _schema_info_or_503(auto_bootstrap=AUTO_BOOTSTRAP_SCHEMA)
return {"version": info.version, "source": info.source, "send_design_enabled": bool(SEND_ENDPOINT)}
return {"version": info.version, "source": info.source, "schema_profile": LIGHT_PROFILE_KEY, "send_design_enabled": bool(SEND_ENDPOINT)}


@app.post("/schema/update")
Expand All @@ -445,6 +448,7 @@ async def health():
"workspace": _workspace_payload(_workspace()),
"schema_version": info.version,
"schema_source": info.source,
"schema_profile": LIGHT_PROFILE_KEY,
"send_design_enabled": bool(SEND_ENDPOINT),
}

Expand Down
95 changes: 90 additions & 5 deletions api/light_mode/cli.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,91 @@
"""CLI entrypoint for Schema Studio Light Mode."""
from __future__ import annotations

import os
from pathlib import Path
import threading
import time
from urllib.request import urlopen
import os
import webbrowser

import uvicorn

from .app import app, DEFAULT_HOST, DEFAULT_PORT

BANNER = "Running in Light Mode (local, single-user, non-production; schema pinned to nomad-simulations/develop)"
def _strip_wrapping_quotes(value: str) -> str:
"""Remove one matching pair of leading/trailing quotes from an env value."""
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value


def _load_env_fallback(path: Path) -> bool:
"""Minimal .env loader used when python-dotenv is unavailable."""
loaded_any = False
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):].strip()
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
os.environ[key] = _strip_wrapping_quotes(value)
loaded_any = True
return loaded_any


def _load_env_file(path: Path) -> bool:
"""Load a dotenv file with python-dotenv when available, else use fallback parser."""
try:
from dotenv import load_dotenv # type: ignore
except Exception:
return _load_env_fallback(path)
return bool(load_dotenv(dotenv_path=path, override=False))


def _candidate_env_files() -> list[Path]:
"""Return candidate `.env` files in precedence order, de-duplicated."""
candidates: list[Path] = []

explicit = os.getenv("SCHEMA_STUDIO_ENV_FILE")
if explicit:
candidates.append(Path(explicit).expanduser())

candidates.append(Path.cwd() / ".env")
candidates.append(Path(__file__).resolve().parents[2] / ".env")

unique: list[Path] = []
seen: set[str] = set()
for p in candidates:
key = str(p)
if key in seen:
continue
seen.add(key)
unique.append(p)
return unique


def _load_first_available_env() -> Path | None:
"""Load the first existing env file that sets at least one variable."""
for env_path in _candidate_env_files():
if not env_path.is_file():
continue
if _load_env_file(env_path):
return env_path
return None


def _banner(profile_key: str, branch: str) -> str:
"""Build a startup banner describing active light-mode schema profile settings."""
return (
"Running in Light Mode (local, single-user, non-production; "
f"schema profile={profile_key}, branch={branch})"
)


def _open_browser_when_ready(url: str, timeout_seconds: float = 120.0) -> None:
Expand All @@ -27,13 +102,23 @@ def _open_browser_when_ready(url: str, timeout_seconds: float = 120.0) -> None:
return
except Exception:
time.sleep(0.2)
# Fallback: open anyway if readiness check timed out.
webbrowser.open(url)


def main() -> None:
"""Run the light-mode API server and open the UI once healthcheck responds."""
loaded_env = _load_first_available_env()

# Import after loading .env so app defaults resolve from environment when present.
from .app import app, DEFAULT_HOST, DEFAULT_PORT
from .schema_source import active_profile

profile = active_profile()
url = f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
print(BANNER)

if loaded_env is not None:
print(f"Loaded environment from {loaded_env}")
print(_banner(profile.key, profile.default_branch))
print(f"Launching browser at {url} when server is ready\n")
threading.Thread(target=_open_browser_when_ready, args=(url,), daemon=True).start()

Expand Down
Loading