refactor: simplify bencher (#28694)
* chore: flatten bencher class layout * chore: rename bencher to clarify * docs: add file level docstrings
This commit is contained in:
parent
c5b7910ded
commit
fef569e284
5 changed files with 221 additions and 132 deletions
|
|
@ -51,7 +51,7 @@ from frappe.query_builder.utils import (
|
|||
from frappe.utils.caching import request_cache
|
||||
from frappe.utils.data import cint, cstr, sbool
|
||||
|
||||
from .bencher import Bench
|
||||
from .bench_interface import Bench
|
||||
|
||||
# Local application imports
|
||||
from .exceptions import *
|
||||
|
|
|
|||
|
|
@ -1,3 +1,46 @@
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
|
@ -8,6 +51,14 @@ from typing_extensions import override
|
|||
|
||||
from frappe.config import ConfigHandler, ConfigType
|
||||
|
||||
__all__ = [
|
||||
"Apps",
|
||||
"Bench",
|
||||
"Logs",
|
||||
"Run",
|
||||
"Sites",
|
||||
]
|
||||
|
||||
# used to implement legacy code paths to keep the main path clean
|
||||
_current_bench: "Bench"
|
||||
|
||||
|
|
@ -64,13 +115,13 @@ class BenchPathResolver(PathLike, Serializable):
|
|||
class FrappeComponent:
|
||||
python_path: Path
|
||||
name: str
|
||||
app: "Apps.App"
|
||||
app: "App"
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return (
|
||||
self._is_frappe_component()
|
||||
or (isinstance(self, Apps.App) and self._is_app_installed())
|
||||
or (isinstance(self, Apps.App.Module) and self._is_module_registered())
|
||||
or (isinstance(self, App) and self._is_app_installed())
|
||||
or (isinstance(self, Module) and self._is_module_registered())
|
||||
)
|
||||
|
||||
def _is_frappe_component(self) -> bool:
|
||||
|
|
@ -104,74 +155,76 @@ class FrappeComponent:
|
|||
return False
|
||||
|
||||
|
||||
class Module(FrappeComponent, PathLike, Serializable):
|
||||
def __init__(self, *, name: str, path: Path, app: "App") -> None:
|
||||
self.app = app
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.python_path = self.path
|
||||
|
||||
@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, PathLike, 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)
|
||||
|
||||
@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
|
||||
|
||||
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]
|
||||
|
||||
|
||||
class Apps(BenchPathResolver):
|
||||
__apps: dict[str, "Apps.App"]
|
||||
|
||||
class App(FrappeComponent, PathLike, Serializable):
|
||||
__modules: dict[str, "Apps.App.Module"]
|
||||
|
||||
class Module(FrappeComponent, PathLike, Serializable):
|
||||
def __init__(self, *, name: str, path: Path, app: "Apps.App") -> None:
|
||||
self.app = app
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.python_path = self.path
|
||||
|
||||
@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]
|
||||
|
||||
def __init__(self, *, name: str, path: Path):
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.python_path = self.path.joinpath(self.name)
|
||||
|
||||
@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 := self.Module(name=d.name, path=d, app=self))
|
||||
}
|
||||
return self.__modules
|
||||
|
||||
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]
|
||||
__apps: dict[str, App]
|
||||
|
||||
def __init__(self, path: str | Path | None = None, parent_path: Path | None = None) -> None:
|
||||
super().__init__(path, parent_path)
|
||||
|
|
@ -192,9 +245,7 @@ class Apps(BenchPathResolver):
|
|||
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 := self.App(name=d.name, path=d))
|
||||
d.name: app for d in self.path.iterdir() if d.is_dir() and (app := App(name=d.name, path=d))
|
||||
}
|
||||
return self.__apps
|
||||
|
||||
|
|
@ -226,58 +277,57 @@ class Run(BenchPathResolver):
|
|||
)
|
||||
|
||||
|
||||
class Site(ConfigHandler, PathLike, Serializable):
|
||||
_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"))
|
||||
|
||||
@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]
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class Sites(BenchPathResolver, ConfigHandler):
|
||||
SCOPE_ALL_SITES: Final = "__scope-all-sites__"
|
||||
__sites: dict[str, "Sites.Site"]
|
||||
|
||||
class Site(ConfigHandler, PathLike, Serializable):
|
||||
_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"))
|
||||
|
||||
@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]
|
||||
|
||||
@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
|
||||
__sites: dict[str, Site]
|
||||
|
||||
def __init__(self, *, bench: "Bench", path: str | Path | None = None, parent_path: Path | None = None):
|
||||
BenchPathResolver.__init__(self, path, parent_path)
|
||||
|
|
@ -328,7 +378,7 @@ class Sites(BenchPathResolver, ConfigHandler):
|
|||
site_path.joinpath("logs"),
|
||||
]:
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
self.__sites[site_name] = self.Site(bench=self.bench, name=site_name, path=site_path)
|
||||
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:
|
||||
|
|
@ -337,7 +387,7 @@ class Sites(BenchPathResolver, ConfigHandler):
|
|||
# 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
|
||||
|
||||
def scope(self, site_name: str | None = None) -> "Sites.Site":
|
||||
def scope(self, site_name: str | None = None) -> Site:
|
||||
if site_name is None:
|
||||
return self.site
|
||||
|
||||
|
|
@ -369,11 +419,11 @@ class Sites(BenchPathResolver, ConfigHandler):
|
|||
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] = self.Site(bench=self.bench, name=path.name, path=path)
|
||||
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] = self.Site(
|
||||
self.__sites[self.site_name] = Site(
|
||||
bench=self.bench, name=self.site_name, path=(self.path / self.site_name)
|
||||
)
|
||||
return self.__sites
|
||||
|
|
@ -407,10 +457,10 @@ class Bench(BenchPathResolver):
|
|||
self.apps = Apps(parent_path=self.path)
|
||||
|
||||
@property
|
||||
def site(self) -> Sites.Site:
|
||||
def site(self) -> Site:
|
||||
return self.sites.site
|
||||
|
||||
def scope(self, site_name: str) -> Sites.Site:
|
||||
def scope(self, site_name: str) -> Site:
|
||||
return self.sites.scope(site_name)
|
||||
|
||||
@property
|
||||
|
|
@ -1,3 +1,42 @@
|
|||
"""
|
||||
This module provides a comprehensive configuration management system for the Frappe framework.
|
||||
It includes classes and functions to register, load, store, and update configuration options
|
||||
from various sources such as files, environment variables.
|
||||
|
||||
### Key Classes
|
||||
- `ConfigType`: A dictionary subclass that provides attribute-style access to configuration options
|
||||
and warns when accessing unregistered configuration options.
|
||||
- `ConfigRegistry`: A registry for configuration options with their documentation and default values.
|
||||
- `ConfigHandler`: Handles loading, storing, and updating configuration values from files and environment.
|
||||
Supports hot reloading of configuration upon changes.
|
||||
|
||||
### Configuration Registry
|
||||
The `ConfigRegistry` class allows registering configuration options with their documentation and default values.
|
||||
This registry is used to manage and document all available configuration options.
|
||||
|
||||
### Configuration Handling
|
||||
The `ConfigHandler` class is responsible for loading configuration from files, updating it from environment variables,
|
||||
and applying additional configuration from external modules. It also supports hot reloading of the configuration.
|
||||
|
||||
### Example Usage
|
||||
```python
|
||||
from config_manager import register
|
||||
|
||||
# Register a new configuration option
|
||||
register("new_option", "Documentation for the new option", "default_value")
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Configuration options can be registered with default values that can be either static or dynamic (callable).
|
||||
- Environment variables are used to override configuration values.
|
||||
- Additional configuration can be applied from external modules using the `extra_config` option.
|
||||
- The module uses file locking to ensure safe updates to the configuration file.
|
||||
|
||||
### Global Configuration
|
||||
The module includes global default configurations for common Frappe settings such as Redis URLs, database connections,
|
||||
and more. These can be overridden using environment variables or by updating the configuration file.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# BEWARE don't put anything in this file except exceptions
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from .bencher import (
|
||||
from .bench_interface import (
|
||||
BenchNotScopedError,
|
||||
BenchSiteNotLoadedError,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ files = [
|
|||
"frappe/types/docref.py",
|
||||
"frappe/types/frappedict.py",
|
||||
"frappe/types/filter.py",
|
||||
"frappe/bencher.py",
|
||||
"frappe/bench_interface.py",
|
||||
"frappe/config.py",
|
||||
]
|
||||
exclude = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue