Skip to content

Commit

Permalink
feat: support exporting notebook models
Browse files Browse the repository at this point in the history
  • Loading branch information
mtth committed Sep 12, 2024
1 parent eee9ce3 commit 68248ea
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 208 deletions.
395 changes: 202 additions & 193 deletions poetry.lock

Large diffs are not rendered by default.

67 changes: 52 additions & 15 deletions src/opvious/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
import sys
from typing import Any, Mapping, Optional

from .common import __version__
from .client import Client
from .specifications import load_notebook_models, LocalSpecification
from . import __version__, Client, LocalSpecification, load_notebook_models
from .modeling import Model

_COMMAND = "python -m opvious"

Expand All @@ -23,6 +22,7 @@
{_COMMAND} register-notebook PATH [MODEL]
[-dn NAME] [-t TAGS] [--allow-empty]
{_COMMAND} register-sources GLOB [-dn NAME] [-t TAGS]
{_COMMAND} export-notebook-model PATH [MODEL] [-o PATH]
{_COMMAND} (-h | --help)
{_COMMAND} --version
Expand All @@ -33,6 +33,7 @@
server
-n, --name NAME Formulation name. By default this name is inferred
from the file's name, omitting the extension
-o, --out PATH Path where to store the exported model.
-t, --tags TAGS Comma-separated list of tags. By default only the
`latest` tag is added
--version Show SDK version
Expand Down Expand Up @@ -80,18 +81,11 @@ async def handle_notebook(
name: Optional[str],
allow_empty: bool,
) -> None:
sn = load_notebook_models(path, allow_empty=allow_empty)
if model_name is None:
model_names = list(sn.__dict__.keys())
if not self._dry_run and len(model_names) != 1:
raise Exception(f"Notebook has 0 or 2+ models ({model_names})")
else:
model_names = [model_name]
if name is None:
name = _default_name(path)
for model_name in model_names:
model = getattr(sn, model_name)
await self._handle(model.specification(), name)
models = _load_notebook_models(path, model_name)
if self._dry_run:
return
_name, model = _singleton_model(models)
await self._handle(model.specification(), name or _default_name(path))

async def handle_sources(self, glob: str, name: Optional[str]) -> None:
if name is None:
Expand All @@ -104,10 +98,53 @@ def _default_name(path: str) -> str:
return os.path.splitext(os.path.basename(path))[0]


def _load_notebook_models(
path: str,
model_name: Optional[str],
) -> dict[str, Model]:
sn = load_notebook_models(path, allow_empty=True)
if model_name is None:
return {k: v for k, v in sn.__dict__.items() if isinstance(v, Model)}
return {model_name: getattr(sn, model_name)}


def _singleton_model(models: dict[str, Model]) -> tuple[str, Model]:
if len(models) != 1:
raise Exception(
"Notebook has 0 or 2+ models, please specify a model "
"name to select one"
)
return next(iter(models.items()))


async def _export_notebook_model(
client: Client,
notebook_path: str,
model_name: Optional[str] = None,
export_path: Optional[str] = None,
) -> None:
models = _load_notebook_models(notebook_path, model_name)
name, model = _singleton_model(models)
if not export_path:
export_path = f"{name}.proto"
with open(export_path, "bw+") as writer:
await client.export_specification(model.specification(), writer)


async def _run(args: Mapping[str, Any]) -> None:
client = Client.from_environment()
if not client:
raise Exception("Missing OPVIOUS_ENDPOINT environment variable")

if args["export-notebook-model"]:
await _export_notebook_model(
client,
notebook_path=args["PATH"],
model_name=args["MODEL"],
export_path=args["--out"],
)
return

