cmdop 0.1.15__py3-none-any.whl → 0.1.17__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.
cmdop/__init__.py CHANGED
@@ -116,14 +116,15 @@ from cmdop.discovery import (
116
116
  get_online_agents,
117
117
  list_agents,
118
118
  )
119
- from cmdop.helpers import (
120
- JSONFormatter,
121
- flatten_json,
122
- json_to_yaml,
123
- json_to_text,
119
+ from cmdop.helpers import json_to_toon, JsonCleaner
120
+ from cmdop.logging import (
121
+ get_logger,
122
+ setup_logging,
123
+ find_project_root,
124
+ get_log_dir,
124
125
  )
125
126
 
126
- __version__ = "0.1.15"
127
+ __version__ = "0.1.17"
127
128
 
128
129
  __all__ = [
129
130
  # Version
@@ -217,8 +218,11 @@ __all__ = [
217
218
  "BrowserNavigationError",
218
219
  "BrowserElementNotFoundError",
219
220
  # Helpers
220
- "JSONFormatter",
221
- "flatten_json",
222
- "json_to_yaml",
223
- "json_to_text",
221
+ "json_to_toon",
222
+ "JsonCleaner",
223
+ # Logging
224
+ "get_logger",
225
+ "setup_logging",
226
+ "find_project_root",
227
+ "get_log_dir",
224
228
  ]
cmdop/helpers/__init__.py CHANGED
@@ -1,15 +1,9 @@
1
1
  """CMDOP SDK helpers."""
2
2
 
3
- from cmdop.helpers.formatting import (
4
- JSONFormatter,
5
- flatten_json,
6
- json_to_yaml,
7
- json_to_text,
8
- )
3
+ from cmdop.helpers.formatting import json_to_toon
4
+ from cmdop.helpers.cleaner import JsonCleaner
9
5
 
10
6
  __all__ = [
11
- "JSONFormatter",
12
- "flatten_json",
13
- "json_to_yaml",
14
- "json_to_text",
7
+ "json_to_toon",
8
+ "JsonCleaner",
15
9
  ]
@@ -0,0 +1,53 @@
1
+ """JSON cleaning utilities for LLM consumption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from toon_python import encode as toon_encode
8
+
9
+
10
+ class JsonCleaner:
11
+ """
12
+ Clean JSON and convert to TOON format for LLM.
13
+
14
+ Usage:
15
+ cleaner = JsonCleaner(noise_keys={"Photos", "images"})
16
+ toon_text = cleaner.to_toon(data)
17
+ """
18
+
19
+ DEFAULT_NOISE_KEYS = frozenset({
20
+ "statusItemTypes",
21
+ "attributes",
22
+ "ordering",
23
+ "updatedDate",
24
+ })
25
+
26
+ def __init__(self, noise_keys: set[str] | None = None):
27
+ self.noise_keys = self.DEFAULT_NOISE_KEYS | (noise_keys or set())
28
+
29
+ def compact(self, data: Any) -> Any:
30
+ """Remove nulls, empty values, noise keys. Simplify {code,title}->title."""
31
+ def _process(obj: Any) -> Any:
32
+ if isinstance(obj, dict):
33
+ # Simplify {code, title} pattern
34
+ keys = set(obj.keys())
35
+ if keys <= {"code", "title", "description"} and "title" in keys:
36
+ return obj.get("title") or obj.get("code")
37
+ # Clean dict
38
+ result = {}
39
+ for k, v in obj.items():
40
+ if k in self.noise_keys:
41
+ continue
42
+ cleaned = _process(v)
43
+ if cleaned is not None and cleaned != "" and cleaned != [] and cleaned != {}:
44
+ result[k] = cleaned
45
+ return result
46
+ elif isinstance(obj, list):
47
+ return [_process(item) for item in obj if _process(item) not in (None, "", [], {})]
48
+ return obj
49
+ return _process(data)
50
+
51
+ def to_toon(self, data: Any) -> str:
52
+ """Compact and convert to TOON format."""
53
+ return toon_encode(self.compact(data))
@@ -1,70 +1,15 @@
1
1
  """JSON formatting utilities."""
2
2
 
3
- import json
3
+ from __future__ import annotations
4
4
 
5
- import yaml
6
- from flatten_json import flatten
5
+ from toon_python import encode as toon_encode
7
6
 
8
7
 
9
- class JSONFormatter:
8
+ def json_to_toon(data: dict | list) -> str:
10
9
  """
11
- JSON to various formats converter.
10
+ Convert JSON to TOON format (saves 30-50% tokens).
12
11
 
13
- Usage:
14
- fmt = JSONFormatter()
15
-
16
- flat = fmt.flatten({"a": {"b": 1}}) # {"a.b": 1}
17
- yaml_str = fmt.to_yaml(data)
18
- text = fmt.to_text(data) # key: value lines
12
+ Example:
13
+ {"name": "Alice", "age": 25} → "name: Alice\\nage: 25"
19
14
  """
20
-
21
- def __init__(self, separator: str = "."):
22
- self.separator = separator
23
-
24
- def flatten(self, data: dict | list) -> dict:
25
- """
26
- Flatten nested JSON to flat dict with dot notation.
27
-
28
- {"a": {"b": 1}} -> {"a.b": 1}
29
- """
30
- if isinstance(data, list):
31
- data = {"items": data}
32
- return flatten(data, self.separator)
33
-
34
- def to_yaml(self, data: dict | list) -> str:
35
- """Convert to YAML string."""
36
- return yaml.dump(
37
- data,
38
- allow_unicode=True,
39
- default_flow_style=False,
40
- sort_keys=False,
41
- indent=2,
42
- )
43
-
44
- def to_text(self, data: dict | list) -> str:
45
- """Convert to flat text (key: value per line)."""
46
- flat = self.flatten(data)
47
- lines = []
48
- for key, value in flat.items():
49
- if isinstance(value, str):
50
- val_str = value
51
- elif value is None:
52
- val_str = "null"
53
- elif isinstance(value, bool):
54
- val_str = "true" if value else "false"
55
- else:
56
- val_str = str(value)
57
- lines.append(f"{key}: {val_str}")
58
- return "\n".join(lines)
59
-
60
- def to_json(self, data: dict | list, indent: int = 2) -> str:
61
- """Convert to formatted JSON string."""
62
- return json.dumps(data, indent=indent, ensure_ascii=False)
63
-
64
-
65
- # Default instance
66
- _fmt = JSONFormatter()
67
-
68
- flatten_json = _fmt.flatten
69
- json_to_yaml = _fmt.to_yaml
70
- json_to_text = _fmt.to_text
15
+ return toon_encode(data)
cmdop/logging.py ADDED
@@ -0,0 +1,252 @@
1
+ """
2
+ Universal Python logger with Rich console output and file persistence.
3
+
4
+ Auto-detects project root by looking for Python project markers:
5
+ - pyproject.toml
6
+ - setup.py
7
+ - requirements.txt
8
+ - .git
9
+
10
+ Usage:
11
+ from cmdop.logging import get_logger
12
+
13
+ log = get_logger(__name__)
14
+ log.info("Hello world")
15
+ log.error("Something failed", exc_info=True)
16
+
17
+ # Or with custom settings
18
+ log = get_logger(__name__, level="DEBUG", log_to_file=True)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import sys
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+ from typing import Literal
28
+
29
+ # Try to import Rich, fallback to standard logging if not available
30
+ try:
31
+ from rich.console import Console
32
+ from rich.logging import RichHandler
33
+ from rich.traceback import install as install_rich_traceback
34
+
35
+ RICH_AVAILABLE = True
36
+ except ImportError:
37
+ RICH_AVAILABLE = False
38
+
39
+ __all__ = [
40
+ "get_logger",
41
+ "setup_logging",
42
+ "find_project_root",
43
+ "get_log_dir",
44
+ ]
45
+
46
+ # Project markers to search for (in priority order)
47
+ PROJECT_MARKERS = [
48
+ "pyproject.toml",
49
+ "setup.py",
50
+ "setup.cfg",
51
+ "requirements.txt",
52
+ "Pipfile",
53
+ ".git",
54
+ ]
55
+
56
+ LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
57
+
58
+ # Cache for project root
59
+ _project_root_cache: Path | None = None
60
+ _logging_configured: bool = False
61
+
62
+
63
+ def find_project_root(start_path: Path | None = None) -> Path | None:
64
+ """
65
+ Find Python project root by searching for project markers.
66
+
67
+ Searches upward from start_path (default: cwd) looking for:
68
+ - pyproject.toml
69
+ - setup.py
70
+ - requirements.txt
71
+ - .git
72
+
73
+ Returns:
74
+ Path to project root, or None if not found
75
+ """
76
+ global _project_root_cache
77
+
78
+ if _project_root_cache is not None:
79
+ return _project_root_cache
80
+
81
+ if start_path is None:
82
+ start_path = Path.cwd()
83
+
84
+ path = start_path.resolve()
85
+
86
+ for current in [path] + list(path.parents):
87
+ for marker in PROJECT_MARKERS:
88
+ if (current / marker).exists():
89
+ _project_root_cache = current
90
+ return current
91
+
92
+ return None
93
+
94
+
95
+ def get_log_dir(app_name: str = "app") -> Path:
96
+ """
97
+ Get log directory path.
98
+
99
+ Priority:
100
+ 1. Project root / logs (if project root found)
101
+ 2. ~/.local/logs/{app_name} (Linux/macOS)
102
+ 3. Current working directory / logs
103
+
104
+ Creates directory if it doesn't exist.
105
+
106
+ Args:
107
+ app_name: Application name for fallback directory
108
+
109
+ Returns:
110
+ Path to log directory
111
+ """
112
+ # Try project root first
113
+ project_root = find_project_root()
114
+ if project_root:
115
+ log_dir = project_root / "logs"
116
+ else:
117
+ # Fallback to home directory
118
+ home = Path.home()
119
+ if sys.platform == "darwin":
120
+ log_dir = home / "Library" / "Logs" / app_name
121
+ elif sys.platform == "win32":
122
+ log_dir = home / "AppData" / "Local" / app_name / "logs"
123
+ else: # Linux and others
124
+ log_dir = home / ".local" / "logs" / app_name
125
+
126
+ # Create directory if needed
127
+ log_dir.mkdir(parents=True, exist_ok=True)
128
+ return log_dir
129
+
130
+
131
+ def setup_logging(
132
+ level: LogLevel = "INFO",
133
+ log_to_file: bool = True,
134
+ log_to_console: bool = True,
135
+ app_name: str = "app",
136
+ rich_tracebacks: bool = True,
137
+ ) -> None:
138
+ """
139
+ Configure root logger with Rich console and file handlers.
140
+
141
+ Args:
142
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
143
+ log_to_file: Whether to write logs to file
144
+ log_to_console: Whether to output to console
145
+ app_name: Application name for log file naming
146
+ rich_tracebacks: Install Rich traceback handler for exceptions
147
+ """
148
+ global _logging_configured
149
+
150
+ if _logging_configured:
151
+ return
152
+
153
+ root_logger = logging.getLogger()
154
+ root_logger.setLevel(getattr(logging, level))
155
+
156
+ # Clear existing handlers
157
+ root_logger.handlers.clear()
158
+
159
+ # Console handler with Rich (if available)
160
+ if log_to_console:
161
+ if RICH_AVAILABLE:
162
+ console = Console(stderr=True)
163
+ console_handler = RichHandler(
164
+ console=console,
165
+ show_time=True,
166
+ show_path=True,
167
+ rich_tracebacks=True,
168
+ tracebacks_show_locals=True,
169
+ markup=True,
170
+ )
171
+ console_handler.setFormatter(logging.Formatter("%(message)s"))
172
+ else:
173
+ console_handler = logging.StreamHandler(sys.stderr)
174
+ console_handler.setFormatter(
175
+ logging.Formatter(
176
+ "%(asctime)s | %(levelname)-8s | %(name)s - %(message)s",
177
+ datefmt="%H:%M:%S",
178
+ )
179
+ )
180
+ root_logger.addHandler(console_handler)
181
+
182
+ # File handler
183
+ if log_to_file:
184
+ log_dir = get_log_dir(app_name)
185
+ date_str = datetime.now().strftime("%Y-%m-%d")
186
+ log_file = log_dir / f"{app_name}_{date_str}.log"
187
+
188
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
189
+ file_handler.setFormatter(
190
+ logging.Formatter(
191
+ "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d - %(message)s",
192
+ datefmt="%Y-%m-%d %H:%M:%S",
193
+ )
194
+ )
195
+ root_logger.addHandler(file_handler)
196
+
197
+ # Install Rich tracebacks globally
198
+ if rich_tracebacks and RICH_AVAILABLE:
199
+ install_rich_traceback(show_locals=True, suppress=[])
200
+
201
+ _logging_configured = True
202
+
203
+
204
+ def get_logger(
205
+ name: str | None = None,
206
+ level: LogLevel | None = None,
207
+ log_to_file: bool = True,
208
+ app_name: str = "app",
209
+ ) -> logging.Logger:
210
+ """
211
+ Get a configured logger instance.
212
+
213
+ On first call, sets up the logging system with Rich console output
214
+ and file persistence.
215
+
216
+ Args:
217
+ name: Logger name (typically __name__)
218
+ level: Override log level (default: INFO)
219
+ log_to_file: Whether to write to log file
220
+ app_name: Application name for log file
221
+
222
+ Returns:
223
+ Configured logger instance
224
+
225
+ Example:
226
+ log = get_logger(__name__)
227
+ log.info("Starting process")
228
+ log.debug("Debug details: %s", data)
229
+ log.error("Failed!", exc_info=True)
230
+ """
231
+ # Setup logging system on first call
232
+ setup_logging(
233
+ level=level or "INFO",
234
+ log_to_file=log_to_file,
235
+ app_name=app_name,
236
+ )
237
+
238
+ logger = logging.getLogger(name)
239
+
240
+ # Override level if specified
241
+ if level:
242
+ logger.setLevel(getattr(logging, level))
243
+
244
+ return logger
245
+
246
+
247
+ # Convenience aliases
248
+ debug = lambda msg, *args, **kw: get_logger().debug(msg, *args, **kw)
249
+ info = lambda msg, *args, **kw: get_logger().info(msg, *args, **kw)
250
+ warning = lambda msg, *args, **kw: get_logger().warning(msg, *args, **kw)
251
+ error = lambda msg, *args, **kw: get_logger().error(msg, *args, **kw)
252
+ critical = lambda msg, *args, **kw: get_logger().critical(msg, *args, **kw)
@@ -1,17 +1,44 @@
1
- """Browser service module."""
1
+ """Browser automation service.
2
+
3
+ Structure:
4
+ browser/
5
+ models.py - Data models (BrowserCookie, BrowserState)
6
+ js.py - JavaScript code builders
7
+ base/ - Base classes (BaseSession, BaseServiceMixin)
8
+ sync/ - Sync implementation (BrowserSession, BrowserService)
9
+ aio/ - Async implementation (AsyncBrowserSession, AsyncBrowserService)
10
+
11
+ Usage:
12
+ # Sync
13
+ with client.browser.create_session() as session:
14
+ session.navigate("https://example.com")
15
+ data = session.fetch_all(urls, headers={"Accept": "application/json"})
16
+
17
+ # Async
18
+ async with client.browser.create_session() as session:
19
+ await session.navigate("https://example.com")
20
+ data = await session.fetch_all(urls, headers={"Accept": "application/json"})
21
+ """
2
22
 
3
23
  from cmdop.services.browser.models import BrowserCookie, BrowserState, raise_browser_error
4
- from cmdop.services.browser.session import BrowserSession
5
- from cmdop.services.browser.service import BrowserService
6
- from cmdop.services.browser.async_session import AsyncBrowserSession
7
- from cmdop.services.browser.async_service import AsyncBrowserService
24
+
25
+ # Sync
26
+ from cmdop.services.browser.sync.session import BrowserSession
27
+ from cmdop.services.browser.sync.service import BrowserService
28
+
29
+ # Async
30
+ from cmdop.services.browser.aio.session import AsyncBrowserSession
31
+ from cmdop.services.browser.aio.service import AsyncBrowserService
8
32
 
9
33
  __all__ = [
34
+ # Models
10
35
  "BrowserCookie",
11
36
  "BrowserState",
37
+ "raise_browser_error",
38
+ # Sync
12
39
  "BrowserSession",
13
40
  "BrowserService",
41
+ # Async
14
42
  "AsyncBrowserSession",
15
43
  "AsyncBrowserService",
16
- "raise_browser_error",
17
44
  ]
@@ -0,0 +1,6 @@
1
+ """Asynchronous browser session and service."""
2
+
3
+ from cmdop.services.browser.aio.session import AsyncBrowserSession
4
+ from cmdop.services.browser.aio.service import AsyncBrowserService
5
+
6
+ __all__ = ["AsyncBrowserSession", "AsyncBrowserService"]
@@ -1,4 +1,4 @@
1
- """Browser service (async)."""
1
+ """Asynchronous browser service."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -6,17 +6,16 @@ import json
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from cmdop.services.base import BaseService
9
+ from cmdop.services.browser.base.service import BaseServiceMixin, cookie_to_pb, pb_to_cookie
9
10
  from cmdop.services.browser.models import BrowserCookie, BrowserState, raise_browser_error
10
- from cmdop.services.browser.async_session import AsyncBrowserSession
11
- from cmdop.services.browser._base import cookie_to_pb, pb_to_cookie
12
11
 
13
12
  if TYPE_CHECKING:
14
13
  from cmdop.transport.base import BaseTransport
15
14
 
16
15
 
17
- class AsyncBrowserService(BaseService):
16
+ class AsyncBrowserService(BaseService, BaseServiceMixin):
18
17
  """
19
- Async browser service.
18
+ Asynchronous browser service.
20
19
 
21
20
  Example:
22
21
  async with client.browser.create_session() as session:
@@ -36,6 +35,8 @@ class AsyncBrowserService(BaseService):
36
35
  self._stub = TerminalStreamingServiceStub(self._async_channel)
37
36
  return self._stub
38
37
 
38
+ # === Session Management ===
39
+
39
40
  async def create_session(
40
41
  self,
41
42
  start_url: str | None = None,
@@ -44,8 +45,9 @@ class AsyncBrowserService(BaseService):
44
45
  headless: bool = False,
45
46
  width: int = 1280,
46
47
  height: int = 800,
47
- ) -> AsyncBrowserSession:
48
+ ) -> "AsyncBrowserSession":
48
49
  from cmdop._generated.rpc_messages.browser_pb2 import BrowserCreateSessionRequest
50
+ from cmdop.services.browser.aio.session import AsyncBrowserSession
49
51
 
50
52
  request = BrowserCreateSessionRequest(
51
53
  provider=provider,
@@ -71,6 +73,8 @@ class AsyncBrowserService(BaseService):
71
73
  if not response.success:
72
74
  raise RuntimeError(f"Failed to close browser session: {response.error}")
73
75
 
76
+ # === Navigation & Interaction ===
77
+
74
78
  async def navigate(self, session_id: str, url: str, timeout_ms: int = 30000) -> str:
75
79
  from cmdop._generated.rpc_messages.browser_pb2 import BrowserNavigateRequest
76
80
 
@@ -132,6 +136,8 @@ class AsyncBrowserService(BaseService):
132
136
 
133
137
  return response.found
134
138
 
139
+ # === Extraction ===
140
+
135
141
  async def extract(
136
142
  self, session_id: str, selector: str, attr: str | None = None, limit: int = 100
137
143
  ) -> list[str]:
@@ -194,6 +200,8 @@ class AsyncBrowserService(BaseService):
194
200
 
195
201
  return response.text
196
202
 
203
+ # === JavaScript ===
204
+
197
205
  async def execute_script(self, session_id: str, script: str) -> str:
198
206
  from cmdop._generated.rpc_messages.browser_pb2 import BrowserExecuteScriptRequest
199
207
 
@@ -209,6 +217,8 @@ class AsyncBrowserService(BaseService):
209
217
 
210
218
  return response.result
211
219
 
220
+ # === State & Cookies ===
221
+
212
222
  async def screenshot(self, session_id: str, full_page: bool = False) -> bytes:
213
223
  from cmdop._generated.rpc_messages.browser_pb2 import BrowserScreenshotRequest
214
224
 
@@ -265,6 +275,8 @@ class AsyncBrowserService(BaseService):
265
275
 
266
276
  return [pb_to_cookie(c) for c in response.cookies]
267
277
 
278
+ # === Parser helpers ===
279
+
268
280
  async def validate_selectors(
269
281
  self, session_id: str, item: str, fields: dict[str, str]
270
282
  ) -> dict: