development-hub/src/development_hub/settings.py

430 lines
15 KiB
Python

"""Settings management for Development Hub."""
import json
from pathlib import Path
class Settings:
"""Application settings with file persistence."""
_instance = None
_settings_file = Path.home() / ".config" / "development-hub" / "settings.json"
_session_file = Path.home() / ".config" / "development-hub" / "session.json"
# Default settings
DEFAULTS = {
"deploy_docs_after_creation": True,
"default_project_path": str(Path.home() / "PycharmProjects"),
"preferred_editor": "auto", # "auto", "pycharm", "xed", "code", "gedit"
"project_search_paths": [str(Path.home() / "PycharmProjects")],
"project_ignore_folders": ["trash", "project-docs", ".cache", "__pycache__", "node_modules"],
"auto_start_docs_server": True,
# Git hosting settings
"git_host_type": "", # "gitea", "github", "gitlab", ""
"git_host_url": "", # e.g., "https://gitea.example.com" or "https://github.com"
"git_host_owner": "", # username or organization
"git_host_token": "", # API token (stored in settings, not ideal but simple)
# Documentation settings
"docs_mode": "auto", # "auto" | "standalone" | "project-docs"
"docs_root": "", # Empty = derive from mode
"docusaurus_path": "", # Path to project-docs
"pages_url": "", # Separate from git_host_url
# Integration paths
"cmdforge_path": "", # Override cmdforge location
"progress_dir": "", # Override progress directory
}
# Available editor choices with display names
EDITOR_CHOICES = [
("auto", "Auto-detect"),
("pycharm", "PyCharm (IDE with markdown preview)"),
("code", "VS Code"),
("xed", "Xed (quick text editor)"),
("gedit", "Gedit"),
("subl", "Sublime Text"),
]
# Git hosting provider choices
GIT_HOST_CHOICES = [
("gitea", "Gitea"),
("github", "GitHub"),
("gitlab", "GitLab"),
]
def __new__(cls):
"""Singleton pattern."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._settings = {}
cls._instance._load()
return cls._instance
def _load(self):
"""Load settings from file."""
self._settings = self.DEFAULTS.copy()
if self._settings_file.exists():
try:
with open(self._settings_file) as f:
saved = json.load(f)
self._settings.update(saved)
except (json.JSONDecodeError, OSError):
pass
def _save(self):
"""Save settings to file."""
self._settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._settings_file, "w") as f:
json.dump(self._settings, f, indent=2)
def get(self, key: str, default=None):
"""Get a setting value."""
return self._settings.get(key, default)
def set(self, key: str, value):
"""Set a setting value and save."""
self._settings[key] = value
self._save()
@property
def deploy_docs_after_creation(self) -> bool:
"""Whether to deploy docs after creating a new project."""
return self.get("deploy_docs_after_creation", True)
@deploy_docs_after_creation.setter
def deploy_docs_after_creation(self, value: bool):
self.set("deploy_docs_after_creation", value)
@property
def default_project_path(self) -> Path:
"""Default path for new projects."""
return Path(self.get("default_project_path", str(Path.home() / "PycharmProjects")))
@default_project_path.setter
def default_project_path(self, value: Path):
self.set("default_project_path", str(value))
@property
def preferred_editor(self) -> str:
"""Preferred editor for opening files."""
return self.get("preferred_editor", "auto")
@preferred_editor.setter
def preferred_editor(self, value: str):
self.set("preferred_editor", value)
@property
def project_search_paths(self) -> list[str]:
"""List of directories to search for projects."""
return self.get("project_search_paths", [str(Path.home() / "PycharmProjects")])
@project_search_paths.setter
def project_search_paths(self, value: list[str]):
self.set("project_search_paths", value)
@property
def project_ignore_folders(self) -> list[str]:
"""Folder names to ignore during project discovery."""
return self.get("project_ignore_folders", ["trash", ".cache", "__pycache__", "node_modules"])
@project_ignore_folders.setter
def project_ignore_folders(self, value: list[str]):
self.set("project_ignore_folders", value)
@property
def auto_start_docs_server(self) -> bool:
"""Whether to auto-start the docs server on application startup."""
return self.get("auto_start_docs_server", True)
@auto_start_docs_server.setter
def auto_start_docs_server(self, value: bool):
self.set("auto_start_docs_server", value)
@property
def git_host_type(self) -> str:
"""Git hosting type (gitea, github, gitlab)."""
return self.get("git_host_type", "")
@git_host_type.setter
def git_host_type(self, value: str):
self.set("git_host_type", value)
@property
def git_host_url(self) -> str:
"""Git host URL."""
return self.get("git_host_url", "")
@git_host_url.setter
def git_host_url(self, value: str):
self.set("git_host_url", value)
@property
def git_host_owner(self) -> str:
"""Git host username or organization."""
return self.get("git_host_owner", "")
@git_host_owner.setter
def git_host_owner(self, value: str):
self.set("git_host_owner", value)
@property
def git_host_token(self) -> str:
"""Git host API token."""
return self.get("git_host_token", "")
@git_host_token.setter
def git_host_token(self, value: str):
self.set("git_host_token", value)
@property
def is_git_configured(self) -> bool:
"""Check if git hosting is configured."""
return bool(self.git_host_type and self.git_host_url and self.git_host_owner)
@property
def docs_mode(self) -> str:
"""Documentation mode (auto, standalone, project-docs)."""
return self.get("docs_mode", "auto")
@docs_mode.setter
def docs_mode(self, value: str):
self.set("docs_mode", value)
@property
def effective_docs_mode(self) -> str:
"""Get effective docs mode, resolving 'auto' to actual mode."""
mode = self.docs_mode
if mode == "auto":
# Check if project-docs exists in default location
project_docs = Path(self.project_search_paths[0]) / "project-docs" if self.project_search_paths else None
if project_docs and project_docs.exists():
return "project-docs"
return "standalone"
return mode
@property
def docs_root(self) -> Path:
"""Get the documentation root path."""
explicit = self.get("docs_root", "")
if explicit:
return Path(explicit).expanduser()
# Derive from mode
if self.effective_docs_mode == "project-docs":
return self.docusaurus_path / "docs" if self.docusaurus_path else Path.home() / ".local" / "share" / "development-hub" / "docs"
return Path.home() / ".local" / "share" / "development-hub" / "docs"
@docs_root.setter
def docs_root(self, value: Path | str):
self.set("docs_root", str(value) if value else "")
@property
def docusaurus_path(self) -> Path | None:
"""Get the path to the docusaurus project."""
explicit = self.get("docusaurus_path", "")
if explicit:
return Path(explicit).expanduser()
# Default location
if self.project_search_paths:
default = Path(self.project_search_paths[0]) / "project-docs"
if default.exists():
return default
return None
@docusaurus_path.setter
def docusaurus_path(self, value: Path | str | None):
self.set("docusaurus_path", str(value) if value else "")
@property
def pages_url(self) -> str:
"""Get the pages URL for documentation hosting."""
explicit = self.get("pages_url", "")
if explicit:
return explicit
# Derive from git_host_url for gitea
if self.git_host_type == "gitea" and self.git_host_url:
# https://gitea.example.com -> https://pages.example.com
import re
match = re.match(r"https?://gitea\.(.+)", self.git_host_url)
if match:
return f"https://pages.{match.group(1)}"
return ""
@pages_url.setter
def pages_url(self, value: str):
self.set("pages_url", value)
@property
def cmdforge_path(self) -> Path | None:
"""Get the CmdForge path."""
explicit = self.get("cmdforge_path", "")
if explicit:
return Path(explicit).expanduser()
# Default locations
if self.project_search_paths:
default = Path(self.project_search_paths[0]) / "CmdForge"
if default.exists():
return default
return None
@cmdforge_path.setter
def cmdforge_path(self, value: Path | str | None):
self.set("cmdforge_path", str(value) if value else "")
@property
def progress_dir(self) -> Path:
"""Get the progress log directory."""
explicit = self.get("progress_dir", "")
if explicit:
return Path(explicit).expanduser()
# Default: under docs_root
return self.docs_root / "progress"
@progress_dir.setter
def progress_dir(self, value: Path | str):
self.set("progress_dir", str(value) if value else "")
@property
def is_docs_enabled(self) -> bool:
"""Check if documentation features are available."""
mode = self.effective_docs_mode
if mode == "project-docs":
return self.docusaurus_path is not None and self.docusaurus_path.exists()
return True # standalone mode always works
@property
def is_cmdforge_available(self) -> bool:
"""Check if CmdForge is available."""
import shutil
# Check explicit path
if self.cmdforge_path and (self.cmdforge_path / ".venv" / "bin" / "cmdforge").exists():
return True
# Check PATH
return shutil.which("cmdforge") is not None
def export_workspace(self, path: Path) -> None:
"""Export current settings to a workspace file.
Args:
path: Path to write the workspace YAML file
"""
import yaml
workspace = {
"name": f"{self.git_host_owner}'s Development Environment" if self.git_host_owner else "Development Environment",
"version": 1,
"paths": {
"projects_root": self.project_search_paths[0] if self.project_search_paths else str(Path.home() / "Projects"),
},
"documentation": {
"enabled": self.is_docs_enabled,
"mode": self.docs_mode,
"auto_start_server": self.auto_start_docs_server,
},
}
# Add docs_root if explicit
if self.get("docs_root"):
workspace["paths"]["docs_root"] = self.get("docs_root")
# Add docusaurus_path if in project-docs mode
if self.effective_docs_mode == "project-docs" and self.docusaurus_path:
workspace["documentation"]["docusaurus_path"] = str(self.docusaurus_path)
# Add git hosting if configured
if self.is_git_configured:
workspace["git_hosting"] = {
"type": self.git_host_type,
"url": self.git_host_url,
"owner": self.git_host_owner,
}
if self.pages_url:
workspace["git_hosting"]["pages_url"] = self.pages_url
# Add feature flags
workspace["features"] = {
"cmdforge_integration": self.is_cmdforge_available,
"progress_tracking": True,
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
yaml.dump(workspace, f, default_flow_style=False, sort_keys=False)
def import_workspace(self, path: Path) -> dict:
"""Import settings from a workspace file.
Args:
path: Path to the workspace YAML file
Returns:
Dict with import results including any warnings
"""
import yaml
with open(path) as f:
workspace = yaml.safe_load(f)
results = {"imported": [], "warnings": []}
# Import paths
if "paths" in workspace:
paths = workspace["paths"]
if "projects_root" in paths:
projects_root = str(Path(paths["projects_root"]).expanduser())
self.project_search_paths = [projects_root]
self.default_project_path = Path(projects_root)
results["imported"].append("projects_root")
if "docs_root" in paths:
self.docs_root = paths["docs_root"]
results["imported"].append("docs_root")
# Import documentation settings
if "documentation" in workspace:
docs = workspace["documentation"]
if "mode" in docs:
self.docs_mode = docs["mode"]
results["imported"].append("docs_mode")
if "docusaurus_path" in docs:
self.docusaurus_path = docs["docusaurus_path"]
results["imported"].append("docusaurus_path")
if "auto_start_server" in docs:
self.auto_start_docs_server = docs["auto_start_server"]
results["imported"].append("auto_start_docs_server")
# Import git hosting
if "git_hosting" in workspace:
git = workspace["git_hosting"]
if "type" in git:
self.git_host_type = git["type"]
if "url" in git:
self.git_host_url = git["url"]
if "owner" in git:
self.git_host_owner = git["owner"]
if "pages_url" in git:
self.pages_url = git["pages_url"]
results["imported"].append("git_hosting")
# Mark setup as completed
self.set("setup_completed", True)
return results
def save_session(self, state: dict):
"""Save session state to file."""
self._session_file.parent.mkdir(parents=True, exist_ok=True)
try:
with open(self._session_file, "w") as f:
json.dump(state, f, indent=2)
except OSError:
pass
def load_session(self) -> dict:
"""Load session state from file."""
if self._session_file.exists():
try:
with open(self._session_file) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {}