smooth-py 0.4.4.dev20260324__tar.gz → 0.4.4.dev20260423__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: smooth-py
3
- Version: 0.4.4.dev20260324
3
+ Version: 0.4.4.dev20260423
4
4
  Summary:
5
5
  Author: Luca Pinchetti
6
6
  Author-email: luca@circlemind.co
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "smooth-py"
3
- version = "0.4.4.dev20260324"
3
+ version = "0.4.4.dev20260423"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "Luca Pinchetti",email = "luca@circlemind.co"}
@@ -14,6 +14,7 @@ from ._tools import (
14
14
  AsyncSmoothTool,
15
15
  SmoothTool,
16
16
  )
17
+ from .models import Secret
17
18
 
18
19
  # Export public API
19
20
  __all__ = [
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "BadRequestError",
29
30
  "TimeoutError",
30
31
  "ToolCallError",
32
+ "Secret",
31
33
  # Deprecated
32
34
  "TaskHandle",
33
35
  "AsyncTaskHandle",
@@ -5,6 +5,7 @@ import io
5
5
  import os
6
6
  import secrets
7
7
  import threading
8
+ import warnings
8
9
  from pathlib import Path
9
10
  from types import TracebackType
10
11
  from typing import Any, Callable, Coroutine, Generator, Sequence, Type, TypedDict, TypeVar, cast
@@ -177,6 +178,7 @@ class SmoothClient(BaseClient):
177
178
  profile_id: str | None = None,
178
179
  profile_read_only: bool = False,
179
180
  stealth_mode: bool = False,
181
+ use_stealth: bool = True,
180
182
  proxy_server: str | None = None,
181
183
  proxy_username: str | None = None,
182
184
  proxy_password: str | None = None,
@@ -190,6 +192,12 @@ class SmoothClient(BaseClient):
190
192
  show_cursor: bool = False,
191
193
  ) -> SessionHandle:
192
194
  """Opens a browser session."""
195
+ if stealth_mode:
196
+ warnings.warn(
197
+ "'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
198
+ DeprecationWarning,
199
+ stacklevel=2,
200
+ )
193
201
  # Handle proxy_server="self" - auto-start local proxy tunnel
194
202
  self_proxy = proxy_server == "self"
195
203
  if self_proxy and not proxy_password:
@@ -206,6 +214,7 @@ class SmoothClient(BaseClient):
206
214
  profile_id=profile_id,
207
215
  profile_read_only=profile_read_only,
208
216
  stealth_mode=stealth_mode,
217
+ use_stealth=use_stealth,
209
218
  proxy_server=proxy_server,
210
219
  proxy_username=proxy_username,
211
220
  proxy_password=proxy_password,
@@ -252,6 +261,7 @@ class SmoothClient(BaseClient):
252
261
  profile_id: str | None = None,
253
262
  profile_read_only: bool = False,
254
263
  stealth_mode: bool = False,
264
+ use_stealth: bool = True,
255
265
  proxy_server: str | None = None,
256
266
  proxy_username: str | None = None,
257
267
  proxy_password: str | None = None,
@@ -285,7 +295,8 @@ class SmoothClient(BaseClient):
285
295
  session_id: (Deprecated, now `profile_id`) Browser session ID to use.
286
296
  profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
287
297
  profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
288
- stealth_mode: Run the browser in stealth mode.
298
+ stealth_mode: (Deprecated, ignored) Use `use_stealth` instead.
299
+ use_stealth: Run the browser in stealth mode. Default is True.
289
300
  proxy_server: Proxy server address to route browser traffic through.
290
301
  proxy_username: Proxy server username.
291
302
  proxy_password: Proxy server password.
@@ -307,6 +318,12 @@ class SmoothClient(BaseClient):
307
318
  Raises:
308
319
  ApiException: If the API request fails.
309
320
  """
321
+ if stealth_mode:
322
+ warnings.warn(
323
+ "'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
324
+ DeprecationWarning,
325
+ stacklevel=2,
326
+ )
310
327
  custom_tools_ = (
311
328
  [tool if isinstance(tool, SmoothTool) else SmoothTool(**tool) for tool in custom_tools] if custom_tools else None
312
329
  )
@@ -332,6 +349,7 @@ class SmoothClient(BaseClient):
332
349
  profile_id=profile_id or session_id,
333
350
  profile_read_only=profile_read_only,
334
351
  stealth_mode=stealth_mode,
352
+ use_stealth=use_stealth,
335
353
  proxy_server=proxy_server,
336
354
  proxy_username=proxy_username,
337
355
  proxy_password=proxy_password,
@@ -628,6 +646,7 @@ class SmoothAsyncClient(BaseClient):
628
646
  profile_id: str | None = None,
629
647
  profile_read_only: bool = False,
630
648
  stealth_mode: bool = False,
649
+ use_stealth: bool = True,
631
650
  proxy_server: str | None = None,
632
651
  proxy_username: str | None = None,
633
652
  proxy_password: str | None = None,
@@ -651,6 +670,12 @@ class SmoothAsyncClient(BaseClient):
651
670
  async with client.session(...) as session:
652
671
  ...
653
672
  """
