Merge pull request 'Add LLM client interface with LiteLLM adapter (A5/A11)' (#8) from llm-client into main
CI / checks (push) Successful in 10s
CI / checks (push) Successful in 10s
Reviewed-on: dongar/nisse#8 Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #8.
This commit is contained in:
commit
5dad5cf90f
8 files changed
+462
-2
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
# Copy to .env (chmod 600). Real environment variables win over this file.
|
||||||
|
# .env is gitignored; never commit real values.
|
||||||
|
|
||||||
|
# --- LLM access (A5): the LiteLLM proxy is the single LLM destination ---
|
||||||
|
NISSE_LLM_BASE_URL=http://litellm.example:4000
|
||||||
|
NISSE_LLM_TOKEN=sk-your-litellm-virtual-key
|
||||||
|
|
||||||
|
# --- Role mapping (A11): roles resolve to concrete model strings ---
|
||||||
|
NISSE_ROLE_EXECUTOR=anthropic/claude-haiku-4-5-20251001
|
||||||
|
NISSE_ROLE_PLANNER=anthropic/claude-opus-5
|
||||||
|
NISSE_ROLE_DELEGATE=anthropic/claude-opus-5
|
||||||
|
NISSE_ROLE_CONSULTANT=anthropic/claude-opus-5
|
||||||
|
|
||||||
|
# --- Matrix transport (A1), used by nisse-dev's MatrixTransport ---
|
||||||
|
NISSE_MATRIX_HOMESERVER=https://matrix.example
|
||||||
|
NISSE_MATRIX_USER_ID=@nisse-dev:example
|
||||||
|
NISSE_MATRIX_TOKEN=syt_your_matrix_access_token
|
||||||
|
NISSE_MATRIX_ROOM_ID=!roomid:example
|
||||||
|
NISSE_MATRIX_OPERATORS=@you:example
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "nisse-dev"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Dev agent: plans and implements software projects, produces Gitea PRs"
|
description = "Dev agent: plans and implements software projects, produces Gitea PRs"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = ["nisse-core"]
|
dependencies = ["nisse-core", "httpx>=0.27"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
nisse-dev = "nisse_dev.main:main"
|
nisse-dev = "nisse_dev.main:main"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Minimal .env loader (spec A5: config from env/.env, nothing hardcoded).
|
||||||
|
|
||||||
|
Deliberately tiny: KEY=VALUE lines, comments and blanks ignored, existing
|
||||||
|
environment variables never overridden (real env wins over the file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import MutableMapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file(path: Path, env: MutableMapping[str, str] = os.environ) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if key and key not in env:
|
||||||
|
env[key] = value.strip()
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""LLM access (spec A5, A11).
|
||||||
|
|
||||||
|
One client interface, present from the first real LLM call. All traffic goes
|
||||||
|
to the LiteLLM proxy — the VM's single LLM destination; the agent holds one
|
||||||
|
LiteLLM token and no vendor credentials. Config comes from the environment
|
||||||
|
(``NISSE_LLM_BASE_URL``, ``NISSE_LLM_TOKEN``), nothing hardcoded.
|
||||||
|
|
||||||
|
Model identity is expressed as roles (A11), resolved to concrete model strings
|
||||||
|
from env config; the resolved mapping is pinned at task start so a mid-task
|
||||||
|
config change cannot split a task across two planners.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any, ClassVar, Protocol
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nisse_dev.canonical import (
|
||||||
|
AssistantText,
|
||||||
|
CanonicalEvent,
|
||||||
|
StoredEvent,
|
||||||
|
ToolCall,
|
||||||
|
mint_call_id,
|
||||||
|
)
|
||||||
|
from nisse_dev.render import OpenAIChatRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient(Protocol):
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
events: list[StoredEvent],
|
||||||
|
tools: list[dict[str, Any]] | None,
|
||||||
|
model: str,
|
||||||
|
system: str | None = None,
|
||||||
|
) -> list[CanonicalEvent]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoleMap:
|
||||||
|
executor: str
|
||||||
|
planner: str
|
||||||
|
delegate: str
|
||||||
|
consultant: str
|
||||||
|
|
||||||
|
_ENV_VARS: ClassVar[dict[str, str]] = {
|
||||||
|
"executor": "NISSE_ROLE_EXECUTOR",
|
||||||
|
"planner": "NISSE_ROLE_PLANNER",
|
||||||
|
"delegate": "NISSE_ROLE_DELEGATE",
|
||||||
|
"consultant": "NISSE_ROLE_CONSULTANT",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, env: Mapping[str, str] = os.environ) -> "RoleMap":
|
||||||
|
missing = [var for var in cls._ENV_VARS.values() if not env.get(var)]
|
||||||
|
if missing:
|
||||||
|
raise LLMError(f"role mapping incomplete; set: {', '.join(missing)}")
|
||||||
|
return cls(**{role: env[var] for role, var in cls._ENV_VARS.items()})
|
||||||
|
|
||||||
|
def resolve(self, role: str) -> str:
|
||||||
|
if role not in self._ENV_VARS:
|
||||||
|
raise LLMError(f"unknown role: {role!r}")
|
||||||
|
return str(getattr(self, role))
|
||||||
|
|
||||||
|
def pinned(self) -> dict[str, str]:
|
||||||
|
"""The full resolved mapping, to record at task start (A11)."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class LiteLLMClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
timeout_s: float = 120.0,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._http = httpx.Client(
|
||||||
|
base_url=base_url.rstrip("/"),
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=timeout_s,
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
self._renderer = OpenAIChatRenderer()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, env: Mapping[str, str] = os.environ) -> "LiteLLMClient":
|
||||||
|
base_url = env.get("NISSE_LLM_BASE_URL")
|
||||||
|
token = env.get("NISSE_LLM_TOKEN")
|
||||||
|
if not base_url or not token:
|
||||||
|
raise LLMError("set NISSE_LLM_BASE_URL and NISSE_LLM_TOKEN")
|
||||||
|
return cls(base_url, token)
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
events: list[StoredEvent],
|
||||||
|
tools: list[dict[str, Any]] | None,
|
||||||
|
model: str,
|
||||||
|
system: str | None = None,
|
||||||
|
) -> list[CanonicalEvent]:
|
||||||
|
body = self._renderer.render(events, tools, model, system=system)
|
||||||
|
try:
|
||||||
|
response = self._http.post("/chat/completions", json=body)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise LLMError(f"LLM request failed: {exc}") from exc
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise LLMError(
|
||||||
|
f"LLM request returned {response.status_code}: {response.text[:500]}"
|
||||||
|
)
|
||||||
|
return self._parse(response.json(), requested_model=model)
|
||||||
|
|
||||||
|
def _parse(
|
||||||
|
self, data: dict[str, Any], requested_model: str
|
||||||
|
) -> list[CanonicalEvent]:
|
||||||
|
try:
|
||||||
|
message = data["choices"][0]["message"]
|
||||||
|
except (KeyError, IndexError) as exc:
|
||||||
|
raise LLMError(f"malformed completion response: {exc}") from exc
|
||||||
|
produced_by = data.get("model") or requested_model
|
||||||
|
out: list[CanonicalEvent] = []
|
||||||
|
if message.get("content"):
|
||||||
|
out.append(AssistantText(text=message["content"], model=produced_by))
|
||||||
|
# Provider tool-call IDs are mapped at the boundary (A10): each call
|
||||||
|
# gets a locally-minted canonical ID; the provider's is dropped, since
|
||||||
|
# every future request is rendered wholly from the canonical log.
|
||||||
|
for call in message.get("tool_calls") or []:
|
||||||
|
out.append(
|
||||||
|
ToolCall(
|
||||||
|
call_id=mint_call_id(),
|
||||||
|
tool=call["function"]["name"],
|
||||||
|
arguments=json.loads(call["function"]["arguments"] or "{}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._http.close()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nisse_dev.canonical import (
|
||||||
|
AssistantText,
|
||||||
|
StoredEvent,
|
||||||
|
ToolCall,
|
||||||
|
UserMessage,
|
||||||
|
)
|
||||||
|
from nisse_dev.envfile import load_env_file
|
||||||
|
from nisse_dev.llm import LiteLLMClient, LLMError, RoleMap
|
||||||
|
|
||||||
|
EVENTS = [StoredEvent(id=1, ts="t", event=UserMessage(text="hi", source="m:1"))]
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(handler) -> LiteLLMClient:
|
||||||
|
return LiteLLMClient(
|
||||||
|
"http://litellm.test/v1",
|
||||||
|
"sk-test",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def completion(message: dict) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"model": "gpt-test-1", "choices": [{"message": message}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_shape_and_auth() -> None:
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen["url"] = str(request.url)
|
||||||
|
seen["auth"] = request.headers.get("authorization")
|
||||||
|
seen["body"] = json.loads(request.content)
|
||||||
|
return completion({"content": "hello"})
|
||||||
|
|
||||||
|
make_client(handler).complete(EVENTS, tools=None, model="local/qwen", system="s")
|
||||||
|
assert seen["url"] == "http://litellm.test/v1/chat/completions"
|
||||||
|
assert seen["auth"] == "Bearer sk-test"
|
||||||
|
assert seen["body"]["model"] == "local/qwen"
|
||||||
|
assert seen["body"]["messages"][0] == {"role": "system", "content": "s"}
|
||||||
|
assert seen["body"]["messages"][1] == {"role": "user", "content": "hi"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_assistant_text_parsed_with_producing_model() -> None:
|
||||||
|
client = make_client(lambda request: completion({"content": "hello"}))
|
||||||
|
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
|
||||||
|
assert event == AssistantText(text="hello", model="gpt-test-1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_calls_get_locally_minted_canonical_ids() -> None:
|
||||||
|
message = {
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_provider_abc",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run_tests",
|
||||||
|
"arguments": '{"path": "tests/"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
client = make_client(lambda request: completion(message))
|
||||||
|
[event] = client.complete(EVENTS, tools=None, model="local/qwen")
|
||||||
|
assert isinstance(event, ToolCall)
|
||||||
|
assert event.tool == "run_tests"
|
||||||
|
assert event.arguments == {"path": "tests/"}
|
||||||
|
assert event.call_id != "call_provider_abc" # provider ID never a join key
|
||||||
|
assert event.call_id.startswith("call-")
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error_status_raises_llm_error() -> None:
|
||||||
|
client = make_client(lambda request: httpx.Response(429, text="rate limited"))
|
||||||
|
with pytest.raises(LLMError, match="429"):
|
||||||
|
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_failure_raises_llm_error() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectError("refused")
|
||||||
|
|
||||||
|
with pytest.raises(LLMError, match="request failed"):
|
||||||
|
make_client(handler).complete(EVENTS, tools=None, model="local/qwen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_response_raises_llm_error() -> None:
|
||||||
|
client = make_client(lambda request: httpx.Response(200, json={"oops": True}))
|
||||||
|
with pytest.raises(LLMError, match="malformed"):
|
||||||
|
client.complete(EVENTS, tools=None, model="local/qwen")
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_ENV = {
|
||||||
|
"NISSE_ROLE_EXECUTOR": "local/qwen",
|
||||||
|
"NISSE_ROLE_PLANNER": "anthropic/claude-opus-5",
|
||||||
|
"NISSE_ROLE_DELEGATE": "anthropic/claude-opus-5",
|
||||||
|
"NISSE_ROLE_CONSULTANT": "anthropic/claude-opus-5",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_map_resolves_and_pins() -> None:
|
||||||
|
roles = RoleMap.from_env(ROLE_ENV)
|
||||||
|
assert roles.resolve("executor") == "local/qwen"
|
||||||
|
assert roles.pinned() == {
|
||||||
|
"executor": "local/qwen",
|
||||||
|
"planner": "anthropic/claude-opus-5",
|
||||||
|
"delegate": "anthropic/claude-opus-5",
|
||||||
|
"consultant": "anthropic/claude-opus-5",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_map_missing_env_raises() -> None:
|
||||||
|
with pytest.raises(LLMError, match="NISSE_ROLE_PLANNER"):
|
||||||
|
RoleMap.from_env({"NISSE_ROLE_EXECUTOR": "local/qwen"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_role_raises() -> None:
|
||||||
|
with pytest.raises(LLMError, match="unknown role"):
|
||||||
|
RoleMap.from_env(ROLE_ENV).resolve("boss")
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_from_env_requires_config() -> None:
|
||||||
|
with pytest.raises(LLMError, match="NISSE_LLM_BASE_URL"):
|
||||||
|
LiteLLMClient.from_env({})
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_env_file(tmp_path: Path) -> None:
|
||||||
|
envfile = tmp_path / ".env"
|
||||||
|
envfile.write_text(
|
||||||
|
"# comment\n\nNISSE_LLM_TOKEN=sk-abc\nNISSE_LLM_BASE_URL = http://x \n"
|
||||||
|
)
|
||||||
|
env: dict[str, str] = {"NISSE_LLM_TOKEN": "sk-already-set"}
|
||||||
|
load_env_file(envfile, env)
|
||||||
|
assert env["NISSE_LLM_TOKEN"] == "sk-already-set" # real env wins
|
||||||
|
assert env["NISSE_LLM_BASE_URL"] == "http://x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_env_file_missing_is_noop(tmp_path: Path) -> None:
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
load_env_file(tmp_path / "nope.env", env)
|
||||||
|
assert env == {}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Live smoke test against the real LiteLLM proxy.
|
||||||
|
|
||||||
|
Skips unless NISSE_LLM_BASE_URL, NISSE_LLM_TOKEN, and NISSE_ROLE_EXECUTOR are
|
||||||
|
present (from the environment or the repo-root .env) — CI stays credential-free
|
||||||
|
by choice; this runs from the operator's machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nisse_dev.canonical import AssistantText, StoredEvent, UserMessage
|
||||||
|
from nisse_dev.envfile import load_env_file
|
||||||
|
from nisse_dev.llm import LiteLLMClient
|
||||||
|
|
||||||
|
load_env_file(Path(__file__).resolve().parents[2] / ".env")
|
||||||
|
|
||||||
|
REQUIRED = ("NISSE_LLM_BASE_URL", "NISSE_LLM_TOKEN", "NISSE_ROLE_EXECUTOR")
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.integration,
|
||||||
|
pytest.mark.skipif(
|
||||||
|
not all(os.environ.get(var) for var in REQUIRED),
|
||||||
|
reason=f"live LLM config absent; set {', '.join(REQUIRED)}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_model_completes_via_litellm() -> None:
|
||||||
|
client = LiteLLMClient.from_env()
|
||||||
|
events = [
|
||||||
|
StoredEvent(
|
||||||
|
id=1,
|
||||||
|
ts="t",
|
||||||
|
event=UserMessage(
|
||||||
|
text="Reply with exactly one word: pong", source="smoke-test"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
result = client.complete(
|
||||||
|
events, tools=None, model=os.environ["NISSE_ROLE_EXECUTOR"]
|
||||||
|
)
|
||||||
|
texts = [e.text for e in result if isinstance(e, AssistantText)]
|
||||||
|
assert texts, f"no assistant text in {result!r}"
|
||||||
|
assert "pong" in texts[0].lower()
|
||||||
@@ -15,6 +15,28 @@ dev = [
|
|||||||
{ name = "ruff", specifier = ">=0.6" },
|
{ name = "ruff", specifier = ">=0.6" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -24,6 +46,52 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -43,11 +111,15 @@ name = "nisse-dev"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "nisse-dev" }
|
source = { editable = "nisse-dev" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "nisse-core" },
|
{ name = "nisse-core" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "nisse-core", editable = "nisse-core" }]
|
requires-dist = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "nisse-core", editable = "nisse-core" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nisse-ops"
|
name = "nisse-ops"
|
||||||
@@ -127,3 +199,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user