diff --git a/benchmarks/cli.py b/benchmarks/cli.py new file mode 100644 index 0000000..9bef296 --- /dev/null +++ b/benchmarks/cli.py @@ -0,0 +1,55 @@ +import sys +from pathlib import Path + +import typer +from pydantic_core import ValidationError + +from benchmarks.core.config import ConfigParser +from benchmarks.deluge.config import DelugeExperimentConfig + +parser = ConfigParser() +parser.register(DelugeExperimentConfig) + +app = typer.Typer() + + +def _parse_config(config: Path): + if not config.exists(): + print(f'Config file {config} does not exist.') + sys.exit(-1) + + with config.open(encoding='utf-8') as config: + try: + return parser.parse(config) + except ValidationError as e: + print(f'There were errors parsing the config file.') + for error in e.errors(): + print(f' - {error["loc"]}: {error["msg"]} {error["input"]}') + sys.exit(-1) + + +@app.command() +def list(config: Path): + """ + Lists the experiments available in CONFIG. + """ + parsed = _parse_config(config) + print(f'Available experiments in {config}:') + for experiment in parsed.keys(): + print(f' - {experiment}') + + +@app.command() +def run(config: Path, experiment: str): + """ + Runs the experiment with name EXPERIMENT. + """ + parsed = _parse_config(config) + if experiment not in parsed: + print(f'Experiment {experiment} not found in {config}.') + sys.exit(-1) + parsed[experiment].run() + + +if __name__ == '__main__': + app() diff --git a/benchmarks/core/config.py b/benchmarks/core/config.py index 9594796..7ef45a5 100644 --- a/benchmarks/core/config.py +++ b/benchmarks/core/config.py @@ -1,22 +1,28 @@ """Basic utilities for structuring experiment configurations based on Pydantic schemas.""" - +import os import re from abc import abstractmethod -from typing import Annotated +from io import TextIOBase +from typing import Annotated, Type, Dict, TextIO +import yaml from pydantic import BaseModel, IPvAnyAddress, AfterValidator -from typing_extensions import Generic +from typing_extensions import Generic, overload -from benchmarks.core.experiments.experiments import TExperiment +from benchmarks.core.experiments.experiments import TExperiment, Experiment def drop_config_suffix(name: str) -> str: return name[:-6] if name.endswith('Config') else name +def to_snake_case(name: str) -> str: + return re.sub(r'(? TExperiment: pass + + +class ConfigParser: + """ + :class:`ConfigParser` is a utility class to parse configuration files into :class:`ExperimentBuilder`s. + Currently, each :class:`ExperimentBuilder` can appear at most once in the config file. + """ + + def __init__(self): + self.root_tags = {} + + def register(self, root: Type[ExperimentBuilder[Experiment]]): + name = root.__name__ + alias = root.model_config.get('alias_generator', lambda x: x)(name) + self.root_tags[alias] = root + + @overload + def parse(self, data: dict) -> Dict[str, ExperimentBuilder[Experiment]]: + ... + + @overload + def parse(self, data: TextIO) -> Dict[str, ExperimentBuilder[Experiment]]: + ... + + def parse(self, data: dict | TextIO) -> Dict[str, ExperimentBuilder[Experiment]]: + if isinstance(data, TextIOBase): + entries = yaml.safe_load(os.path.expandvars(data.read())) + else: + entries = data + + return { + tag: self.root_tags[tag].model_validate(config) + for tag, config in entries.items() + } diff --git a/benchmarks/core/tests/test_config.py b/benchmarks/core/tests/test_config.py index 23ccd67..6aa3d71 100644 --- a/benchmarks/core/tests/test_config.py +++ b/benchmarks/core/tests/test_config.py @@ -1,9 +1,13 @@ +import os +from io import StringIO from ipaddress import IPv4Address, IPv6Address +from typing import cast import pytest -from pydantic import ValidationError +import yaml +from pydantic import ValidationError, BaseModel -from benchmarks.core.config import Host, DomainName +from benchmarks.core.config import Host, DomainName, ConfigParser, ConfigModel def test_should_parse_ipv4_address(): @@ -47,3 +51,51 @@ def test_should_fail_invalid_names(): for invalid_name in invalid_names: with pytest.raises(ValidationError): Host(address=invalid_name) + + +class Root1(ConfigModel): + index: int + + +class Root2(ConfigModel): + name: str + + +def test_should_parse_multiple_roots(): + config_file = StringIO(""" + root1: + index: 1 + + root2: + name: "root2" + """) + + parser = ConfigParser() + + parser.register(Root1) + parser.register(Root2) + + conf = parser.parse(yaml.safe_load(config_file)) + + assert cast(Root1, conf['root1']).index == 1 + assert cast(Root2, conf['root2']).name == 'root2' + + +def test_should_expand_env_vars_when_fed_a_config_file(): + config_file = StringIO(""" + root1: + index: ${BTB_MY_INDEX} + root2: + name: "My name is ${BTB_NAME}" + """) + + os.environ['BTB_MY_INDEX'] = '10' + os.environ['BTB_NAME'] = 'John Doe' + + parser = ConfigParser() + parser.register(Root1) + parser.register(Root2) + + conf = parser.parse(config_file) + assert cast(Root1, conf['root1']).index == 10 + assert cast(Root2, conf['root2']).name == 'My name is John Doe' diff --git a/poetry.lock b/poetry.lock index 7f362dc..af6627a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,20 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" @@ -44,6 +58,41 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "mypy" version = "1.13.0" @@ -281,6 +330,20 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pytest" version = "8.3.3" @@ -363,6 +426,35 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "torrentool" version = "1.2.0" @@ -377,6 +469,23 @@ files = [ [package.extras] cli = ["click"] +[[package]] +name = "typer" +version = "0.13.1" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.13.1-py3-none-any.whl", hash = "sha256:5b59580fd925e89463a29d363e0a43245ec02765bde9fb77d39e5d0f29dd7157"}, + {file = "typer-0.13.1.tar.gz", hash = "sha256:9d444cb96cc268ce6f8b94e13b4335084cef4c079998a9f4851a90229a3bd25c"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "types-pyyaml" version = "6.0.12.20240917" @@ -402,4 +511,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "c10ab6006a3097ae8fcbac02448e98cf18f61146ab311979e1e9d5e735e2369d" +content-hash = "f3c2c0480921a5d481f0e8de78329998a8fddae7ccecc6cb9cb9a01e3f6137c5" diff --git a/pyproject.toml b/pyproject.toml index 8e3b12a..aa25a89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ pathvalidate = "^3.2.1" torrentool = "^1.2.0" pydantic = "^2.10.2" pyyaml = "^6.0.2" +typer = "^0.13.1" [tool.poetry.group.test.dependencies] pytest = "^8.3.3"