Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/check_python_package.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Check

on:
push:
pull_request:
branches: ["main"]

jobs:
test:
strategy:
fail-fast: false
matrix:
runs-on: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
runs-on: ${{matrix.runs-on}}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}

- name: Install the project
run: uv sync --all-extras --dev

- name: Lint with ruff
run: uv run ruff check --output-format=github .

- name: Run tests
run: uv run pytest --cov=opltools --cov-report=term-missing
35 changes: 35 additions & 0 deletions examples/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from opltools import Suite, Problem, Library, Link, Variables, ValueRange
from pydantic_yaml import to_yaml_str
from opltools.schema import Implementation

things = {}

things["impl_py_cocoex"] = Implementation(
name="coco-experiment Python module",
description="Python bindings for the experiment part of the COCO framework",
language="python",
links=[
Link(type="repository", url="https://github.com/numbbo/coco-experiment"),
Link(type="package", url="https://pypi.org/project/coco-experiment/")
],
evaluation_time="sub second"
)

for fnr in range(1, 25):
id = f"fn_bbob_f{fnr}"
things[f"fn_bbob_f{fnr}"] = Problem(
name=f"BBOB F_{fnr}",
objectives={1},
variables=Variables(continuous=ValueRange(min=1, max=80)),
implementations=["impl_py_cocoex"]
)

things["suite_bbob"] = Suite(
name="BBOB",
problems={f"fn_bbob_f{fnr}" for fnr in range(1, 25)},
implementations=["impl_py_cocoex"]
)

library = Library(things)

print(to_yaml_str(library))
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[project]
name = "opltools"
version = "0.1.0"
description = "Tools to manage the Optimization Problem Library"
readme = "README.md"
authors = [
{ name = "Olaf Mersmann", email = "olafm@p-value.net" }
]
requires-python = ">=3.10"
dependencies = [
"pydantic>=2.13.3",
"pydantic-yaml>=1.6.0",
"pyyaml>=6.0.3",
]

[project.scripts]
opl = "opltools.cli:main"

[build-system]
requires = ["uv_build>=0.11.7,<0.12.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-cov>=7.1.0",
"ruff>=0.15.11",
]

[tool.ruff]
# In addition to the standard set of exclusions, omit all tests, plus a specific file.
extend-exclude = ["utils/*.py"]
15 changes: 15 additions & 0 deletions src/opltools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from .schema import Problem, Suite, Generator, Implementation, Library, YesNoSome, Link, Reference, Variables, ValueRange, Constraints

__all__ = [
"Problem",
"Suite",
"Generator",
"Implementation",
"Library",
"YesNoSome",
"Link",
"Reference",
"Constraints",
"Variables",
"ValueRange"
]
42 changes: 42 additions & 0 deletions src/opltools/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import sys
import argparse
from pydantic import ValidationError
from pydantic_yaml import parse_yaml_raw_as

from .schema import Library


def cmd_validate(args):
try:
with open(args.file) as f:
raw = f.read()
except OSError as e:
print(f"Error reading file: {e}", file=sys.stderr)
return 1

try:
parse_yaml_raw_as(Library, raw)
print(f"{args.file}: OK")
return 0
except ValidationError as e:
for error in e.errors():
loc = " -> ".join(str(p) for p in error["loc"]) if error["loc"] else "(root)"
print(f"{args.file}: {loc}: {error['msg']}")
return 1


def main():
parser = argparse.ArgumentParser(prog="opl", description="OPL tools")
subparsers = parser.add_subparsers(dest="command", required=True)

validate_parser = subparsers.add_parser("validate", help="Validate a YAML file against the Library schema")
validate_parser.add_argument("file", help="YAML file to validate")

args = parser.parse_args()

if args.command == "validate":
sys.exit(cmd_validate(args))


if __name__ == "__main__":
main()
Empty file added src/opltools/py.typed
Empty file.
162 changes: 162 additions & 0 deletions src/opltools/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from enum import Enum
from typing_extensions import Self
from pydantic import BaseModel, RootModel, ConfigDict, model_validator

from .utils import ValueRange, union_range


class OPLType(Enum):
problem = "problem"
suite = "suite"
generator = "generator"
implementation = 'implementation'


class YesNoSome(Enum):
yes = "yes"
no = "no"
some = "some"
unknown = "?"


class Link(BaseModel):
type: str | None = None
url: str


class Thing(BaseModel):
type: OPLType
model_config = ConfigDict(extra='allow')


class Objectives(RootModel):
root: int | set[int] | ValueRange = 0

def union(self, other: Self) -> Self:
self.root = union_range(self.root, other.root)
return self


