seitime-frappe/frappe/bench_interface.py
David Arnold 0ff4c73e18
fix: bencher scoping (#28703)
* fix(bench_interface): sites loading

* feat(bench_interface): guard against naive scoping back to all sites

* refactor(bench_interface): remove 'scope' methods for (newer) site_name
2024-12-08 01:37:42 +00:00

480 lines
14 KiB
Python

"""
This module provides a consolidated API for interacting with the Frappe bench environment.
It includes classes and utilities to manage and access various components of the bench,
such as applications, sites, logs, and configuration.
### Key Classes
- `Bench`: The central class representing the Frappe bench environment.
- `Apps`: Manages applications within the bench.
- `Sites`: Manages sites within the bench, including site configuration and scoping to a single site.
- `Logs`: Handles log files for the bench.
- `Run`: Manages the run/config directory of the bench.
### Usage
This module is designed to be used as the **only** interface to the Frappe bench, allowing for
well-specified and central interaction with its components.
### Example
```python
from bench_interface import Bench
# Initialize the bench
bench = Bench()
# Access applications
apps = bench.apps
for app in apps:
print(app.name)
# Access sites
sites = bench.sites
for site in sites:
print(site.name)
# Scope to a specific site
scoped_site = bench.sites.scope("site_name")
print(scoped_site.name)
```
### Notes
- This module uses environment variables and default paths to locate bench and its compontents.
- It ensures thread-safety and security by storing site names in thread-local storage.
- Mangled attributes ensure that no accidential api surface is construed.
"""
import json
import os
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Final, cast
from typing_extensions import override
from frappe.config import ConfigHandler, ConfigType
__all__ = [
"Bench",
"BenchNotScopedError",
"BenchSiteNotLoadedError",
]
# used to implement legacy code paths to keep the main path clean
_current_bench: "Bench"
class BenchNotScopedError(NotImplementedError):
pass
class BenchSiteNotLoadedError(ValueError):
pass
class _Serializable:
def __json__(self) -> dict[str, Any]: # type: ignore[no-any-explicit]
return {k: v for k, v in self.__dict__.items()} # type: ignore[no-any-expr]
class _PathResolvable:
path: Path
__path_name: str
__env_variable: str
def __init__(self, path: str | Path | None = None, parent_path: Path | None = None) -> None:
if path is None:
path = os.environ.get(cast(str, getattr(self, f"_{self.__class__.__name__}__env_variable")))
if path is None and parent_path:
path = parent_path.joinpath(
cast(str, getattr(self, f"_{self.__class__.__name__}__path_name"))
)
if path is None:
raise ValueError(f"Unable to determine path for {self.__class__.__name__}")
self.path = Path(path).resolve()
# os.PathLike
def __fspath__(self) -> str:
return str(self.path)
class _FrappeComponent:
name: str
path: Path
python_path: Path
app: "App"
def __bool__(self) -> bool:
return (
self._is_frappe_component()
or (isinstance(self, App) and self._is_app_installed())
or (isinstance(self, Module) and self._is_module_registered())
)
def _is_frappe_component(self) -> bool:
return self.python_path.is_dir() and self.python_path.joinpath(".frappe").exists()
def _is_app_installed(self) -> bool:
global _current_bench
res = self.name in _current_bench.sites.path.joinpath("apps.txt").read_text()
if res:
from frappe.deprecation_dumpster import deprecation_warning
deprecation_warning(
"2024-10-18",
"yet unknown",
f"Instead of adding {self.name} to sites/apps.txt, drop an empty file at {self.python_path}/.frappe",
)
return res
return False
def _is_module_registered(self) -> bool:
res = self.name.title() in self.app.python_path.joinpath("modules.txt").read_text()
if res:
from frappe.deprecation_dumpster import deprecation_warning
deprecation_warning(
"2024-10-18",
"yet unknown",
f"Instead of adding {self.name.title()} to {self.app.python_path}/modules.txt, drop an empty file at {self.python_path}/.frappe",
)
return res
return False
# os.PathLike
def __fspath__(self) -> str:
return str(self.path)
class Module(_FrappeComponent, _Serializable):
def __init__(self, *, name: str, path: Path, app: "App") -> None:
self.name = name
self.path = path
self.python_path = self.path
self.app = app
@override
def __repr__(self) -> str:
return (
super().__repr__()
+ ("\n * Path: " + str(self.path))
+ ("\n * Python Path: " + str(self.python_path))
)
@override
def __json__(self) -> dict[str, Any]: # type: ignore[no-any-explicit,no-any-decorated]
excluded = (
"app", # prevent circular deps
)
return {k: v for k, v in self.__dict__.items() if k not in excluded} # type: ignore[no-any-expr]
class App(_FrappeComponent, _Serializable):
__modules: dict[str, Module]
def __init__(self, *, name: str, path: Path):
self.name = name
self.path = path
self.python_path = self.path.joinpath(self.name)
def __iter__(self) -> Iterator[Module]:
return iter(self.modules.values())
def __len__(self) -> int:
return len(self.modules)
def __getitem__(self, key: str) -> Module:
return self.modules[key]
@override
def __repr__(self) -> str:
return (
super().__repr__()
+ ("\n * Path: " + str(self.path))
+ ("\n * Python Path: " + str(self.python_path))
+ ("\n * Modules:\n\t" + ("\n\t".join([str(module) for module in self.modules]) or "n/a"))
)
@override
def __str__(self) -> str:
return self.name
@override
def __json__(self) -> dict[str, Module]:
return self.modules
@property
def modules(self) -> dict[str, Module]:
if not hasattr(self, "__modules"):
self.__modules = {
d.name: module
for d in self.python_path.iterdir()
if d.is_dir() and (module := Module(name=d.name, path=d, app=self))
}
return self.__modules
class Apps(_PathResolvable, _Serializable):
__apps: dict[str, App]
__path_name = "apps"
__env_variable = "FRAPPE_APPS_PATH"
def __init__(self, path: str | Path | None = None, parent_path: Path | None = None) -> None:
super().__init__(path, parent_path)
def __iter__(self) -> Iterator[App]:
return iter(self.apps.values())
def __len__(self) -> int:
return len(self.apps)
def __getitem__(self, key: str) -> App:
return self.apps[key]
@override
def __repr__(self) -> str:
return (
super().__repr__()
+ ("\n * Apps Path: " + str(self.path))
+ ("\n * Loaded Apps:\n\t" + ("\n\t".join([str(app) for app in self.apps]) or "n/a"))
)
@override
def __json__(self) -> dict[str, App]:
return self.apps
@property
def apps(self) -> dict[str, App]:
if not hasattr(self, "__apps"):
self.__apps = {
d.name: app for d in self.path.iterdir() if d.is_dir() and (app := App(name=d.name, path=d))
}
return self.__apps
class Logs(_PathResolvable, _Serializable):
__path_name = "logs"
__env_variable = "FRAPPE_LOGS_PATH"
def __init__(self, *, path: str | Path | None = None, parent_path: Path | None = None):
super().__init__(path, parent_path)
def get_log_file(self, log_type: str) -> Path:
return self.path.joinpath(f"{log_type}.log")
class Run(_PathResolvable, _Serializable):
# config is the legacy naming of this folder; a misnomer
__path_name = "config"
__env_variable = "FRAPPE_RUN_PATH"
class Site(ConfigHandler, _Serializable):
name: str
path: Path
bench: "Bench"
_combined_config: ConfigType
def __init__(self, *, bench: "Bench", name: str, path: str | Path):
self.name = name
self.path = Path(path)
self.bench: Bench = bench
ConfigHandler.__init__(self, self.path.joinpath("site_config.json"))
@property
@override
def config(self) -> ConfigType:
if not hasattr(self, "_combined_config") or self._config_stale or self.bench.sites._config_stale:
site_config = super().config.copy()
config = self.bench.sites.config.copy()
config.update(site_config)
self._combined_config = ConfigType(**config)
return self._combined_config
# os.PathLike
def __fspath__(self) -> str:
return str(self.path)
@override
def __repr__(self) -> str:
return super().__repr__() + ("\n * Site Path: " + str(self.path)) + ("\n * Site Name: " + self.name)
@override
def __eq__(self, o: Any) -> bool: # type: ignore[no-any-explicit,no-any-decorated]
return self.name == str(o) # type: ignore[no-any-expr]
@override
def __hash__(self) -> int:
return hash(self.name)
@override
def __str__(self) -> str:
return self.name
@override
def __json__(self) -> dict[str, Any]: # type: ignore[no-any-explicit,no-any-decorated]
excluded = (
"bench", # prevent circular deps
"_ConfigHandler__config", # holds file contents
"_config_stale", # never stale after accessored
"_config",
)
naming = {"_combined_config": "config"}
self.config # ensure config is loaded
return {naming.get(k, k): v for k, v in self.__dict__.items() if k not in excluded} # type: ignore[no-any-expr]
class Sites(_PathResolvable, ConfigHandler, _Serializable):
SCOPE_ALL_SITES: Final = "__scope-all-sites__"
__sites: dict[str, Site]
__path_name = "sites"
__env_variable = "FRAPPE_SITES_PATH"
if legacy_env_path := os.environ.get("SITES_PATH"):
os.environ["FRAPPE_SITES_PATH"] = legacy_env_path
def __init__(self, *, bench: "Bench", path: str | Path | None = None, parent_path: Path | None = None):
_PathResolvable.__init__(self, path, parent_path)
ConfigHandler.__init__(self, self.path.joinpath("common_site_config.json"))
self.bench = bench
# security & thread-safety: site_name is stored in thread-local storage
self.site_name = os.environ.get("FRAPPE_SITE") or cast(str | None, self.config.get("default_site"))
@property
def site_name(self) -> str | None:
import frappe
return cast(str | None, getattr(frappe.local, "site_name", None))
@site_name.setter
def site_name(self, value):
if self.site_name is not None and value == self.SCOPE_ALL_SITES:
raise BenchNotScopedError("Cannot scope back to SCOPE_ALL_SITES when already scoped")
import frappe
frappe.local.site_name = value
if hasattr(self, "_Sites__sites"):
del self.__sites
def add_site(self, site_name: str) -> None:
site_path = self.path.joinpath(site_name)
for dir_path in [
site_path.joinpath("public", "files"),
site_path.joinpath("private", "backups"),
site_path.joinpath("private", "files"),
site_path.joinpath("locks"),
site_path.joinpath("logs"),
]:
dir_path.mkdir(parents=True, exist_ok=True)
self.__sites[site_name] = Site(bench=self.bench, name=site_name, path=site_path)
def remove_site(self, site_name: str) -> None:
if site_name in self.__sites:
del self.__sites[site_name]
# site_path = self.path.joinpath(site_name)
# Note: This doesn't actually delete the site directory, just removes it from the sites dict
# Actual deletion should be handled separately with proper safeguards
@property
def scoped(self) -> bool:
return bool(self.site_name) and self.site_name != self.SCOPE_ALL_SITES
@property
def site(self) -> Site:
# security & thread-safety: site_name is stored in thread-local storage
if not self.site_name or self.site_name == self.SCOPE_ALL_SITES:
raise BenchNotScopedError("Sites was not scoped to a single site, yet.")
return self[self.site_name]
@property
def _sites(self) -> dict[str, Site]:
if not hasattr(self, "_Sites__sites"):
self.__sites = {}
# security & thread-safety: site_name is stored in thread-local storage
if self.site_name == self.SCOPE_ALL_SITES:
for path in self.path.iterdir():
if path.is_dir() and path.joinpath("site_config.json").exists():
self.__sites[path.name] = Site(bench=self.bench, name=path.name, path=path)
elif self.site_name:
# init scoped site even if it doesn't exist for use during site creation
# all file access must reamin lazy and only happen after completed setup
self.__sites[self.site_name] = Site(
bench=self.bench, name=self.site_name, path=(self.path / self.site_name)
)
return self.__sites
def __iter__(self) -> Iterator[Site]:
# security & thread-safety: site_name is stored in thread-local storage
if self.site_name == self.SCOPE_ALL_SITES:
return iter(self._sites.values())
elif self.site_name:
return iter([self[self.site_name]])
raise BenchNotScopedError("Sites was not scoped, yet.")
def __len__(self) -> int:
return len(self._sites)
def __getitem__(self, key: str) -> Site:
try:
return self._sites[key]
except KeyError:
raise BenchSiteNotLoadedError(f"Site '{key}' was not found")
@override
def __repr__(self) -> str:
return (
super().__repr__()
+ ("\n * Sites Path: " + str(self.path))
+ ("\n * Scoped Site: " + (self.site_name or "n/a"))
+ ("\n * Loaded Sites:\n\t" + ("\n\t".join([str(site) for site in self]) or "n/a"))
)
@override
def __json__(self) -> dict[str, Any]: # type: ignore[no-any-explicit,no-any-decorated]
excluded = (
"bench", # prevent circular deps
"_ConfigHandler__config", # holds file contents
"_config_stale", # never stale after accessored
)
naming = {"_config": "config", "_Sites__sites": "_sites"}
self._sites # ensure sites are loaded
return {naming.get(k, k): v for k, v in self.__dict__.items() if k not in excluded} # type: ignore[no-any-expr]
class Bench(_PathResolvable, _Serializable):
__env_variable = "FRAPPE_BENCH_PATH"
if legacy_env_path := os.environ.get("FRAPPE_BENCH_ROOT"):
os.environ["FRAPPE_BENCH_PATH"] = legacy_env_path
if not os.environ.get("FRAPPE_BENCH_PATH") and (legacy_env_path_sites := os.environ.get("SITES_PATH")):
os.environ["FRAPPE_BENCH_PATH"] = str(Path(legacy_env_path_sites).resolve().parent)
if not os.environ.get("FRAPPE_BENCH_PATH"):
# bench folder in standard layout
os.environ["FRAPPE_BENCH_PATH"] = str(Path(__file__).resolve().parents[3])
def __init__(self, path: str | Path | None = None):
super().__init__(path)
self.logs = Logs(parent_path=self.path)
self.run = Run(parent_path=self.path)
self.sites = Sites(bench=self, parent_path=self.path)
global _current_bench
_current_bench = self
self.apps = Apps(parent_path=self.path)
@property
def site(self) -> Site:
return self.sites.site
@property
def scoped(self) -> bool:
return self.sites.scoped
@override
def __repr__(self) -> str:
return (
super().__repr__()
+ ("\n * Bench Path: " + str(self.path))
+ ("\n\n" + str(self.apps))
+ ("\n\n" + str(self.sites))
)