673
+ if stealth_mode:
674
+ warnings.warn(
675
+ "'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
676
+ DeprecationWarning,
677
+ stacklevel=2,
678
+ )
654
679
  return _AsyncSessionContextManager(self._session_coro(
655
680
  url=None,
656
681
  files=files,
@@ -661,6 +686,7 @@ class SmoothAsyncClient(BaseClient):
661
686
  profile_id=profile_id,
662
687
  profile_read_only=profile_read_only,
663
688
  stealth_mode=stealth_mode,
689
+ use_stealth=use_stealth,
664
690
  proxy_server=proxy_server,
665
691
  proxy_username=proxy_username,
666
692
  proxy_password=proxy_password,
@@ -695,6 +721,7 @@ class SmoothAsyncClient(BaseClient):
695
721
  profile_id: str | None = None,
696
722
  profile_read_only: bool = False,
697
723
  stealth_mode: bool = False,
724
+ use_stealth: bool = True,
698
725
  proxy_server: str | None = None,
699
726
  proxy_username: str | None = None,
700
727
  proxy_password: str | None = None,
@@ -724,6 +751,7 @@ class SmoothAsyncClient(BaseClient):
724
751
  profile_id=profile_id,
725
752
  profile_read_only=profile_read_only,
726
753
  stealth_mode=stealth_mode,
754
+ use_stealth=use_stealth,
727
755
  proxy_server=proxy_server,
728
756
  proxy_username=proxy_username,
729
757
  proxy_password=proxy_password,
@@ -783,6 +811,7 @@ class SmoothAsyncClient(BaseClient):
783
811
  profile_id: str | None = None,
784
812
  profile_read_only: bool = False,
785
813
  stealth_mode: bool = False,
814
+ use_stealth: bool = True,
786
815
  proxy_server: str | None = None,
787
816
  proxy_username: str | None = None,
788
817
  proxy_password: str | None = None,
@@ -815,7 +844,8 @@ class SmoothAsyncClient(BaseClient):
815
844
  session_id: (Deprecated, now `profile_id`) Browser session ID to use.
816
845
  profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
817
846
  profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
818
- stealth_mode: Run the browser in stealth mode.
847
+ stealth_mode: (Deprecated, ignored) Use `use_stealth` instead.
848
+ use_stealth: Run the browser in stealth mode. Default is True.
819
849
  proxy_server: Proxy server address to route browser traffic through.
820
850
  proxy_username: Proxy server username.
821
851
  proxy_password: Proxy server password.
@@ -837,6 +867,12 @@ class SmoothAsyncClient(BaseClient):
837
867
  Raises:
838
868
  ApiException: If the API request fails.
839
869
  """
870
+ if stealth_mode:
871
+ warnings.warn(
872
+ "'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
873
+ DeprecationWarning,
874
+ stacklevel=2,
875
+ )
840
876
  if proxy_server == "self" and task is not None:
841
877
  raise BadRequestError(
842
878
  'proxy_server="self" is not supported in run(). '
@@ -864,6 +900,7 @@ class SmoothAsyncClient(BaseClient):
864
900
  profile_id=profile_id or session_id,
865
901
  profile_read_only=profile_read_only,
866
902
  stealth_mode=stealth_mode,
903
+ use_stealth=use_stealth,
867
904
  proxy_server=proxy_server,
868
905
  proxy_username=proxy_username,
869
906
  proxy_password=proxy_password, # pyright: ignore[reportArgumentType]
@@ -19,6 +19,7 @@ from .models import (
19
19
  ActionGotoResponse,
20
20
  ActionRunTaskResponse,
21
21
  BrowserSessionResponse,
22
+ Secret,
22
23
  TaskEvent,
23
24
  TaskResponse,
24
25
  TaskUpdateRequest,
@@ -566,6 +567,7 @@ class AsyncSessionHandle(AsyncTaskHandleEx):
566
567
  response_model: dict[str, Any] | Type[BaseModel] | None = None,
567
568
  url: str | None = None,
568
569
  metadata: dict[str, Any] | None = None,
570
+ secrets: dict[str, Secret] | None = None,
569
571
  ):
570
572
  """Extracts from the given URL."""
571
573
  if response_model is not None and not isinstance(response_model, dict):
@@ -580,6 +582,7 @@ class AsyncSessionHandle(AsyncTaskHandleEx):
580
582
  "response_model": response_model,
581
583
  "url": url,
582
584
  "metadata": metadata,
585
+ "secrets": {k: v.model_dump(context={"reveal_secrets": True}) for k, v in secrets.items()} if secrets else None,
583
586
  },
584
587
  },
585
588
  )
@@ -721,9 +724,10 @@ class SessionHandle(TaskHandleEx):
721
724
  response_model: dict[str, Any] | Type[BaseModel] | None = None,
722
725
  url: str | None = None,
723
726
  metadata: dict[str, Any] | None = None,
727
+ secrets: dict[str, Secret] | None = None,
724
728
  ):
725
729
  """Extracts from the given URL."""
726
- return self._run_async(self._async_handle.run_task(task, max_steps, response_model, url, metadata))
730
+ return self._run_async(self._async_handle.run_task(task, max_steps, response_model, url, metadata, secrets))
727
731
 
728
732
  def result(self, timeout: int | None = None, poll_interval: float | None = None) -> "TaskResponse":
729
733
  """Waits for the session to close and returns the result."""
@@ -9,7 +9,11 @@ The proxy connects to a remote FRP server and exposes a local SOCKS5 proxy
9
9
  that can be used by the browser session.
10
10
  """
11
11
 
12
+ import collections
13
+ import contextlib
14
+ import fcntl
12
15
  import logging
16
+ import os
13
17
  import platform
14
18
  import shutil
15
19
  import subprocess
@@ -24,14 +28,28 @@ from dataclasses import dataclass, field
24
28
  from pathlib import Path
25
29
  from typing import Any
26
30
 
31
+ _frpc_logger = logging.getLogger("smooth.frpc")
32
+
27
33
  # FRP version to use
28
34
  FRP_VERSION = "0.66.0"
29
35
 
30
36
  # Directory to store FRP binaries and configs
31
37
  FRP_DIR = Path.home() / ".smooth" / "frp"
32
38
 
33
- # Lock to prevent concurrent FRP installations from racing
34
- _install_lock = threading.Lock()
39
+ @contextlib.contextmanager
40
+ def _file_lock(lock_path: Path):
41
+ """Cross-process file lock using fcntl (Unix) or msvcrt (Windows)."""
42
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
43
+ fd = open(lock_path, "w")
44
+ try:
45
+ if platform.system().lower() == "windows":
46
+ import msvcrt
47
+ msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
48
+ else:
49
+ fcntl.flock(fd, fcntl.LOCK_EX)
50
+ yield
51
+ finally:
52
+ fd.close()
35
53
 
36
54
 
37
55
  @dataclass
@@ -48,9 +66,11 @@ class ProxyConfig:
48
66
  class _ProxyState:
49
67
  """Internal state for a proxy instance."""
50
68
 
51
- process: subprocess.Popen[bytes] | None = None
69
+ process: subprocess.Popen[str] | None = None
52
70
  config_file: Path | None = None
53
71
  lock: threading.Lock = field(default_factory=threading.Lock)
72
+ log_tail: collections.deque[str] = field(default_factory=lambda: collections.deque(maxlen=200))
73
+ drain_thread: threading.Thread | None = None
54
74
 
55
75
 
56
76
  class FRPProxy:
@@ -114,16 +134,16 @@ class FRPProxy:
114
134
  bin_name = "frpc.exe" if os_name == "windows" else "frpc"
115
135
  bin_path = FRP_DIR / bin_name
116
136
 
117
- # Check if binary already exists
137
+ # Fast path: binary already installed
118
138
  if bin_path.exists():
119
139
  return bin_path
120
140
 
121
- with _install_lock:
122
- # Re-check after acquiring lock (another thread may have installed it)
141
+ # Cross-process lock: only one process downloads at a time
142
+ with _file_lock(FRP_DIR / ".install.lock"):
143
+ # Re-check after acquiring lock (another process may have installed it)
123
144
  if bin_path.exists():
124
145
  return bin_path
125
146
 
126
- # Construct download URL
127
147
  folder_name = f"frp_{FRP_VERSION}_{os_name}_{arch}"
128
148
  filename = f"{folder_name}.{ext}"
129
149
  url = f"https://github.com/fatedier/frp/releases/download/v{FRP_VERSION}/{filename}"
@@ -149,11 +169,14 @@ class FRPProxy:
149
169
  if attempt == max_retries - 1:
150
170
  raise
151
171
  backoff = 2 ** attempt
152
- logging.getLogger("smooth").warning("FRP download failed (attempt %d/%d), retrying in %ds: %s", attempt + 1, max_retries, backoff, e)
172
+ logging.getLogger("smooth").warning(
173
+ "FRP download failed (attempt %d/%d), retrying in %ds: %s",
174
+ attempt + 1, max_retries, backoff, e,
175
+ )
153
176
  time.sleep(backoff)
154
177
 
155
- # Extract to a unique temp dir to avoid races
156
- extract_dir = FRP_DIR / f"extract_tmp_{threading.get_ident()}"
178
+ # Extract to a unique temp dir (PID-based to avoid cross-process collisions)
179
+ extract_dir = FRP_DIR / f"extract_tmp_{os.getpid()}"
157
180
  extract_dir.mkdir(exist_ok=True)
158
181
 
159
182
  if ext == "zip":
@@ -163,23 +186,26 @@ class FRPProxy:
163
186
  with tarfile.open(tmp_path, "r:gz") as t:
164
187
  t.extractall(extract_dir)
165
188
 
166
- # Move binary
189
+ # Move binary: write to temp name, chmod, then atomic rename
167
190
  src = extract_dir / folder_name / bin_name
168
- if bin_path.exists():
169
- bin_path.unlink()
170
- shutil.move(str(src), str(bin_path))
191
+ tmp_bin = FRP_DIR / f".{bin_name}.tmp.{os.getpid()}"
192
+ shutil.move(str(src), str(tmp_bin))
193
+
194
+ if os_name != "windows":
195
+ tmp_bin.chmod(0o755)
196
+
197
+ os.rename(str(tmp_bin), str(bin_path))
171
198
 
172
199
  # Cleanup
173
- tmp_path.unlink()
200
+ tmp_path.unlink(missing_ok=True)
174
201
  shutil.rmtree(extract_dir, ignore_errors=True)
175
202
 
176
- # Make executable on Unix
177
- if os_name != "windows":
178
- bin_path.chmod(0o755)
179
-
180
203
  return bin_path
181
204
 
182
205
  except Exception as e:
206
+ # Clean up partial artifacts on failure
207
+ tmp_bin = FRP_DIR / f".{bin_name}.tmp.{os.getpid()}"
208
+ tmp_bin.unlink(missing_ok=True)
183
209
  raise RuntimeError(f"Failed to install FRP: {e}") from e
184
210
 
185
211
  def _create_config(self) -> Path:
@@ -201,7 +227,8 @@ auth:
201
227
  token: "{self.config.token}"
202
228
 
203
229
  log:
204
- level: "error"
230
+ to: "console"
231
+ level: "info"
205
232
 
206
233
  transport:
207
234
  protocol: "wss"
@@ -236,27 +263,52 @@ proxies:
236
263
  # Build command
237
264
  cmd = [str(self._bin_path), "-c", str(self._state.config_file)]
238
265
 
239
- # Start process with suppressed output
266
+ # Pipe stdout/stderr so we can forward frpc's own logs through Python's
267
+ # logging system (captured by pytest/log handlers) and keep a bounded
268
+ # tail for error reporting.
240
269
  self._state.process = subprocess.Popen(
241
270
  cmd,
242
- stdout=subprocess.DEVNULL,
243
- stderr=subprocess.DEVNULL,
271
+ stdout=subprocess.PIPE,
272
+ stderr=subprocess.STDOUT,
273
+ bufsize=1,
274
+ text=True,
244
275
  )
245
276
 
246
- # Give it a moment to start and check if it failed immediately
247
- try:
248
- # Wait for the process to exit or timeout
249
- stdout_data, stderr_data = self._state.process.communicate(timeout=1.0)
277
+ session_id = self.config.session_id
278
+ proc = self._state.process
279
+ tail = self._state.log_tail
280
+
281
+ def _drain() -> None:
282
+ assert proc.stdout is not None
283
+ try:
284
+ for line in proc.stdout:
285
+ line = line.rstrip()
286
+ if not line:
287
+ continue
288
+ tail.append(line)
289
+ _frpc_logger.info("[%s] %s", session_id, line)
290
+ except Exception:
291
+ pass
250
292
 
251
- # If we get here, the process exited within 1 second
252
- stderr = stderr_data.decode() if stderr_data else ""
253
- stdout = stdout_data.decode() if stdout_data else ""
254
- self._cleanup()
255
- raise RuntimeError(f"FRP process exited immediately: {stderr}. Output: {stdout}")
293
+ self._state.drain_thread = threading.Thread(
294
+ target=_drain, name=f"frpc-drain-{session_id}", daemon=True,
295
+ )
296
+ self._state.drain_thread.start()
256
297
 
298
+ # Give it a moment to start and check if it failed immediately.
299
+ try:
300
+ self._state.process.wait(timeout=1.0)
301
+ rc = self._state.process.returncode
302
+ # Let the drain thread flush what it has.
303
+ self._state.drain_thread.join(timeout=0.5)
304
+ tail_str = "\n".join(tail)
305
+ self._cleanup()
306
+ raise RuntimeError(
307
+ f"FRP process exited immediately (rc={rc}). frpc output:\n{tail_str}"
308
+ )
257
309
  except subprocess.TimeoutExpired:
258
- # Process is still running after 1 second
259
- pass
310
+ # Process is still running after 1 second — expected happy path.
311
+ pass
260
312
 
261
313
  except Exception as e:
262
314
  self._cleanup()
@@ -283,6 +335,13 @@ proxies:
283
335
  pass
284
336
  self._state.process = None
285
337
 
338
+ if self._state.drain_thread is not None:
339
+ try:
340
+ self._state.drain_thread.join(timeout=1.0)
341
+ except Exception:
342
+ pass
343
+ self._state.drain_thread = None
344
+
286
345
  if self._state.config_file is not None:
287
346
  try:
288
347
  if self._state.config_file.exists():
@@ -288,7 +288,7 @@ async def start_session(args: argparse.Namespace):
288
288
  device=args.device,
289
289
  allowed_urls=allowed_urls,
290
290
  enable_recording=True, # Enabled by default
291
- stealth_mode=True, # Enabled by default
291
+ use_stealth=True, # Enabled by default
292
292
  use_adblock=True, # Enabled by default
293
293
  proxy_server=proxy_server,
294
294
  proxy_username=proxy_username,
@@ -21,6 +21,12 @@ SensitiveStr = Annotated[SecretStr, PlainSerializer(_serialize_secret, when_used
21
21
 
22
22
  DeviceType = Literal["desktop", "mobile", "desktop-lg"]
23
23
 
24
+ class Secret(BaseModel):
25
+ """A secret value with URL-based access control."""
26
+
27
+ value: SensitiveStr = Field(description="The secret value.")
28
+ allowed_urls: list[str] = Field(description="URL patterns where this secret can be used (e.g. 'https://github.com/*').")
29
+
24
30
 
25
31
  class Certificate(BaseModel):
26
32
  """Client certificate for accessing secure websites.
@@ -112,7 +118,12 @@ class TaskRequest(BaseModel):
112
118
  "Changes made during the task will not be saved back to the profile."
113
119
  ),
114
120
  )
115
- stealth_mode: bool = Field(default=False, description="Run the browser in stealth mode.")
121
+ stealth_mode: bool = Field(
122
+ default=False,
123
+ description="(Deprecated, ignored) Use `use_stealth` instead. Stealth is on by default.",
124
+ deprecated=True,
125
+ )
126
+ use_stealth: bool = Field(default=True, description="Run the browser in stealth mode. Default is True.")
116
127
  proxy_server: str | None = Field(
117
128
  default=None,
118
129
  description=("Proxy server url to route browser traffic through."),
@@ -361,6 +372,7 @@ class BrowserSessionRequest(BaseModel):
361
372
  proxy_username: str | None = Field(default=None, description="Proxy server username.")
362
373
  proxy_password: SensitiveStr | None = Field(default=None, description="Proxy server password.")
363
374
  extensions: list[str] | None = Field(default=None, description="List of extensions to install for the task.")
375
+ use_stealth: bool = Field(default=True, description="Run the browser in stealth mode. Default is True.")
364
376
 
365
377
  @model_validator(mode="before")
366
378
  @classmethod
@@ -374,6 +386,17 @@ class BrowserSessionRequest(BaseModel):
374
386
  data["profile_id"] = data.pop("session_id") # pyright: ignore[reportUnknownMemberType]
375
387
  return data # pyright: ignore[reportUnknownVariableType]
376
388
 
389
+ @model_validator(mode="before")
390
+ @classmethod
391
+ def _handle_deprecated_stealth_mode(cls, data: Any) -> Any:
392
+ if isinstance(data, dict) and "stealth_mode" in data:
393
+ warnings.warn(
394
+ "'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
395
+ DeprecationWarning,
396
+ stacklevel=2,
397
+ )
398
+ return data # pyright: ignore[reportUnknownVariableType]
399
+
377
400
  @computed_field(return_type=str | None)
378
401
  @property
379
402
  def session_id(self):