handler = _SpecificationHandler(client, args["--tags"], args["--dry-run"])
if args["register-notebook"]:
await handler.handle_notebook(
Expand Down
39 changes: 39 additions & 0 deletions src/opvious/client/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from typing import (
AsyncIterator,
BinaryIO,
Iterable,
Mapping,
Optional,
Expand Down Expand Up @@ -48,6 +49,7 @@
solve_strategy_to_json,
)
from ..executors import (
BinaryExecutorResult,
Executor,
JsonExecutorResult,
JsonSeqExecutorResult,
Expand All @@ -69,6 +71,7 @@
Problem,
ProblemOutlineCache,
ProblemOutlineGenerator,
ProblemTransformation,
SolveInputsBuilder,
feasible_outcome_details,
log_progress,
Expand Down Expand Up @@ -193,6 +196,42 @@ async def annotate_specification(
]
return specification.annotated(issues)

async def export_specification(
self,
specification: LocalSpecification,
writer: BinaryIO,
transformations: Optional[list[ProblemTransformation]] = None,
) -> None:
"""Exports a specification to its canonical representation
Args:
specification: The specification to export
transformations: Transformations to apply to the specification
"""
sources = [s.text for s in specification.sources]

if transformations:
outline_generator = await ProblemOutlineGenerator.sources(
executor=self._executor, sources=sources
)
for tf in transformations or []:
outline_generator.add_transformation(tf)
_outline, transformation_data = await outline_generator.generate()
else:
transformation_data = []

async with self._executor.execute(
result_type=BinaryExecutorResult,
url="/sources/assemble",
method="POST",
json_data=json_dict(
sources=sources,
transformations=transformation_data,
),
) as res:
async for chunk in res.bytes():
writer.write(chunk)

async def register_specification(
self,
specification: LocalSpecification,
Expand Down
2 changes: 2 additions & 0 deletions src/opvious/executors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Optional

from .common import (
BinaryExecutorResult,
Executor,
ExecutorError,
ExecutorResult,
Expand All @@ -24,6 +25,7 @@
"Executor",
"ExecutorError",
"ExecutorResult",
"BinaryExecutorResult",
"JsonExecutorResult",
"JsonSeqExecutorResult",
"PlainTextExecutorResult",
Expand Down
7 changes: 7 additions & 0 deletions src/opvious/executors/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import AsyncIterator, Optional

from .common import (
BinaryExecutorResult,
CONTENT_TYPE_HEADER,
Executor,
ExecutorError,
Expand Down Expand Up @@ -89,6 +90,12 @@ async def _send(
trace=trace,
reader=res.content,
)
elif BinaryExecutorResult.is_eligible(ctype):
yield BinaryExecutorResult(
status=status,
trace=trace,
reader=res.content,
)
else:
text = await res.text()
raise ExecutorError(
Expand Down
30 changes: 30 additions & 0 deletions src/opvious/executors/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,34 @@ def flush(self) -> str:
return buf


@dataclasses.dataclass
class BinaryExecutorResult(ExecutorResult):
"""Binary execution result"""

content_type = "application/octet-stream"
reader: Any = dataclasses.field(repr=False)

async def bytes(
self, assert_status: Optional[int] = 200
) -> AsyncIterator[bytes]:
if assert_status:
self._assert_status(assert_status)

# Non-streaming
if isinstance(self.reader, bytes):
yield self.reader

# Streaming
if hasattr(self.reader, "__aiter__"):
async for chunk in self.reader:
yield chunk
elif hasattr(self.reader, "__iter__"):
for chunk in self.reader:
yield chunk
else:
raise Exception(f"Non-iterable reader: {self.reader}")


@dataclasses.dataclass
class JsonExecutorResult(ExecutorResult):
"""Unary JSON execution result"""
Expand Down Expand Up @@ -256,6 +284,8 @@ async def execute(
accept = "application/json-seq;q=1, text/plain;q=0.1"
elif result_type == PlainTextExecutorResult:
accept = "text/plain"
elif result_type == BinaryExecutorResult:
accept = "application/octet-stream;q=1, text/plain;q=0.1"
else:
raise Exception(f"Unsupported result type: {result_type}")
all_headers["accept"] = accept
Expand Down
4 changes: 4 additions & 0 deletions src/opvious/executors/pyodide.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import AsyncIterator, Optional

from .common import (
BinaryExecutorResult,
CONTENT_TYPE_HEADER,
Executor,
ExecutorError,
Expand Down Expand Up @@ -48,6 +49,9 @@ async def _send(
yield PlainTextExecutorResult(
status=status, trace=trace, reader=text
)
elif BinaryExecutorResult.is_eligible(ctype):
data = await res.js_response.bytes()
yield BinaryExecutorResult(status=status, trace=trace, reader=data)
else:
text = await res.js_response.text()
raise ExecutorError(status=status, trace=trace, reason=text)
3 changes: 3 additions & 0 deletions src/opvious/executors/urllib.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import AsyncIterator, Optional

from .common import (
BinaryExecutorResult,
CONTENT_TYPE_HEADER,
Headers,
Executor,
Expand Down Expand Up @@ -54,6 +55,8 @@ async def _send(
yield PlainTextExecutorResult(
status=status, trace=trace, reader=res
)
elif BinaryExecutorResult.is_eligible(ctype):
yield BinaryExecutorResult(status=status, trace=trace, reader=res)
else:
raise ExecutorError(
status=status,
Expand Down

0 comments on commit 68248ea

Please sign in to comment.