pygent 0.1.0__py3-none-any.whl → 0.1.2__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.
pygent/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Pacote Pygent."""
1
+ """Pygent package."""
2
2
  from importlib import metadata as _metadata
3
3
 
4
4
  try:
pygent/agent.py CHANGED
@@ -1,4 +1,4 @@
1
- """Camada de orquestração: recebe mensagens, chama OpenAI, delega ferramentas."""
1
+ """Orchestration layer: receives messages, calls the OpenAI-compatible backend and dispatches tools."""
2
2
 
3
3
  import json
4
4
  import os
@@ -8,7 +8,10 @@ import time
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any, Dict, List
10
10
 
11
- import openai
11
+ try:
12
+ import openai # type: ignore
13
+ except ModuleNotFoundError: # pragma: no cover - fallback to bundled client
14
+ from . import openai_compat as openai
12
15
  from rich.console import Console
13
16
  from rich.panel import Panel
14
17
 
@@ -55,8 +58,8 @@ class Agent:
55
58
  console.print(assistant_msg.content)
56
59
 
57
60
 
58
- def run_interactive() -> None: # pragma: no cover
59
- agent = Agent()
61
+ def run_interactive(use_docker: bool | None = None) -> None: # pragma: no cover
62
+ agent = Agent(runtime=Runtime(use_docker=use_docker))
60
63
  console.print("[bold green]Pygent[/] iniciado. (digite /exit para sair)")
61
64
  try:
62
65
  while True:
pygent/cli.py CHANGED
@@ -1,5 +1,12 @@
1
- """Ponto de entrada da CLI do Pygent."""
1
+ """Command-line entry point for Pygent."""
2
+ import argparse
3
+
2
4
  from .agent import run_interactive
3
5
 
4
6
  def main() -> None: # pragma: no cover
5
- run_interactive()
7
+ parser = argparse.ArgumentParser(prog="pygent")
8
+ parser.add_argument("--docker", dest="use_docker", action="store_true", help="run commands in a Docker container")
9
+ parser.add_argument("--no-docker", dest="use_docker", action="store_false", help="run locally")
10
+ parser.set_defaults(use_docker=None)
11
+ args = parser.parse_args()
12
+ run_interactive(use_docker=args.use_docker)
@@ -0,0 +1,71 @@
1
+ import os
2
+ import json
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List
5
+ from urllib import request
6
+
7
+ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
8
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
9
+
10
+ @dataclass
11
+ class ToolCallFunction:
12
+ name: str
13
+ arguments: str
14
+
15
+ @dataclass
16
+ class ToolCall:
17
+ id: str
18
+ type: str
19
+ function: ToolCallFunction
20
+
21
+ @dataclass
22
+ class Message:
23
+ role: str
24
+ content: str | None = None
25
+ tool_calls: List[ToolCall] | None = None
26
+
27
+ @dataclass
28
+ class Choice:
29
+ message: Message
30
+
31
+ @dataclass
32
+ class ChatCompletion:
33
+ choices: List[Choice]
34
+
35
+
36
+ def _post(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
37
+ data = json.dumps(payload).encode()
38
+ headers = {"Content-Type": "application/json"}
39
+ if OPENAI_API_KEY:
40
+ headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
41
+ req = request.Request(f"{OPENAI_BASE_URL}{path}", data=data, headers=headers)
42
+ with request.urlopen(req) as resp:
43
+ return json.loads(resp.read().decode())
44
+
45
+
46
+ class _ChatCompletions:
47
+ def create(self, model: str, messages: List[Dict[str, Any]], tools: Any = None, tool_choice: str | None = "auto") -> ChatCompletion:
48
+ payload: Dict[str, Any] = {"model": model, "messages": messages}
49
+ if tools is not None:
50
+ payload["tools"] = tools
51
+ if tool_choice is not None:
52
+ payload["tool_choice"] = tool_choice
53
+ raw = _post("/chat/completions", payload)
54
+ choices: List[Choice] = []
55
+ for ch in raw.get("choices", []):
56
+ msg_data = ch.get("message", {})
57
+ tool_calls = []
58
+ for tc in msg_data.get("tool_calls", []):
59
+ func = ToolCallFunction(**tc.get("function", {}))
60
+ tool_calls.append(ToolCall(id=tc.get("id", ""), type=tc.get("type", ""), function=func))
61
+ msg = Message(role=msg_data.get("role", ""), content=msg_data.get("content"), tool_calls=tool_calls or None)
62
+ choices.append(Choice(message=msg))
63
+ return ChatCompletion(choices=choices)
64
+
65
+
66
+ class _Chat:
67
+ def __init__(self) -> None:
68
+ self.completions = _ChatCompletions()
69
+
70
+
71
+ chat = _Chat()
pygent/runtime.py CHANGED
@@ -1,43 +1,75 @@
1
- """Isola as execuções num container Docker efêmero."""
1
+ """Run commands in a Docker container, falling back to local execution if needed."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import os
5
5
  import shutil
6
- import tempfile
7
6
  import subprocess