class Variables(BaseModel):
continuous: int | set[int] | ValueRange = 0
integer: int | set[int] | ValueRange = 0
binary: int | set[int] | ValueRange = 0
categorical: int | set[int] | ValueRange = 0

def union(self, other: Self) -> Self:
self.continuous = union_range(self.continuous, other.continuous)
self.integer = union_range(self.integer, other.integer)
self.binary = union_range(self.integer, other.binary)
self.categorical = union_range(self.integer, other.categorical)
return self


class Constraints(BaseModel):
box: int | set[int] | ValueRange = 0
linear: int | set[int] | ValueRange = 0
function: int | set[int] | ValueRange = 0

def union(self, other: Variables) -> Self:
self.box = union_range(self.box, other.box)
self.linear = union_range(self.linear, other.linear)
self.function = union_range(self.function, other.function)
return self


class Reference(BaseModel):
title: str
authors: list[str]
link: Link | None = None


class Usage(BaseModel):
language: str
code: str


class Implementation(Thing):
type: OPLType= OPLType.implementation
name: str
description: str
links: list[Link] | None = None
language: str | None = None
evaluation_time: str | None = None
requirements: str | list[str] | None = None


class ProblemLike(Thing):
name: str
long_name: str | None = None
description: str | None = None
tags: set[str] | None = None
references: set[Reference] | None = None
implementations: set[str] | None = None
objectives: set[int] | None = None
variables: Variables | None = None
constraints: Constraints | None = None
soft_constraints: Constraints | None = None
dynamic_type: set[str] | None = None
noise_type: set[str] | None = None
allows_partial_evaluation: YesNoSome | None = None
can_evaluate_objectives_independently: YesNoSome | None = None
modality: set[str] | None = None
fidelity_levels: set[int] | None = None
code_examples: set[str] | None = None
source: set[str] | None = None


class Problem(ProblemLike):
type:OPLType = OPLType.problem
instances: ValueRange | list[str] | None = None


class Suite(ProblemLike):
type:OPLType = OPLType.suite
problems: set[str] | None = None


class Generator(ProblemLike):
type:OPLType = OPLType.generator


class Library(RootModel):
root: dict[str, Problem | Generator | Suite | Implementation] | None

def _check_id_references(self, ids, type:OPLType) -> None:
if not self.root:
return
for id in ids:
if id in self.root:
if self.root[id].type != type:
raise ValueError(f"ID {id} is a {self.root[id].name}, expected a {type.name}")
else:
raise ValueError(f"Missing {type.name} with id '{id}'")

def _fixup_fidelity(self, suite: Suite) -> Suite:
return suite

@model_validator(mode="after")
def validate(self) -> Self:
if not self.root:
return self

# Make sure all problems referenced in suites exists
for id, thing in self.root.items():
if isinstance(thing, Suite) and thing.problems:
for problem_id in thing.problems:
if problem_id not in self.root:
raise ValueError(f"Suite {id} references problem with undefined id '{problem_id}'.")
if self.root[problem_id].type != OPLType.problem:
raise ValueError(f"Suite {id} references problem with id '{problem_id}' but id is a {self.root[problem_id].type.name}.")
return self

__all__ = [
"Problem",
"Suite",
"Generator",
"Implementation",
"Library",
"YesNoSome",
"Link",
"Reference"
]
61 changes: 61 additions & 0 deletions src/opltools/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from typing_extensions import Self
from pydantic import BaseModel, model_validator


class ValueRange(BaseModel):
min: int | None
max: int | None

@model_validator(mode="after")
def _check(self) -> Self:
if not self.min and not self.max:
raise ValueError("Variable range should have at least a min or max value.")
return self


def _none_min(a, b):
if a and b:
return min(a, b)
elif a:
return a
else:
return b


def _none_max(a, b):
if a and b:
return max(a, b)
elif a:
return a
else:
return b


def union_range(
a: int | set[int] | ValueRange,
b: int | set[int] | ValueRange
) -> int | set[int] | ValueRange:
if isinstance(a, int):
a = { a }
if isinstance(b, int):
b = { b }

if isinstance(a, set) and isinstance(b, set):
res = a.union(b)
return res.pop() if len(res) == 1 else res
elif isinstance(a, ValueRange) and isinstance(b, ValueRange):
return ValueRange(min=_none_min(a.min, b.min), max=_none_max(a.max, b.max))

if isinstance(a, set):
a, b = b, a
if isinstance(a, ValueRange) and isinstance(b, set):
res = ValueRange(min=a.min, max=a.max)
if res.min:
for v in b:
res.min = min(v, res.min)
if res.max:
for v in b:
res.max = max(v, res.max)#
return res

raise Exception("BAM")
Empty file added tests/__init__.py
Empty file.
Loading