refactor: simplify bencher (#28694)

* chore: flatten bencher class layout

* chore: rename bencher to clarify

* docs: add file level docstrings
This commit is contained in:
David Arnold 2024-12-07 15:10:03 +01:00 committed by GitHub
parent c5b7910ded
commit fef569e284
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 221 additions and 132 deletions

View file

@ -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 *

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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 = [