7
+ import tempfile
8
8
  import uuid
9
9
  from pathlib import Path
10
10
  from typing import Union
11
11
 
12
- import docker
12
+ try: # Docker may not be available (e.g. Windows without Docker)
13
+ import docker # type: ignore
14
+ except Exception: # pragma: no cover - optional dependency
15
+ docker = None
13
16
 
14
17
 
15
18
  class Runtime:
16
- """Cada instância corresponde a um diretório + container dedicados."""
19
+ """Executes commands in a Docker container or locally if Docker is unavailable."""
17
20
 
18
- def __init__(self, image: str | None = None) -> None:
21
+ def __init__(self, image: str | None = None, use_docker: bool | None = None) -> None:
19
22
  self.base_dir = Path(tempfile.mkdtemp(prefix="pygent_"))
20
23
  self.image = image or os.getenv("PYGENT_IMAGE", "python:3.12-slim")
21
- self.client = docker.from_env()
22
- self.container = self.client.containers.run(
23
- self.image,
24
- name=f"pygent-{uuid.uuid4().hex[:8]}",
25
- command="sleep infinity",
26
- volumes={str(self.base_dir): {"bind": "/workspace", "mode": "rw"}},
27
- working_dir="/workspace",
28
- detach=True,
29
- tty=True,
30
- network_disabled=True,
31
- mem_limit="512m",
32
- pids_limit=256,
33
- )
24
+ env_opt = os.getenv("PYGENT_USE_DOCKER")
25
+ if use_docker is None:
26
+ use_docker = (env_opt != "0") if env_opt is not None else True
27
+ self._use_docker = bool(docker) and use_docker
28
+ if self._use_docker:
29
+ try:
30
+ self.client = docker.from_env()
31
+ self.container = self.client.containers.run(
32
+ self.image,
33
+ name=f"pygent-{uuid.uuid4().hex[:8]}",
34
+ command="sleep infinity",
35
+ volumes={str(self.base_dir): {"bind": "/workspace", "mode": "rw"}},
36
+ working_dir="/workspace",
37
+ detach=True,
38
+ tty=True,
39
+ network_disabled=True,
40
+ mem_limit="512m",
41
+ pids_limit=256,
42
+ )
43
+ except Exception:
44
+ self._use_docker = False
45
+ if not self._use_docker:
46
+ self.client = None
47
+ self.container = None
34
48
 
35
49
  # ---------------- public API ----------------
36
50
  def bash(self, cmd: str, timeout: int = 30) -> str:
37
- """Roda comando dentro do container e devolve saída combinada."""
38
- res = self.container.exec_run(cmd, workdir="/workspace", demux=True, tty=False, timeout=timeout)
39
- stdout, stderr = res.output if isinstance(res.output, tuple) else (res.output, b"")
40
- return (stdout or b"").decode() + (stderr or b"").decode()
51
+ """Run a command in the container or locally and return the output."""
52
+ if self._use_docker and self.container is not None:
53
+ res = self.container.exec_run(
54
+ cmd,
55
+ workdir="/workspace",
56
+ demux=True,
57
+ tty=False,
58
+ timeout=timeout,
59
+ )
60
+ stdout, stderr = (
61
+ res.output if isinstance(res.output, tuple) else (res.output, b"")
62
+ )
63
+ return (stdout or b"").decode() + (stderr or b"").decode()
64
+ proc = subprocess.run(
65
+ cmd,
66
+ shell=True,
67
+ cwd=self.base_dir,
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=timeout,
71
+ )
72
+ return proc.stdout + proc.stderr
41
73
 
42
74
  def write_file(self, rel_path: Union[str, Path], content: str) -> str:
43
75
  p = self.base_dir / rel_path
@@ -46,8 +78,9 @@ class Runtime:
46
78
  return f"Wrote {p.relative_to(self.base_dir)}"
47
79
 
48
80
  def cleanup(self) -> None:
49
- try:
50
- self.container.kill()
51
- finally:
52
- self.container.remove(force=True)
53
- shutil.rmtree(self.base_dir, ignore_errors=True)
81
+ if self._use_docker and self.container is not None:
82
+ try:
83
+ self.container.kill()
84
+ finally:
85
+ self.container.remove(force=True)
86
+ shutil.rmtree(self.base_dir, ignore_errors=True)
pygent/tools.py CHANGED
@@ -1,4 +1,4 @@
1
- """Mapa de ferramentas disponíveis para o agente."""
1
+ """Map of tools available to the agent."""
2
2
  from __future__ import annotations
3
3
  import json
