buildwright 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Per-agent model routing: cada agent amb el seu model, local o remot."""
2
+
3
+ __version__ = "0.1.0"
buildwright/agents.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+ from pydantic_ai import Agent
9
+ from pydantic_ai.models import Model
10
+
11
+ from .config import ROLES, Settings
12
+ from .registry import ModelRegistry
13
+
14
+
15
+ class Plan(BaseModel):
16
+ """Structured output of the planner agent."""
17
+
18
+ steps: list[str] = Field(min_length=1, max_length=5)
19
+ needs_strong_model: bool = Field(
20
+ default=False,
21
+ description="true only when the task needs the strong remote model",
22
+ )
23
+
24
+
25
+ class Review(BaseModel):
26
+ """Structured output of the reviewer agent."""
27
+
28
+ verdict: Literal["pass", "fail"]
29
+ comments: str
30
+
31
+
32
+ ModelFactory = Callable[[str], Model] # role -> Model (tests use TestModel)
33
+
34
+
35
+ @dataclass
36
+ class Wright:
37
+ """Agent factory: each role wired to the model assigned to it in Settings."""
38
+
39
+ settings: Settings
40
+ registry: ModelRegistry | None = None
41
+ model_factory: ModelFactory | None = None
42
+
43
+ def model_for(self, role: str) -> Model:
44
+ if role not in ROLES:
45
+ raise KeyError(f"unknown role '{role}'. Known: {', '.join(ROLES)}")
46
+ if self.model_factory is not None:
47
+ return self.model_factory(role)
48
+ assert self.registry is not None, "registry required without model_factory"
49
+ return self.registry.build(self.settings.role_model(role))
50
+
51
+ def planner(self) -> Agent[None, Plan]:
52
+ return Agent(
53
+ self.model_for("planner"),
54
+ output_type=Plan,
55
+ instructions=(
56
+ "You are a task planner. Break the user task into 1-5 concrete steps. "
57
+ "Set needs_strong_model=true only for hard reasoning or code-heavy tasks."
58
+ ),
59
+ )
60
+
61
+ def worker(self) -> Agent[None, str]:
62
+ return Agent(
63
+ self.model_for("worker"),
64
+ instructions="You are a meticulous engineer. Execute the given step. Be concise.",
65
+ )
66
+
67
+ def reviewer(self) -> Agent[None, Review]:
68
+ return Agent(
69
+ self.model_for("reviewer"),
70
+ output_type=Review,
71
+ instructions=(
72
+ "You are a strict reviewer. Judge the work against the task; "
73
+ "reply verdict pass or fail with short comments."
74
+ ),
75
+ )
buildwright/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .agents import Wright
7
+ from .config import ROLES, Settings
8
+ from .graph import build_graph
9
+ from .observability import get_tracer
10
+ from .registry import ModelRegistry
11
+
12
+
13
+ def _wright(settings: Settings) -> Wright:
14
+ registry = ModelRegistry.load(settings.models_file)
15
+ return Wright(settings=settings, registry=registry)
16
+
17
+
18
+ def cmd_models(settings: Settings) -> None:
19
+ registry = ModelRegistry.load(settings.models_file)
20
+ for kind in ("local", "remote"):
21
+ print(f"\n{kind.upper()}")
22
+ for s in registry.list(kind):
23
+ key = f" [key: {s.api_key_env}]" if s.api_key_env else ""
24
+ print(f" {s.id:<24} {s.label}{key}")
25
+
26
+
27
+ def cmd_roles(settings: Settings) -> None:
28
+ ModelRegistry.load(settings.models_file) # validates the file
29
+ print("\nrole model-id model wire")
30
+ registry = ModelRegistry.load(settings.models_file)
31
+ for role in ROLES:
32
+ model_id = settings.role_model(role)
33
+ wire = registry.spec(model_id).model or model_id.split("/", 1)[1]
34
+ print(f"{role:<12} {model_id:<24} {wire}")
35
+
36
+
37
+ def cmd_ask(settings: Settings, task: str) -> None:
38
+ board = _wright(settings)
39
+ graph = build_graph(board)
40
+ tracer = get_tracer(settings)
41
+
42
+ with tracer.span("buildwright.ask", task=task):
43
+ state = graph.invoke({"task": task})
44
+
45
+ plan = state["plan"]
46
+ print("\n--- plan", f"({settings.role_model('planner')}) ---")
47
+ for i, step in enumerate(plan.steps, 1):
48
+ print(f" {i}. {step}")
49
+ print("\n--- answer", f"({settings.role_model('worker')}) ---")
50
+ print(state["answer"])
51
+ review = state["review"]
52
+ print(f"\n--- review ({settings.role_model('reviewer')}): {review.verdict} ---")
53
+ print(f" {review.comments}")
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> None:
57
+ parser = argparse.ArgumentParser(
58
+ prog="buildwright",
59
+ description="Per-agent model routing: one model per role, local and remote in the same registry.",
60
+ )
61
+ sub = parser.add_subparsers(dest="cmd", required=True)
62
+ sub.add_parser("models", help="list the models in the registry")
63
+ sub.add_parser("roles", help="show the role -> model mapping")
64
+ ask = sub.add_parser("ask", help="run the plan -> execute -> review pipeline")
65
+ ask.add_argument("task")
66
+
67
+ args = parser.parse_args(argv)
68
+ settings = Settings()
69
+
70
+ try:
71
+ if args.cmd == "models":
72
+ cmd_models(settings)
73
+ elif args.cmd == "roles":
74
+ cmd_roles(settings)
75
+ else:
76
+ cmd_ask(settings, args.task)
77
+ except (KeyError, RuntimeError) as exc:
78
+ print(f"error: {exc}", file=sys.stderr)
79
+ sys.exit(1)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
buildwright/config.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from importlib import resources
5
+ from pathlib import Path
6
+
7
+ from pydantic import Field
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+ ROLES = ("planner", "worker", "reviewer")
11
+
12
+
13
+ def default_models_file() -> Path:
14
+ """Registry resolution: project-local ./models.yaml > the bundled models.yaml."""
15
+ local = Path("models.yaml")
16
+ if local.is_file():
17
+ return local
18
+ return Path(str(resources.files("buildwright").joinpath("models.yaml")))
19
+
20
+
21
+ class Settings(BaseSettings):
22
+ """Central configuration. The role->model mapping is the switchboard itself."""
23
+
24
+ model_config = SettingsConfigDict(
25
+ env_prefix="BW_",
26
+ env_file=".env",
27
+ env_file_encoding="utf-8",
28
+ extra="ignore",
29
+ )
30
+
31
+ models_file: Path = Field(default_factory=default_models_file)
32
+
33
+ # role -> registry id (models.yaml)
34
+ role_planner: str = "local/qwen3"
35
+ role_worker: str = "remote/gpt-4o-mini"
36
+ role_reviewer: str = "local/qwen3"
37
+
38
+ # Langfuse: no keys -> tracing disabled (NullTracer)
39
+ langfuse_public_key: str | None = None
40
+ langfuse_secret_key: str | None = None
41
+ langfuse_host: str = "http://localhost:3000"
42
+
43
+ def role_model(self, role: str) -> str:
44
+ value = getattr(self, f"role_{role}", None)
45
+ if value is None:
46
+ raise KeyError(f"unknown role '{role}'. Known: {', '.join(ROLES)}")
47
+ return value
48
+
49
+
50
+ @lru_cache
51
+ def get_settings() -> Settings:
52
+ return Settings()
buildwright/graph.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypedDict
4
+
5
+ from langgraph.graph import END, START, StateGraph
6
+
7
+ from .agents import Plan, Review, Wright
8
+
9
+ # Each node is ONE PydanticAI agent; the model is decided by the role
10
+ # (Settings), never by the node. Assembling a CRAG or changing the flow
11
+ # therefore touches zero lines of model code.
12
+
13
+
14
+ class PipelineState(TypedDict, total=False):
15
+ task: str
16
+ plan: Plan
17
+ step_outputs: list[str]
18
+ answer: str
19
+ review: Review
20
+
21
+
22
+ def build_graph(board: Wright):
23
+ planner = board.planner()
24
+ worker = board.worker()
25
+ reviewer = board.reviewer()
26
+
27
+ def plan(state: PipelineState) -> dict:
28
+ result = planner.run_sync(f"Task: {state['task']}")
29
+ return {"plan": result.output}
30
+
31
+ def execute(state: PipelineState) -> dict:
32
+ outputs: list[str] = []
33
+ for i, step in enumerate(state["plan"].steps, start=1):
34
+ r = worker.run_sync(f"Task: {state['task']}\nStep {i}: {step}")
35
+ outputs.append(r.output)
36
+ return {"step_outputs": outputs, "answer": "\n\n".join(outputs)}
37
+
38
+ def review(state: PipelineState) -> dict:
39
+ r = reviewer.run_sync(f"Task: {state['task']}\n\nWork to review:\n{state['answer']}")
40
+ return {"review": r.output}
41
+
42
+ g = StateGraph(PipelineState)
43
+ g.add_node("plan", plan) # cheap/local model
44
+ g.add_node("execute", execute) # strong/remote model
45
+ g.add_node("review", review) # cheap/local model
46
+ g.add_edge(START, "plan")
47
+ g.add_edge("plan", "execute")
48
+ g.add_edge("execute", "review")
49
+ g.add_edge("review", END)
50
+ return g.compile()
@@ -0,0 +1,46 @@
1
+ # Model registry: local and remote models in one place.
2
+ # Agents never pick a model themselves: role -> id is defined in config
3
+ # (BW_ROLE_* or the project's runtime.yaml)
4
+ models:
5
+ # ---------------- local ----------------
6
+ - id: local/qwen3
7
+ label: Qwen3 8B (Ollama)
8
+ kind: local
9
+ base_url: http://localhost:11434/v1
10
+ model: qwen3:8b
11
+
12
+ - id: local/llamacpp
13
+ label: llama.cpp server (GGUF)
14
+ kind: local
15
+ base_url: http://localhost:8080/v1
16
+ model: local-model
17
+
18
+ # ---------------- remote ----------------
19
+ - id: remote/gpt-4o-mini
20
+ label: GPT-4o mini
21
+ kind: remote
22
+ base_url: https://api.openai.com/v1
23
+ model: gpt-4o-mini
24
+ api_key_env: OPENAI_API_KEY
25
+
26
+ - id: remote/gpt-4o
27
+ label: GPT-4o
28
+ kind: remote
29
+ base_url: https://api.openai.com/v1
30
+ model: gpt-4o
31
+ api_key_env: OPENAI_API_KEY
32
+
33
+ - id: remote/claude-sonnet
34
+ label: Claude Sonnet 4.5
35
+ kind: remote
36
+ base_url: https://api.anthropic.com/v1
37
+ model: claude-sonnet-4-5
38
+ api_key_env: ANTHROPIC_API_KEY
39
+
40
+ # --------- via LiteLLM proxy (optional, docker-compose) ---------
41
+ - id: litellm/gpt-4o-mini
42
+ label: GPT-4o mini via LiteLLM
43
+ kind: remote
44
+ base_url: http://localhost:4000/v1
45
+ model: gpt-4o-mini
46
+ api_key_env: LITELLM_API_KEY
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+
6
+ from .config import Settings
7
+
8
+
9
+ class NullTracer:
10
+ """No-op: when Langfuse keys are absent — zero noise, zero active dependency."""
11
+
12
+ @contextmanager
13
+ def span(self, name: str, **meta: object) -> Iterator[None]:
14
+ yield
15
+
16
+
17
+ class LangfuseTracer:
18
+ def __init__(self, settings: Settings) -> None:
19
+ from langfuse import Langfuse # deferred import: only when actually used
20
+
21
+ self._client = Langfuse(
22
+ public_key=settings.langfuse_public_key,
23
+ secret_key=settings.langfuse_secret_key,
24
+ host=settings.langfuse_host,
25
+ )
26
+
27
+ @contextmanager
28
+ def span(self, name: str, **meta: object) -> Iterator[object]:
29
+ with self._client.start_as_current_observation(
30
+ name=name, metadata=dict(meta)
31
+ ) as span:
32
+ yield span
33
+
34
+
35
+ def get_tracer(settings: Settings) -> NullTracer | LangfuseTracer:
36
+ if settings.langfuse_public_key and settings.langfuse_secret_key:
37
+ try:
38
+ return LangfuseTracer(settings)
39
+ except Exception: # noqa: BLE001 - observability must never break a run
40
+ return NullTracer()
41
+ return NullTracer()
buildwright/py.typed ADDED
File without changes
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ import yaml
8
+ from pydantic import BaseModel
9
+ from pydantic_ai.models import Model
10
+ from pydantic_ai.models.openai import OpenAIChatModel
11
+ from pydantic_ai.providers.openai import OpenAIProvider
12
+
13
+
14
+ class ModelSpec(BaseModel):
15
+ """An entry of models.yaml: a model reachable via an OpenAI-compatible endpoint."""
16
+
17
+ id: str
18
+ label: str
19
+ kind: Literal["local", "remote"]
20
+ base_url: str
21
+ model: str | None = None # "wire" name; defaults to the id without the prefix
22
+ api_key_env: str | None = None
23
+ context_window: int | None = None
24
+
25
+
26
+ class ModelRegistry:
27
+ def __init__(self, specs: list[ModelSpec]) -> None:
28
+ self._specs = {s.id: s for s in specs}
29
+
30
+ @classmethod
31
+ def load(cls, path: Path) -> ModelRegistry:
32
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
33
+ specs = [ModelSpec.model_validate(s) for s in data.get("models", [])]
34
+ return cls(specs)
35
+
36
+ def spec(self, model_id: str) -> ModelSpec:
37
+ try:
38
+ return self._specs[model_id]
39
+ except KeyError:
40
+ known = ", ".join(sorted(self._specs))
41
+ raise KeyError(f"unknown model id '{model_id}'. Known ids: {known}") from None
42
+
43
+ def list(self, kind: str | None = None) -> list[ModelSpec]:
44
+ return [s for s in self._specs.values() if kind is None or s.kind == kind]
45
+
46
+ def build(self, model_id: str) -> Model:
47
+ """Build the PydanticAI model pointing at its endpoint. No network involved."""
48
+ s = self.spec(model_id)
49
+ api_key = "local" # ollama/llama.cpp ignore it; the client requires non-empty
50
+ if s.api_key_env:
51
+ api_key = os.environ.get(s.api_key_env, "")
52
+ if not api_key:
53
+ raise RuntimeError(
54
+ f"model '{s.id}' requires env var {s.api_key_env} (not set)"
55
+ )
56
+ return OpenAIChatModel(
57
+ s.model or s.id.split("/", 1)[1],
58
+ provider=OpenAIProvider(base_url=s.base_url, api_key=api_key),
59
+ )
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: buildwright
3
+ Version: 0.1.0
4
+ Summary: Per-agent model routing for AI agents: LangGraph orchestration, PydanticAI agents, LiteLLM-ready registry, Langfuse tracing
5
+ Project-URL: Homepage, https://github.com/jordimarsal/buildwright
6
+ Project-URL: Repository, https://github.com/jordimarsal/buildwright
7
+ Project-URL: Issues, https://github.com/jordimarsal/buildwright/issues
8
+ Author: Jordi Marçal Poy
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,langfuse,langgraph,litellm,llm,model-routing,pydantic-ai,spec-driven-development
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: langfuse>=3.0
22
+ Requires-Dist: langgraph>=1.0
23
+ Requires-Dist: pydantic-ai-slim[openai]>=1.0
24
+ Requires-Dist: pydantic-settings>=2.4
25
+ Requires-Dist: pyyaml>=6.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # buildwright
29
+
30
+ *The craftsman who builds per the spec* — one model per agent: mechanical steps
31
+ go to the **local** model (cheap, fast), important decisions to the **remote**
32
+ one (strong). The role→model mapping is configuration, not code — switching
33
+ models touches zero lines of pipeline.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ uv tool install buildwright # once published to PyPI
39
+ # or for development:
40
+ uv sync
41
+ ```
42
+
43
+ ## Division of roles (one tool, one job)
44
+
45
+ | Piece | Role | Where it lives |
46
+ |---|---|---|
47
+ | **LangGraph** | the ORDER of the steps (graph, state, retries) | `graph.py` |
48
+ | **PydanticAI** | EACH STEP (agent with structured output) | `agents.py` |
49
+ | **LiteLLM** | WHO serves the model (optional proxy, fallbacks) | `litellm/config.yaml` |
50
+ | **Langfuse** | WHETHER IT WORKED (traces, scores) | `observability.py` |
51
+
52
+ ## The pipeline
53
+
54
+ ```
55
+ START → plan (local) → execute (remote) → review (local) → END
56
+ Plan struct. final answer Review struct.
57
+ ```
58
+
59
+ Each node is a PydanticAI agent; the model is decided by the **role** in
60
+ `Settings` (`BW_ROLE_*`), resolved against the `models.yaml` registry.
61
+
62
+ ## Quickstart
63
+
64
+ ```bash
65
+ uv sync
66
+ cp .env.example .env # adjust BW_ROLE_* and keys
67
+
68
+ uv run buildwright models # list local and remote models
69
+ uv run buildwright roles # role -> model mapping
70
+ uv run buildwright ask "write a snake game in python with pygame"
71
+ ```
72
+
73
+ With no endpoints available, `ask` fails naming the exact model and the missing
74
+ env var.
75
+
76
+ ## Local and remote models
77
+
78
+ The whole registry speaks **OpenAI-compatible** (`models.yaml`):
79
+
80
+ - **local/qwen3** → Ollama at `localhost:11434/v1`
81
+ - **local/llamacpp** → `llama-server` at `localhost:8080/v1` (your GGUF)
82
+ - **remote/gpt-4o-mini, gpt-4o** → OpenAI (key via `OPENAI_API_KEY`)
83
+ - **remote/claude-sonnet** → Anthropic's OpenAI-compat layer
84
+ - **litellm/*** → via the LiteLLM proxy (see `docker-compose.yml`)
85
+
86
+ Registry resolution: `BW_MODELS_FILE` > project-local `./models.yaml` > the
87
+ `models.yaml` bundled with the package. Adding a model = adding 5 lines.
88
+ Reassigning one to an agent = changing a `BW_ROLE_*` in `.env`.
89
+
90
+ ## Optional full stack (LiteLLM + Langfuse + Ollama)
91
+
92
+ With Docker, `docker compose up -d` brings up LiteLLM (routing/fallbacks),
93
+ Langfuse (traces) and Ollama. Langfuse keys show up in its admin UI; copy them
94
+ to `.env` and tracing turns itself on (without keys, the tracer is a no-op).
95
+
96
+ ## Publishing to PyPI
97
+
98
+ Trusted publishing — no API tokens:
99
+
100
+ 1. Create `github.com/jordimarsal/buildwright` and push `main`.
101
+ 2. On pypi.org: *Add a pending publisher* → owner `jordimarsal`, repo
102
+ `buildwright`, workflow `release.yml`, environment `pypi`.
103
+ 3. `git tag v0.1.0 && git push --tags` → the workflow runs `uv build` and
104
+ publishes.
105
+
106
+ ## Tests
107
+
108
+ ```bash
109
+ uv run pytest # offline: pydantic-ai's TestModel, zero network
110
+ ```
111
+
112
+ ## Anti-mess rules
113
+
114
+ 1. Everything speaks OpenAI protocol; PydanticAI never imports litellm.
115
+ 2. One agent = one node = one job. No agents inside agents.
116
+ 3. Tracing registered exactly once (`observability.py`); never scattered.
117
+ 4. Offline evals in CI rule; Langfuse is production monitoring.
118
+ 5. Each piece joins when it has a real trigger (fallbacks→LiteLLM,
119
+ non-linear flow→LangGraph is already in).
@@ -0,0 +1,14 @@
1
+ buildwright/__init__.py,sha256=AWzRjCUL2lAM4A7UJMXDUBT0c46zksXEuQVq2DlHnEc,98
2
+ buildwright/agents.py,sha256=Nq-j27tpllObBR1NQeaK1Bn0K3K1zQ8JrEnkpKDbADM,2332
3
+ buildwright/cli.py,sha256=xine3ba0OUlll4tXkWh2b9g3TFQ9WlJ_SssSEjz2bfo,2754
4
+ buildwright/config.py,sha256=fqr-qFkg1hB9UqqMWvDOSEgT1HTAha5rLTXOT0ZRiC0,1522
5
+ buildwright/graph.py,sha256=hQgleAy5nrIa7oCDhTmoXAon1KAi5_7Sb6PopR7cP6s,1612
6
+ buildwright/models.yaml,sha256=gNsYK83cs8eRLp6dquJO44UtDKDDXWKt6jJJL5H875k,1270
7
+ buildwright/observability.py,sha256=ZG8lRuENpu3PTwes1vhk1oIXCLE3_nZ20A-_nn1FPks,1259
8
+ buildwright/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ buildwright/registry.py,sha256=FkazWd80pjpad79O96YgY9tas9GmALacDMMppdOj_vk,2091
10
+ buildwright-0.1.0.dist-info/METADATA,sha256=yJE4rSPw5lpMb-MApD1RS9XYMLBa3RO9wMniZbhy9NM,4479
11
+ buildwright-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ buildwright-0.1.0.dist-info/entry_points.txt,sha256=izS4KVdhp6UAp5XCmA9M0QA8JKIkNAaCgFDzH0_7IpA,53
13
+ buildwright-0.1.0.dist-info/licenses/LICENSE,sha256=eMgpubNpl0LA4mylx__ss8lgutn16acZxLGnuGclGuE,1074
14
+ buildwright-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ buildwright = buildwright.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordi Marçal Poy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.