ellements 0.2.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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- ellements-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Reusable terminal CLI tool for ellements applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ellements.core import ToolSpec
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TerminalExecutionSpec(BaseModel):
|
|
18
|
+
"""Trusted local terminal execution request."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
command: str | None = None
|
|
23
|
+
args: list[str] = Field(default_factory=list)
|
|
24
|
+
shell: bool = False
|
|
25
|
+
script: str | None = None
|
|
26
|
+
cwd: str | None = None
|
|
27
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
28
|
+
stdin: str | None = None
|
|
29
|
+
timeout_seconds: float = Field(default=300.0, gt=0)
|
|
30
|
+
max_output_bytes: int = Field(default=100_000, gt=0)
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _validate_command(self) -> TerminalExecutionSpec:
|
|
34
|
+
if self.shell:
|
|
35
|
+
if not self.script:
|
|
36
|
+
raise ValueError("shell terminal execution requires script")
|
|
37
|
+
elif not self.command:
|
|
38
|
+
raise ValueError("argv terminal execution requires command")
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TerminalExecutionResult(BaseModel):
|
|
43
|
+
"""Captured result of a terminal execution."""
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(extra="forbid")
|
|
46
|
+
|
|
47
|
+
command_line: list[str]
|
|
48
|
+
cwd: str | None
|
|
49
|
+
started_at: datetime
|
|
50
|
+
completed_at: datetime
|
|
51
|
+
duration_seconds: float
|
|
52
|
+
exit_code: int | None
|
|
53
|
+
timed_out: bool
|
|
54
|
+
stdout: str
|
|
55
|
+
stderr: str
|
|
56
|
+
stdout_truncated: bool = False
|
|
57
|
+
stderr_truncated: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def run_terminal_execution(
|
|
61
|
+
spec: TerminalExecutionSpec,
|
|
62
|
+
) -> TerminalExecutionResult:
|
|
63
|
+
"""Run a local terminal command with bounded output."""
|
|
64
|
+
started_at = datetime.now(UTC)
|
|
65
|
+
start = time.monotonic()
|
|
66
|
+
env = {**os.environ, **spec.env}
|
|
67
|
+
stdin_bytes = spec.stdin.encode("utf-8") if spec.stdin is not None else None
|
|
68
|
+
command_line = (
|
|
69
|
+
[spec.script or ""] if spec.shell else [spec.command or "", *spec.args]
|
|
70
|
+
)
|
|
71
|
+
process = await _start_process(spec, env)
|
|
72
|
+
timed_out = False
|
|
73
|
+
try:
|
|
74
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
75
|
+
process.communicate(input=stdin_bytes),
|
|
76
|
+
timeout=spec.timeout_seconds,
|
|
77
|
+
)
|
|
78
|
+
except TimeoutError:
|
|
79
|
+
timed_out = True
|
|
80
|
+
process.kill()
|
|
81
|
+
stdout_bytes, stderr_bytes = await process.communicate()
|
|
82
|
+
completed_at = datetime.now(UTC)
|
|
83
|
+
stdout, stdout_truncated = _decode_bounded(stdout_bytes, spec.max_output_bytes)
|
|
84
|
+
stderr, stderr_truncated = _decode_bounded(stderr_bytes, spec.max_output_bytes)
|
|
85
|
+
return TerminalExecutionResult(
|
|
86
|
+
command_line=command_line,
|
|
87
|
+
cwd=spec.cwd,
|
|
88
|
+
started_at=started_at,
|
|
89
|
+
completed_at=completed_at,
|
|
90
|
+
duration_seconds=time.monotonic() - start,
|
|
91
|
+
exit_code=process.returncode,
|
|
92
|
+
timed_out=timed_out,
|
|
93
|
+
stdout=stdout,
|
|
94
|
+
stderr=stderr,
|
|
95
|
+
stdout_truncated=stdout_truncated,
|
|
96
|
+
stderr_truncated=stderr_truncated,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def terminal_cli_tool(
|
|
101
|
+
*,
|
|
102
|
+
name: str = "terminal.run",
|
|
103
|
+
description: str = "Run a local terminal command with bounded output.",
|
|
104
|
+
timeout_seconds: float = 300.0,
|
|
105
|
+
max_output_bytes: int = 100_000,
|
|
106
|
+
) -> ToolSpec:
|
|
107
|
+
"""Return an ellements `ToolSpec` for local CLI execution."""
|
|
108
|
+
|
|
109
|
+
async def invoke(
|
|
110
|
+
command: str | None = None,
|
|
111
|
+
args: list[str] | None = None,
|
|
112
|
+
shell: bool = False,
|
|
113
|
+
script: str | None = None,
|
|
114
|
+
cwd: str | None = None,
|
|
115
|
+
env: dict[str, str] | None = None,
|
|
116
|
+
stdin: str | None = None,
|
|
117
|
+
timeout_seconds: float | None = None,
|
|
118
|
+
max_output_bytes: int | None = None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
spec = TerminalExecutionSpec(
|
|
121
|
+
command=command,
|
|
122
|
+
args=args or [],
|
|
123
|
+
shell=shell,
|
|
124
|
+
script=script,
|
|
125
|
+
cwd=str(Path(cwd)) if cwd is not None else None,
|
|
126
|
+
env=env or {},
|
|
127
|
+
stdin=stdin,
|
|
128
|
+
timeout_seconds=timeout_seconds or terminal_cli_tool_timeout,
|
|
129
|
+
max_output_bytes=max_output_bytes or terminal_cli_tool_max_output,
|
|
130
|
+
)
|
|
131
|
+
return (await run_terminal_execution(spec)).model_dump(mode="json")
|
|
132
|
+
|
|
133
|
+
terminal_cli_tool_timeout = timeout_seconds
|
|
134
|
+
terminal_cli_tool_max_output = max_output_bytes
|
|
135
|
+
return ToolSpec(
|
|
136
|
+
name=name,
|
|
137
|
+
description=description,
|
|
138
|
+
params_json_schema={
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"command": {"type": "string"},
|
|
142
|
+
"args": {"type": "array", "items": {"type": "string"}},
|
|
143
|
+
"shell": {"type": "boolean", "default": False},
|
|
144
|
+
"script": {"type": "string"},
|
|
145
|
+
"cwd": {"type": "string"},
|
|
146
|
+
"env": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"additionalProperties": {"type": "string"},
|
|
149
|
+
},
|
|
150
|
+
"stdin": {"type": "string"},
|
|
151
|
+
"timeout_seconds": {"type": "number", "exclusiveMinimum": 0},
|
|
152
|
+
"max_output_bytes": {"type": "integer", "exclusiveMinimum": 0},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
invoke=invoke,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _start_process(
|
|
160
|
+
spec: TerminalExecutionSpec,
|
|
161
|
+
env: dict[str, str],
|
|
162
|
+
) -> asyncio.subprocess.Process:
|
|
163
|
+
if spec.shell:
|
|
164
|
+
assert spec.script is not None
|
|
165
|
+
return await asyncio.create_subprocess_shell(
|
|
166
|
+
spec.script,
|
|
167
|
+
cwd=spec.cwd,
|
|
168
|
+
env=env,
|
|
169
|
+
stdin=asyncio.subprocess.PIPE if spec.stdin is not None else None,
|
|
170
|
+
stdout=asyncio.subprocess.PIPE,
|
|
171
|
+
stderr=asyncio.subprocess.PIPE,
|
|
172
|
+
)
|
|
173
|
+
assert spec.command is not None
|
|
174
|
+
return await asyncio.create_subprocess_exec(
|
|
175
|
+
spec.command,
|
|
176
|
+
*spec.args,
|
|
177
|
+
cwd=spec.cwd,
|
|
178
|
+
env=env,
|
|
179
|
+
stdin=asyncio.subprocess.PIPE if spec.stdin is not None else None,
|
|
180
|
+
stdout=asyncio.subprocess.PIPE,
|
|
181
|
+
stderr=asyncio.subprocess.PIPE,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode_bounded(data: bytes, max_bytes: int) -> tuple[str, bool]:
|
|
186
|
+
truncated = len(data) > max_bytes
|
|
187
|
+
if truncated:
|
|
188
|
+
data = data[:max_bytes]
|
|
189
|
+
return data.decode("utf-8", errors="replace"), truncated
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def terminal_command_text(spec: TerminalExecutionSpec) -> str:
|
|
193
|
+
"""Return a display-safe command string."""
|
|
194
|
+
if spec.shell:
|
|
195
|
+
return spec.script or ""
|
|
196
|
+
return " ".join(shlex.quote(part) for part in [spec.command or "", *spec.args])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = [
|
|
200
|
+
"TerminalExecutionResult",
|
|
201
|
+
"TerminalExecutionSpec",
|
|
202
|
+
"run_terminal_execution",
|
|
203
|
+
"terminal_cli_tool",
|
|
204
|
+
"terminal_command_text",
|
|
205
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Web-oriented standard tools for search, crawling, and YouTube access."""
|
|
2
|
+
|
|
3
|
+
from .browser_viewer import (
|
|
4
|
+
BrowserViewer,
|
|
5
|
+
BrowserViewResult,
|
|
6
|
+
LocalArtifactServer,
|
|
7
|
+
is_remote_url,
|
|
8
|
+
)
|
|
9
|
+
from .crawler import CrawlResult, ExtractionResult, WebCrawler, web_crawler_tools
|
|
10
|
+
from .search import SearchResult, SearchResults, WebSearcher, web_search_tools
|
|
11
|
+
from .youtube import (
|
|
12
|
+
RateLimitedYouTubeSearcher,
|
|
13
|
+
VideoMetadata,
|
|
14
|
+
VideoTranscript,
|
|
15
|
+
YouTubeSearcher,
|
|
16
|
+
youtube_search_tools,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BrowserViewer",
|
|
21
|
+
"BrowserViewResult",
|
|
22
|
+
"CrawlResult",
|
|
23
|
+
"ExtractionResult",
|
|
24
|
+
"LocalArtifactServer",
|
|
25
|
+
"RateLimitedYouTubeSearcher",
|
|
26
|
+
"SearchResult",
|
|
27
|
+
"SearchResults",
|
|
28
|
+
"VideoMetadata",
|
|
29
|
+
"VideoTranscript",
|
|
30
|
+
"WebCrawler",
|
|
31
|
+
"WebSearcher",
|
|
32
|
+
"YouTubeSearcher",
|
|
33
|
+
"is_remote_url",
|
|
34
|
+
"web_crawler_tools",
|
|
35
|
+
"web_search_tools",
|
|
36
|
+
"youtube_search_tools",
|
|
37
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Browser display helpers for local artifacts and remote URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import http.server
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
from urllib.parse import quote, urlparse
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
SourceType = Literal["remote-url", "local-file", "local-directory"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BrowserViewResult(BaseModel):
|
|
20
|
+
"""Structured result for a browser display target."""
|
|
21
|
+
|
|
22
|
+
target: str = Field(description="Original resolved target URL/path.")
|
|
23
|
+
display_url: str = Field(description="URL to navigate a browser to.")
|
|
24
|
+
source_type: SourceType = Field(description="Kind of target being displayed.")
|
|
25
|
+
opened: bool = Field(description="Whether the system browser was opened.")
|
|
26
|
+
serve_root: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
description="Local directory being served for local targets.",
|
|
29
|
+
)
|
|
30
|
+
host: str | None = Field(default=None, description="Local server host.")
|
|
31
|
+
port: int | None = Field(default=None, description="Local server port.")
|
|
32
|
+
duration: int | None = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description="Requested local-server lifetime in seconds.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_remote_url(target: str) -> bool:
|
|
39
|
+
"""Return whether a target is an HTTP(S) URL."""
|
|
40
|
+
parsed = urlparse(target)
|
|
41
|
+
return parsed.scheme in {"http", "https"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _url_path(path: Path) -> str:
|
|
45
|
+
return "/".join(quote(part) for part in path.parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_local_target(target: str | Path) -> tuple[Path, Path, str]:
|
|
49
|
+
path = Path(target).expanduser().resolve()
|
|
50
|
+
if not path.exists():
|
|
51
|
+
raise FileNotFoundError(f"Local target not found: {path}")
|
|
52
|
+
|
|
53
|
+
if path.is_dir():
|
|
54
|
+
return path, path, ""
|
|
55
|
+
return path, path.parent, _url_path(Path(path.name))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LocalArtifactServer:
|
|
59
|
+
"""Small localhost HTTP server for browser-blocked local files/directories."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
target: str | Path,
|
|
64
|
+
*,
|
|
65
|
+
host: str = "127.0.0.1",
|
|
66
|
+
port: int = 0,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.target = target
|
|
69
|
+
self.host = host
|
|
70
|
+
self.port = port
|
|
71
|
+
self.resolved_target, self.serve_root, self._relative_url = (
|
|
72
|
+
_resolve_local_target(target)
|
|
73
|
+
)
|
|
74
|
+
self._server: http.server.ThreadingHTTPServer | None = None
|
|
75
|
+
self._thread: threading.Thread | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def source_type(self) -> SourceType:
|
|
79
|
+
"""Return whether the target is a file or directory."""
|
|
80
|
+
return "local-directory" if self.resolved_target.is_dir() else "local-file"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def display_url(self) -> str:
|
|
84
|
+
"""Return the browser URL for the served target."""
|
|
85
|
+
if self._server is None:
|
|
86
|
+
raise RuntimeError("Local artifact server has not been started.")
|
|
87
|
+
port = int(self._server.server_address[1])
|
|
88
|
+
display_url = f"http://{self.host}:{port}/"
|
|
89
|
+
if self._relative_url:
|
|
90
|
+
display_url += self._relative_url
|
|
91
|
+
return display_url
|
|
92
|
+
|
|
93
|
+
def start(self) -> LocalArtifactServer:
|
|
94
|
+
"""Start serving the target directory in a daemon thread."""
|
|
95
|
+
if self._server is not None:
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
handler = functools.partial(
|
|
99
|
+
http.server.SimpleHTTPRequestHandler,
|
|
100
|
+
directory=str(self.serve_root),
|
|
101
|
+
)
|
|
102
|
+
self._server = http.server.ThreadingHTTPServer((self.host, self.port), handler)
|
|
103
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
104
|
+
self._thread.start()
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def shutdown(self) -> None:
|
|
108
|
+
"""Stop the local server if it is running."""
|
|
109
|
+
if self._server is None:
|
|
110
|
+
return
|
|
111
|
+
self._server.shutdown()
|
|
112
|
+
self._server.server_close()
|
|
113
|
+
self._server = None
|
|
114
|
+
self._thread = None
|
|
115
|
+
|
|
116
|
+
def __enter__(self) -> LocalArtifactServer:
|
|
117
|
+
return self.start()
|
|
118
|
+
|
|
119
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
120
|
+
self.shutdown()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class BrowserViewer:
|
|
124
|
+
"""Open remote URLs or serve local artifacts for browser display."""
|
|
125
|
+
|
|
126
|
+
def open_remote(
|
|
127
|
+
self, target: str, *, open_browser: bool = True
|
|
128
|
+
) -> BrowserViewResult:
|
|
129
|
+
"""Open a remote HTTP(S) URL or return the browser target."""
|
|
130
|
+
if not is_remote_url(target):
|
|
131
|
+
raise ValueError(f"Not an HTTP(S) URL: {target}")
|
|
132
|
+
if open_browser:
|
|
133
|
+
webbrowser.open(target)
|
|
134
|
+
return BrowserViewResult(
|
|
135
|
+
target=target,
|
|
136
|
+
display_url=target,
|
|
137
|
+
source_type="remote-url",
|
|
138
|
+
opened=open_browser,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def serve_local(
|
|
142
|
+
self,
|
|
143
|
+
target: str | Path,
|
|
144
|
+
*,
|
|
145
|
+
host: str = "127.0.0.1",
|
|
146
|
+
port: int = 0,
|
|
147
|
+
open_browser: bool = True,
|
|
148
|
+
duration: int | None = None,
|
|
149
|
+
) -> tuple[LocalArtifactServer, BrowserViewResult]:
|
|
150
|
+
"""Start serving a local file/directory and return the live server."""
|
|
151
|
+
server = LocalArtifactServer(target, host=host, port=port).start()
|
|
152
|
+
if open_browser:
|
|
153
|
+
webbrowser.open(server.display_url)
|
|
154
|
+
result = BrowserViewResult(
|
|
155
|
+
target=str(server.resolved_target),
|
|
156
|
+
display_url=server.display_url,
|
|
157
|
+
source_type=server.source_type,
|
|
158
|
+
opened=open_browser,
|
|
159
|
+
serve_root=str(server.serve_root),
|
|
160
|
+
host=host,
|
|
161
|
+
port=int(server.display_url.split(":", 2)[2].split("/", 1)[0]),
|
|
162
|
+
duration=duration,
|
|
163
|
+
)
|
|
164
|
+
return server, result
|
|
165
|
+
|
|
166
|
+
def serve_for_duration(
|
|
167
|
+
self,
|
|
168
|
+
target: str | Path,
|
|
169
|
+
*,
|
|
170
|
+
host: str = "127.0.0.1",
|
|
171
|
+
port: int = 0,
|
|
172
|
+
duration: int = 300,
|
|
173
|
+
open_browser: bool = True,
|
|
174
|
+
) -> BrowserViewResult:
|
|
175
|
+
"""Serve a local target for a bounded duration; 0 means until interrupted."""
|
|
176
|
+
server, result = self.serve_local(
|
|
177
|
+
target,
|
|
178
|
+
host=host,
|
|
179
|
+
port=port,
|
|
180
|
+
open_browser=open_browser,
|
|
181
|
+
duration=duration,
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
if duration == 0:
|
|
185
|
+
while True:
|
|
186
|
+
time.sleep(3600)
|
|
187
|
+
else:
|
|
188
|
+
time.sleep(duration)
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
pass
|
|
191
|
+
finally:
|
|
192
|
+
server.shutdown()
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def open(
|
|
196
|
+
self,
|
|
197
|
+
target: str | Path,
|
|
198
|
+
*,
|
|
199
|
+
host: str = "127.0.0.1",
|
|
200
|
+
port: int = 0,
|
|
201
|
+
duration: int = 300,
|
|
202
|
+
open_browser: bool = True,
|
|
203
|
+
) -> BrowserViewResult:
|
|
204
|
+
"""Open a remote URL directly or serve a local target for browser display."""
|
|
205
|
+
target_text = str(target)
|
|
206
|
+
if is_remote_url(target_text):
|
|
207
|
+
return self.open_remote(target_text, open_browser=open_browser)
|
|
208
|
+
return self.serve_for_duration(
|
|
209
|
+
target,
|
|
210
|
+
host=host,
|
|
211
|
+
port=port,
|
|
212
|
+
duration=duration,
|
|
213
|
+
open_browser=open_browser,
|
|
214
|
+
)
|