4
4
  from typing import Any, Dict
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygent
3
+ Version: 0.1.2
4
+ Summary: Pygent is a minimalist coding assistant that runs commands in a Docker container when available and falls back to local execution. See https://marianochaves.github.io/pygent for documentation and https://github.com/marianochaves/pygent for the source code.
5
+ Author-email: Mariano Chaves <mchaves.software@gmail.com>
6
+ Project-URL: Documentation, https://marianochaves.github.io/pygent
7
+ Project-URL: Repository, https://github.com/marianochaves/pygent
8
+ Requires-Python: >=3.9
9
+ License-File: LICENSE
10
+ Requires-Dist: rich>=13.7.0
11
+ Provides-Extra: llm
12
+ Requires-Dist: openai>=1.0.0; extra == "llm"
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest; extra == "test"
15
+ Provides-Extra: docs
16
+ Requires-Dist: mkdocs; extra == "docs"
17
+ Provides-Extra: docker
18
+ Requires-Dist: docker>=7.0.0; extra == "docker"
19
+ Dynamic: license-file
@@ -0,0 +1,13 @@
1
+ pygent/__init__.py,sha256=3YOE3tjTGEc987Vz-TqmYwQ4ogLwTmue642Enf4NVBg,360
2
+ pygent/agent.py,sha256=s80JFgtDZ4tNEroh0qIJ0A_8QAiYyORD82R6KsM6vEo,2235
3
+ pygent/cli.py,sha256=Hz2FZeNMVhxoT5DjCqphXla3TisGJtPEz921LEcpxrA,527
4
+ pygent/openai_compat.py,sha256=mS6ntl70jpVH3JzfNYEDhg-z7QIQcMqQTuEV5ja7VOo,2173
5
+ pygent/py.typed,sha256=0Wh72UpGSn4lSGW-u3xMV9kxcBHMdwE15IGUqiJTwqo,52
6
+ pygent/runtime.py,sha256=33y4jieNeyZ-9nxtVlOmO236u2fDAAv2GaaEWTQDdm8,3173
7
+ pygent/tools.py,sha256=Ru2_voFgPUVc6YgBTRVByn7vWTxXAXT-loAWFMkXHno,1460
8
+ pygent-0.1.2.dist-info/licenses/LICENSE,sha256=rIktBU2VR4kHzsWul64cbom2zHIgGqYmABoZwSur6T8,1071
9
+ pygent-0.1.2.dist-info/METADATA,sha256=utdsiVHvR3fMjqGeNvBNC4oMrD0Ytav6CYyrgAyjnLM,857
10
+ pygent-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ pygent-0.1.2.dist-info/entry_points.txt,sha256=ivw-s2f1abmFsbL4173DP1IuMS7sNxQ6gZuDLdu_jKQ,43
12
+ pygent-0.1.2.dist-info/top_level.txt,sha256=P26IYsb-ThK5IkGP_bRuGJQ0Q_Y8JCcbYqVpvULdxDw,7
13
+ pygent-0.1.2.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pygent
3
- Version: 0.1.0
4
- Summary: A minimal agentic coding assistant that runs each task in a secure container.
5
- Author-email: Mariano Chaves <mchaves.software@gmail.com>
6
- Requires-Python: >=3.9
7
- License-File: LICENSE
8
- Requires-Dist: openai>=1.0.0
9
- Requires-Dist: docker>=7.0.0
10
- Requires-Dist: rich>=13.7.0
11
- Provides-Extra: test
12
- Requires-Dist: pytest; extra == "test"
13
- Provides-Extra: docs
14
- Requires-Dist: mkdocs; extra == "docs"
15
- Dynamic: license-file
@@ -1,12 +0,0 @@
1
- pygent/__init__.py,sha256=25N0WIIaSg0e3XXoDGl_OSBN2ADl-8cHm0C0EOR_opw,359
2
- pygent/agent.py,sha256=vOV0Zx3JzeRtJb-QRdBr-DzFIPVIt2bcZb8xeHlOxI0,2003
3
- pygent/cli.py,sha256=qOuIHAtt--NFbPRl-RuGSK7mfFj-4CtU4rRr6KYPgKc,139
4
- pygent/py.typed,sha256=0Wh72UpGSn4lSGW-u3xMV9kxcBHMdwE15IGUqiJTwqo,52
5
- pygent/runtime.py,sha256=jaLhAT-2Wo6DxmPksFyHvVaCNOOu_c7BgrENsIuwTlI,1897
6
- pygent/tools.py,sha256=1mXaPHFtZwT9w8thDeneH-Ryd9CjViiWOeDE1BtRF6I,1471
7
- pygent-0.1.0.dist-info/licenses/LICENSE,sha256=rIktBU2VR4kHzsWul64cbom2zHIgGqYmABoZwSur6T8,1071
8
- pygent-0.1.0.dist-info/METADATA,sha256=Rjva3PKq9hN83Piv8w7ppyNZpgzOnWOjGOYXh7BI7v4,468
9
- pygent-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- pygent-0.1.0.dist-info/entry_points.txt,sha256=ivw-s2f1abmFsbL4173DP1IuMS7sNxQ6gZuDLdu_jKQ,43
11
- pygent-0.1.0.dist-info/top_level.txt,sha256=P26IYsb-ThK5IkGP_bRuGJQ0Q_Y8JCcbYqVpvULdxDw,7
12
- pygent-0.1.0.dist-info/RECORD,,
File without changes