pydoll-python 1.3.3__tar.gz → 1.4.0__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.
Files changed (42) hide show
  1. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/PKG-INFO +23 -2
  2. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/README.md +22 -1
  3. pydoll_python-1.4.0/pydoll/browser/__init__.py +4 -0
  4. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/browser/base.py +22 -19
  5. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/browser/chrome.py +4 -5
  6. pydoll_python-1.4.0/pydoll/browser/constants.py +6 -0
  7. pydoll_python-1.4.0/pydoll/browser/edge.py +74 -0
  8. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/browser/managers.py +61 -33
  9. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/browser/options.py +23 -2
  10. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/browser/page.py +94 -1
  11. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/dom.py +71 -1
  12. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/input.py +89 -0
  13. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/page.py +23 -0
  14. pydoll_python-1.4.0/pydoll/common/__init__.py +1 -0
  15. pydoll_python-1.4.0/pydoll/common/keyboard.py +101 -0
  16. pydoll_python-1.4.0/pydoll/common/keys.py +52 -0
  17. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/connection/connection.py +6 -2
  18. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/element.py +96 -6
  19. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pyproject.toml +1 -1
  20. pydoll_python-1.3.3/pydoll/mixins/__init__.py +0 -0
  21. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/LICENSE +0 -0
  22. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/__init__.py +0 -0
  23. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/__init__.py +0 -0
  24. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/browser.py +0 -0
  25. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/fetch.py +0 -0
  26. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/network.py +0 -0
  27. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/runtime.py +0 -0
  28. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/storage.py +0 -0
  29. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/commands/target.py +0 -0
  30. {pydoll_python-1.3.3/pydoll/browser → pydoll_python-1.4.0/pydoll/connection}/__init__.py +0 -0
  31. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/connection/managers.py +0 -0
  32. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/constants.py +0 -0
  33. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/events/__init__.py +0 -0
  34. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/events/browser.py +0 -0
  35. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/events/dom.py +0 -0
  36. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/events/fetch.py +0 -0
  37. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/events/network.py +0 -0
  38. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/events/page.py +0 -0
  39. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/exceptions.py +0 -0
  40. {pydoll_python-1.3.3/pydoll/connection → pydoll_python-1.4.0/pydoll/mixins}/__init__.py +0 -0
  41. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/mixins/find_elements.py +0 -0
  42. {pydoll_python-1.3.3 → pydoll_python-1.4.0}/pydoll/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pydoll-python
3
- Version: 1.3.3
3
+ Version: 1.4.0
4
4
  Summary:
5
5
  Author: Thalison Fernandes
6
6
  Author-email: thalissfernandes99@gmail.com
@@ -26,7 +26,9 @@ Description-Content-Type: text/markdown
26
26
  </p>
27
27
 
28
28
  <p align="center">
29
- <img src="https://codecov.io/github/thalissonvs/pydoll/graph/badge.svg?token=40I938OGM9"/>
29
+ <a href="https://codecov.io/gh/autoscrape-labs/pydoll">
30
+ <img src="https://codecov.io/gh/autoscrape-labs/pydoll/graph/badge.svg?token=40I938OGM9"/>
31
+ </a>
30
32
  <img src="https://github.com/thalissonvs/pydoll/actions/workflows/tests.yml/badge.svg" alt="Tests">
31
33
  <img src="https://github.com/thalissonvs/pydoll/actions/workflows/ruff-ci.yml/badge.svg" alt="Ruff CI">
32
34
  <img src="https://github.com/thalissonvs/pydoll/actions/workflows/release.yml/badge.svg" alt="Release">
@@ -669,6 +671,25 @@ input_field = await page.find_element(By.CSS_SELECTOR, 'input[name="username"]')
669
671
  await input_field.send_keys("user123")
670
672
  ```
671
673
 
674
+ ##### `async set_input_files(files: Union[str, Path, List[Union[str, Path]]])`
675
+ Send files to an input element:
676
+
677
+ ```python
678
+ file_input_field = await page.find_element(By.XPATH, '//input[@type="file"]')
679
+ # Single-file upload
680
+ await file_input_field.set_input_files(r'c:\demo\demo1.file')
681
+ # Multi-file uploads
682
+ await file_input_field.set_input_files([r'c:\demo\demo1.file', r'c:\demo\demo2.file'])
683
+ ```
684
+
685
+ You can also do this with the `expect_file_chooser` context manager:
686
+ ```python
687
+ async with page.expect_file_chooser(files=r'c:\demo\demo1.file'):
688
+ file_input_field = await page.find_element(By.XPATH, '//button[@id="upload-button-demo"]')
689
+ await file_input_field.click()
690
+ ```
691
+ This way supports any type of elements, not only inputs.
692
+
672
693
  ##### `async type_keys(text: str)`
673
694
  Type realistically, key by key, simulating human input.
674
695
 
@@ -7,7 +7,9 @@
7
7
  </p>
8
8
 
9
9
  <p align="center">
10
- <img src="https://codecov.io/github/thalissonvs/pydoll/graph/badge.svg?token=40I938OGM9"/>
10
+ <a href="https://codecov.io/gh/autoscrape-labs/pydoll">
11
+ <img src="https://codecov.io/gh/autoscrape-labs/pydoll/graph/badge.svg?token=40I938OGM9"/>
12
+ </a>
11
13
  <img src="https://github.com/thalissonvs/pydoll/actions/workflows/tests.yml/badge.svg" alt="Tests">
12
14
  <img src="https://github.com/thalissonvs/pydoll/actions/workflows/ruff-ci.yml/badge.svg" alt="Ruff CI">
13
15
  <img src="https://github.com/thalissonvs/pydoll/actions/workflows/release.yml/badge.svg" alt="Release">
@@ -650,6 +652,25 @@ input_field = await page.find_element(By.CSS_SELECTOR, 'input[name="username"]')
650
652
  await input_field.send_keys("user123")
651
653
  ```
652
654
 
655
+ ##### `async set_input_files(files: Union[str, Path, List[Union[str, Path]]])`
656
+ Send files to an input element:
657
+
658
+ ```python
659
+ file_input_field = await page.find_element(By.XPATH, '//input[@type="file"]')
660
+ # Single-file upload
661
+ await file_input_field.set_input_files(r'c:\demo\demo1.file')
662
+ # Multi-file uploads
663
+ await file_input_field.set_input_files([r'c:\demo\demo1.file', r'c:\demo\demo2.file'])
664
+ ```
665
+
666
+ You can also do this with the `expect_file_chooser` context manager:
667
+ ```python
668
+ async with page.expect_file_chooser(files=r'c:\demo\demo1.file'):
669
+ file_input_field = await page.find_element(By.XPATH, '//button[@id="upload-button-demo"]')
670
+ await file_input_field.click()
671
+ ```
672
+ This way supports any type of elements, not only inputs.
673
+
653
674
  ##### `async type_keys(text: str)`
654
675
  Type realistically, key by key, simulating human input.
655
676
 
@@ -0,0 +1,4 @@
1
+ from pydoll.browser.chrome import Chrome
2
+ from pydoll.browser.edge import Edge
3
+
4
+ __all__ = ['Chrome', 'Edge']
@@ -4,6 +4,7 @@ from functools import partial
4
4
  from random import randint
5
5
 
6
6
  from pydoll import exceptions
7
+ from pydoll.browser.constants import BrowserType
7
8
  from pydoll.browser.managers import (
8
9
  BrowserOptionsManager,
9
10
  BrowserProcessManager,
@@ -35,6 +36,7 @@ class Browser(ABC): # noqa: PLR0904
35
36
  self,
36
37
  options: Options | None = None,
37
38
  connection_port: int = None,
39
+ browser_type: BrowserType = None
38
40
  ):
39
41
  """
40
42
  Initializes the Browser instance.
@@ -43,11 +45,15 @@ class Browser(ABC): # noqa: PLR0904
43
45
  options (Options | None): An instance of the Options class to
44
46
  configure the browser. If None, default options will be used.
45
47
  connection_port (int): The port to connect to the browser.
48
+ browser_type (BrowserType): The type of browser to use.
49
+ If None, it will be inferred from the options.
46
50
 
47
51
  Raises:
48
52
  TypeError: If any of the arguments are not callable.
49
53
  """
50
- self.options = BrowserOptionsManager.initialize_options(options)
54
+ self.options = BrowserOptionsManager.initialize_options(
55
+ options, browser_type
56
+ )
51
57
  self._proxy_manager = ProxyManager(self.options)
52
58
  self._connection_port = (
53
59
  connection_port if connection_port else randint(9223, 9322)
@@ -77,7 +83,7 @@ class Browser(ABC): # noqa: PLR0904
77
83
  exc_val: The exception value, if raised.
78
84
  exc_tb: The traceback, if an exception was raised.
79
85
  """
80
- if await self._is_browser_running():
86
+ if await self._is_browser_running(timeout=2):
81
87
  await self.stop()
82
88
 
83
89
  await self._connection_handler.close()
@@ -242,6 +248,7 @@ class Browser(ABC): # noqa: PLR0904
242
248
  await self._execute_command(BrowserCommands.CLOSE)
243
249
  self._browser_process_manager.stop_process()
244
250
  self._temp_directory_manager.cleanup()
251
+ await self._connection_handler.close()
245
252
  else:
246
253
  raise exceptions.BrowserNotRunning('Browser is not running')
247
254
 
@@ -505,13 +512,16 @@ class Browser(ABC): # noqa: PLR0904
505
512
  """
506
513
 
507
514
  valid_page = next(
508
- (page for page in pages
509
- if page.get('type') == 'page' and page.get('attached')),
510
- None
515
+ (
516
+ page
517
+ for page in pages
518
+ if page.get('type') == 'page' and page.get('attached')
519
+ ),
520
+ None,
511
521
  )
512
522
 
513
523
  if not valid_page:
514
- raise RuntimeError("No valid attached browser page found.")
524
+ raise RuntimeError('No valid attached browser page found.')
515
525
 
516
526
  target_id = valid_page.get('targetId')
517
527
  if not target_id:
@@ -549,20 +559,13 @@ class Browser(ABC): # noqa: PLR0904
549
559
  )
550
560
 
551
561
  def _setup_user_dir(self):
552
- """
553
- Prepares the user data directory if needed.
554
-
555
- This method creates a temporary directory for browser data if
556
- no user directory is specified in the browser options.
557
-
558
- Returns:
559
- None
560
- """
561
- temp_dir = self._temp_directory_manager.create_temp_dir()
562
- if '--user-data-dir' not in [
563
- arg.split('=')[0] for arg in self.options.arguments
562
+ """Prepares the user data directory if necessary."""
563
+ if "--user-data-dir" not in [
564
+ arg.split("=")[0] for arg in self.options.arguments
564
565
  ]:
565
- self.options.arguments.append(f'--user-data-dir={temp_dir.name}')
566
+ # For all browsers, use a temporary directory
567
+ temp_dir = self._temp_directory_manager.create_temp_dir()
568
+ self.options.arguments.append(f"--user-data-dir={temp_dir.name}")
566
569
 
567
570
  @abstractmethod
568
571
  def _get_default_binary_location(self) -> str:
@@ -2,6 +2,7 @@ import platform
2
2
  from typing import Optional
3
3
 
4
4
  from pydoll.browser.base import Browser
5
+ from pydoll.browser.constants import BrowserType
5
6
  from pydoll.browser.managers import BrowserOptionsManager
6
7
  from pydoll.browser.options import Options
7
8
 
@@ -28,7 +29,7 @@ class Chrome(Browser):
28
29
  connection_port (int): The port to connect to the browser.
29
30
  Defaults to 9222.
30
31
  """
31
- super().__init__(options, connection_port)
32
+ super().__init__(options, connection_port, BrowserType.CHROME)
32
33
 
33
34
  @staticmethod
34
35
  def _get_default_binary_location():
@@ -57,7 +58,7 @@ class Chrome(Browser):
57
58
  ],
58
59
  'Darwin': [
59
60
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
60
- ]
61
+ ],
61
62
  }
62
63
 
63
64
  browser_path = browser_paths.get(os_name)
@@ -65,6 +66,4 @@ class Chrome(Browser):
65
66
  if not browser_path:
66
67
  raise ValueError('Unsupported OS')
67
68
 
68
- return BrowserOptionsManager.validate_browser_paths(
69
- browser_path
70
- )
69
+ return BrowserOptionsManager.validate_browser_paths(browser_path)
@@ -0,0 +1,6 @@
1
+ from enum import Enum, auto
2
+
3
+
4
+ class BrowserType(Enum):
5
+ CHROME = auto()
6
+ EDGE = auto()
@@ -0,0 +1,74 @@
1
+ import platform
2
+ from typing import Optional
3
+
4
+ from pydoll.browser.base import Browser
5
+ from pydoll.browser.constants import BrowserType
6
+ from pydoll.browser.managers import BrowserOptionsManager
7
+ from pydoll.browser.options import Options
8
+
9
+
10
+ class Edge(Browser):
11
+ """
12
+ A class that implements the Edge browser functionality.
13
+
14
+ This class provides specific implementation for launching and
15
+ controlling Microsoft Edge browsers.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ options: Optional[Options] = None,
21
+ connection_port: Optional[int] = None,
22
+ ):
23
+ """
24
+ Initializes the Edge browser instance.
25
+
26
+ Args:
27
+ options (Options | None): An instance of Options class to configure
28
+ the browser. If None, default options will be used.
29
+ connection_port (int): The port to connect to the browser.
30
+ Defaults to a random port between 9223 and 9322.
31
+ """
32
+ super().__init__(options, connection_port, BrowserType.EDGE)
33
+
34
+ @staticmethod
35
+ def _get_default_binary_location():
36
+ """
37
+ Gets the default location of the Edge browser executable.
38
+
39
+ This method determines the default Edge executable path based
40
+ on the operating system.
41
+
42
+ Returns:
43
+ str: The path to the Edge browser executable.
44
+
45
+ Raises:
46
+ ValueError: If the operating system is not supported or
47
+ the browser executable is not found at the default location.
48
+ """
49
+ os_name = platform.system()
50
+
51
+ browser_paths = {
52
+ "Windows": [
53
+ (r"C:\Program Files\Microsoft\Edge\Application"
54
+ r"\msedge.exe"),
55
+ (r"C:\Program Files (x86)\Microsoft\Edge"
56
+ r"\Application\msedge.exe"),
57
+ ],
58
+ "Linux": [
59
+ "/usr/bin/microsoft-edge",
60
+ ],
61
+ "Darwin": [
62
+ ("/Applications/Microsoft Edge.app/Contents/MacOS"
63
+ "/Microsoft Edge"),
64
+ ],
65
+ }
66
+
67
+ browser_path = browser_paths.get(os_name)
68
+
69
+ if not browser_path:
70
+ raise ValueError('Unsupported OS')
71
+
72
+ return BrowserOptionsManager.validate_browser_paths(
73
+ browser_path
74
+ )
@@ -4,7 +4,8 @@ import subprocess
4
4
  from contextlib import suppress
5
5
  from tempfile import TemporaryDirectory
6
6
 
7
- from pydoll.browser.options import Options
7
+ from pydoll.browser.constants import BrowserType
8
+ from pydoll.browser.options import ChromeOptions, EdgeOptions, Options
8
9
 
9
10
 
10
11
  class ProxyManager:
@@ -64,8 +65,8 @@ class ProxyManager:
64
65
  argument and the proxy value if found, None otherwise.
65
66
  """
66
67
  for index, arg in enumerate(self.options.arguments):
67
- if arg.startswith('--proxy-server='):
68
- return index, arg.split('=', 1)[1]
68
+ if arg.startswith("--proxy-server="):
69
+ return index, arg.split("=", 1)[1]
69
70
  return None
70
71
 
71
72
  @staticmethod
@@ -87,12 +88,12 @@ class ProxyManager:
87
88
  - str: Password (or None if no credentials)
88
89
  - str: Clean proxy URL without credentials
89
90
  """
90
- if '@' not in proxy_value:
91
+ if "@" not in proxy_value:
91
92
  return False, None, None, proxy_value
92
93
 
93
94
  try:
94
- creds_part, server_part = proxy_value.split('@', 1)
95
- username, password = creds_part.split(':', 1)
95
+ creds_part, server_part = proxy_value.split("@", 1)
96
+ username, password = creds_part.split(":", 1)
96
97
  return True, username, password, server_part
97
98
  except ValueError:
98
99
  return False, None, None, proxy_value
@@ -112,7 +113,7 @@ class ProxyManager:
112
113
  Returns:
113
114
  None
114
115
  """
115
- self.options.arguments[index] = f'--proxy-server={clean_proxy}'
116
+ self.options.arguments[index] = f"--proxy-server={clean_proxy}"
116
117
 
117
118
 
118
119
  class BrowserProcessManager:
@@ -149,11 +150,13 @@ class BrowserProcessManager:
149
150
  Returns:
150
151
  subprocess.Popen: The started browser process.
151
152
  """
152
- self._process = self._process_creator([
153
- binary_location,
154
- f'--remote-debugging-port={port}',
155
- *arguments,
156
- ])
153
+ self._process = self._process_creator(
154
+ [
155
+ binary_location,
156
+ f"--remote-debugging-port={port}",
157
+ *arguments,
158
+ ]
159
+ )
157
160
  return self._process
158
161
 
159
162
  @staticmethod
@@ -239,44 +242,69 @@ class TempDirectoryManager:
239
242
 
240
243
  class BrowserOptionsManager:
241
244
  @staticmethod
242
- def initialize_options(options: Options | None) -> Options:
245
+ def initialize_options(
246
+ options: Options | None, browser_type: BrowserType = None
247
+ ) -> Options:
243
248
  """
244
- Initializes options for the browser.
249
+ Initialize browser options based on browser type.
245
250
 
246
- This method ensures that a valid Options instance is available,
247
- creating a default one if necessary.
251
+ Creates a new options instance based on browser type if none
252
+ is provided, or validates and returns the provided
253
+ options instance.
248
254
 
249
255
  Args:
250
- options (Options | None): An Options instance or None.
256
+ options (Options | None): Browser options instance.
257
+ If None, a new instance
258
+ will be created based on browser_type
259
+ browser_type (BrowserType): Type of browser, used to create
260
+ appropriate options instance
251
261
 
252
262
  Returns:
253
- Options: An initialized Options instance.
263
+ Options: The initialized browser options instance
254
264
 
255
265
  Raises:
256
- ValueError: If options is not None and not an instance of Options.
266
+ ValueError: If provided options is not an instance
267
+ of Options class
257
268
  """
258
269
  if options is None:
259
- return Options()
270
+ if browser_type == BrowserType.CHROME:
271
+ return ChromeOptions()
272
+ elif browser_type == BrowserType.EDGE:
273
+ return EdgeOptions()
274
+ else:
275
+ return Options()
276
+
260
277
  if not isinstance(options, Options):
261
- raise ValueError('Invalid options')
278
+ raise ValueError("Invalid options")
279
+
262
280
  return options
263
281
 
264
282
  @staticmethod
265
283
  def add_default_arguments(options: Options):
266
- """
267
- Adds default arguments to the provided options.
284
+ """Adds default arguments to the provided options"""
285
+ options.arguments.append("--no-first-run")
286
+ options.arguments.append("--no-default-browser-check")
268
287
 
269
- This method appends standard browser arguments that improve
270
- reliability and automation performance.
288
+ # Add browser-specific arguments based on options type
289
+ if isinstance(options, EdgeOptions):
290
+ BrowserOptionsManager._add_edge_arguments(options)
291
+ elif isinstance(options, ChromeOptions):
292
+ BrowserOptionsManager._add_chrome_arguments(options)
271
293
 
272
- Args:
273
- options (Options): The options instance to modify.
294
+ @staticmethod
295
+ def _add_edge_arguments(options: Options):
296
+ """Adds Edge-specific arguments to the options"""
297
+ options.add_argument("--disable-crash-reporter")
298
+ options.add_argument("--disable-features=TranslateUI")
299
+ options.add_argument("--disable-component-update")
300
+ options.add_argument("--disable-background-networking")
301
+ options.add_argument("--remote-allow-origins=*")
274
302
 
275
- Returns:
276
- None
277
- """
278
- options.arguments.append('--no-first-run')
279
- options.arguments.append('--no-default-browser-check')
303
+ @staticmethod
304
+ def _add_chrome_arguments(options: Options):
305
+ """Adds Chrome-specific arguments to the options"""
306
+ options.add_argument("--remote-allow-origins=*")
307
+ # Add other Chrome-specific arguments here
280
308
 
281
309
  @staticmethod
282
310
  def validate_browser_paths(paths: list[str]) -> str:
@@ -299,4 +327,4 @@ class BrowserOptionsManager:
299
327
  for path in paths:
300
328
  if os.path.exists(path) and os.access(path, os.X_OK):
301
329
  return path
302
- raise ValueError(f"No valid browser path found in: {paths}")
330
+ raise ValueError(f'No valid browser path found in: {paths}')
@@ -1,3 +1,4 @@
1
+
1
2
  class Options:
2
3
  """
3
4
  A class to manage command-line options for a browser instance.
@@ -26,6 +27,16 @@ class Options:
26
27
  """
27
28
  return self._arguments
28
29
 
30
+ @arguments.setter
31
+ def arguments(self, args_list: list):
32
+ """
33
+ Sets the list of command-line arguments.
34
+
35
+ Args:
36
+ args_list (list): A list of command-line arguments.
37
+ """
38
+ self._arguments = args_list
39
+
29
40
  @property
30
41
  def binary_location(self) -> str:
31
42
  """
@@ -56,7 +67,17 @@ class Options:
56
67
  Raises:
57
68
  ValueError: If the argument is already in the list of arguments.
58
69
  """
59
- if argument not in self.arguments:
60
- self.arguments.append(argument)
70
+ if argument not in self._arguments:
71
+ self._arguments.append(argument)
61
72
  else:
62
73
  raise ValueError(f'Argument already exists: {argument}')
74
+
75
+
76
+ class ChromeOptions(Options):
77
+ def __init__(self):
78
+ super().__init__()
79
+
80
+
81
+ class EdgeOptions(Options):
82
+ def __init__(self):
83
+ super().__init__()
@@ -1,5 +1,8 @@
1
1
  import asyncio
2
2
  import json
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import List, Union
3
6
 
4
7
  import aiofiles
5
8
 
@@ -13,6 +16,7 @@ from pydoll.commands import (
13
16
  )
14
17
  from pydoll.connection.connection import ConnectionHandler
15
18
  from pydoll.element import WebElement
19
+ from pydoll.events import PageEvents
16
20
  from pydoll.exceptions import InvalidFileExtension
17
21
  from pydoll.mixins.find_elements import FindElementsMixin
18
22
  from pydoll.utils import decode_image_to_bytes
@@ -34,6 +38,7 @@ class Page(FindElementsMixin): # noqa: PLR0904
34
38
  self._network_events_enabled = False
35
39
  self._fetch_events_enabled = False
36
40
  self._dom_events_enabled = False
41
+ self._intercept_file_chooser_dialog_enabled = False
37
42
 
38
43
  @property
39
44
  def page_events_enabled(self) -> bool:
@@ -75,6 +80,17 @@ class Page(FindElementsMixin): # noqa: PLR0904
75
80
  """
76
81
  return self._dom_events_enabled
77
82
 
83
+ @property
84
+ def intercept_file_chooser_dialog_enabled(self) -> bool:
85
+ """
86
+ Returns whether file chooser dialogs are being intercepted or not.
87
+
88
+ Returns:
89
+ bool: True if file chooser dialogs are being intercepted,
90
+ False otherwise.
91
+ """
92
+ return self._intercept_file_chooser_dialog_enabled
93
+
78
94
  @property
79
95
  async def current_url(self) -> str:
80
96
  """
@@ -287,7 +303,7 @@ class Page(FindElementsMixin): # noqa: PLR0904
287
303
  Args:
288
304
  path (str): The file path to save the PDF file to.
289
305
  """
290
- response = await self._execute_command(PageCommands.print_to_pdf(path))
306
+ response = await self._execute_command(PageCommands.print_to_pdf())
291
307
  pdf_b64 = response['result']['data'].encode('utf-8')
292
308
  pdf_bytes = decode_image_to_bytes(pdf_b64)
293
309
  async with aiofiles.open(path, 'wb') as file:
@@ -430,6 +446,22 @@ class Page(FindElementsMixin): # noqa: PLR0904
430
446
  await self._execute_command(DomCommands.enable_dom_events())
431
447
  self._dom_events_enabled = True
432
448
 
449
+ async def enable_intercept_file_chooser_dialog(self):
450
+ """
451
+ Enable intercepting file chooser dialogs.
452
+
453
+ When file chooser interception is enabled, native file chooser dialog
454
+ is not shown. Instead, a protocol event Page.fileChooserOpened is
455
+ emitted.
456
+
457
+ Returns:
458
+ None
459
+ """
460
+ await self._execute_command(
461
+ PageCommands.set_intercept_file_chooser_dialog(True)
462
+ )
463
+ self._intercept_file_chooser_dialog_enabled = True
464
+
433
465
  async def disable_fetch_events(self):
434
466
  """
435
467
  Disables fetch events for the page.
@@ -456,6 +488,21 @@ class Page(FindElementsMixin): # noqa: PLR0904
456
488
  await self._execute_command(PageCommands.disable_page())
457
489
  self._page_events_enabled = False
458
490
 
491
+ async def disable_intercept_file_chooser_dialog(self):
492
+ """
493
+ Disable intercepting file chooser dialogs.
494
+
495
+ When file chooser interception is disabled, native file chooser
496
+ dialog is shown.
497
+
498
+ Returns:
499
+ None
500
+ """
501
+ await self._execute_command(
502
+ PageCommands.set_intercept_file_chooser_dialog(False)
503
+ )
504
+ self._intercept_file_chooser_dialog_enabled = False
505
+
459
506
  async def on(
460
507
  self, event_name: str, callback: callable, temporary: bool = False
461
508
  ):
@@ -554,3 +601,49 @@ class Page(FindElementsMixin): # noqa: PLR0904
554
601
  if asyncio.get_event_loop().time() - start_time > timeout:
555
602
  raise asyncio.TimeoutError('Page load timed out')
556
603
  await asyncio.sleep(0.5)
604
+
605
+ @asynccontextmanager
606
+ async def expect_file_chooser(
607
+ self, files: Union[str, Path, List[Union[str, Path]]]
608
+ ):
609
+ """
610
+ Provide a context manager that expects a file chooser dialog to be
611
+ opened and handles the file upload. When a file selection signal
612
+ is captured, the file is uploaded.
613
+
614
+ Args:
615
+ files (Union[str, Path, List[Union[str, Path]]]): The files to be
616
+ uploaded.
617
+
618
+ Returns:
619
+
620
+ """
621
+
622
+ async def event_handler(event):
623
+ await self._execute_command(
624
+ DomCommands.upload_files(
625
+ files=files,
626
+ backend_node_id=event['params']['backendNodeId'],
627
+ )
628
+ )
629
+
630
+ if self.page_events_enabled is False:
631
+ _before_page_events_enabled = False
632
+ await self.enable_page_events()
633
+ else:
634
+ _before_page_events_enabled = True
635
+
636
+ if self.intercept_file_chooser_dialog_enabled is False:
637
+ await self.enable_intercept_file_chooser_dialog()
638
+
639
+ await self.on(
640
+ PageEvents.FILE_CHOOSER_OPENED, event_handler, temporary=True
641
+ )
642
+
643
+ yield
644
+
645
+ if self.intercept_file_chooser_dialog_enabled is True:
646
+ await self.disable_intercept_file_chooser_dialog()
647
+
648
+ if _before_page_events_enabled is False:
649
+ await self.disable_page_events()
@@ -1,5 +1,6 @@
1
1
  import copy
2
- from typing import Literal
2
+ from pathlib import Path
3
+ from typing import List, Literal, Optional, Union
3
4
 
4
5
  from pydoll.commands.runtime import RuntimeCommands
5
6
  from pydoll.constants import By, Scripts
@@ -43,6 +44,10 @@ class DomCommands:
43
44
  'method': 'DOM.scrollIntoViewIfNeeded',
44
45
  'params': {},
45
46
  }
47
+ SET_FILE_INPUT_FILES_TEMPLATE = {
48
+ 'method': 'DOM.setFileInputFiles',
49
+ 'params': {},
50
+ }
46
51
 
47
52
  @classmethod
48
53
  def scroll_into_view(cls, object_id: str) -> dict:
@@ -325,3 +330,68 @@ class DomCommands:
325
330
  to make it relative.
326
331
  """
327
332
  return f'.{xpath}' if not xpath.startswith('.') else xpath
333
+
334
+ @staticmethod
335
+ def _ensure_file_exists(
336
+ files: Union[str, Path, List[Union[str, Path]]],
337
+ missing_ok: bool = False,
338
+ ) -> List[str]:
339
+ """
340
+ Ensures that the file exists.
341
+
342
+ Args:
343
+ files (Union[str, Path, List[Union[str, Path]]]): Files to check.
344
+ missing_ok (bool): If True, skips the file existence check.
345
+
346
+ Returns:
347
+ List[str]: List of file paths.
348
+ """
349
+ if isinstance(files, str):
350
+ files = [Path(files).absolute()]
351
+ elif isinstance(files, Path):
352
+ files = [files]
353
+
354
+ _has_ensure_files = []
355
+ for filepath in files:
356
+ if isinstance(filepath, str):
357
+ _filepath = Path(filepath).absolute()
358
+ else:
359
+ _filepath = filepath
360
+ if missing_ok is False and _filepath.is_file() is False:
361
+ raise FileExistsError(f'{_filepath} does not exist.')
362
+ _has_ensure_files.append(str(_filepath))
363
+
364
+ return _has_ensure_files
365
+
366
+ @classmethod
367
+ def upload_files(
368
+ cls,
369
+ files: Union[str, Path, List[Union[str, Path]]],
370
+ object_id: Optional[str] = None,
371
+ backend_node_id: Optional[int] = None,
372
+ missing_ok: bool = False,
373
+ ) -> dict:
374
+ """
375
+ Sets the value of the file input to these file paths or files.
376
+
377
+ Args:
378
+ files (Union[str, Path, List[Union[str, Path]]]): Files to upload.
379
+ object_id (Optional[str]): JavaScript object id of node wrapper.
380
+ backend_node_id (Optional[int]): Identifier of the backend node.
381
+ missing_ok (bool): If True, skips the file existence check.
382
+
383
+ Returns:
384
+ dict: The CDP command to set the file input files.
385
+ """
386
+ command = copy.deepcopy(cls.SET_FILE_INPUT_FILES_TEMPLATE)
387
+ if object_id is None and backend_node_id is None:
388
+ raise ValueError(
389
+ 'Either object_id or backend_node_id is required.'
390
+ )
391
+ if object_id is not None:
392
+ command['params']['objectId'] = object_id
393
+ if backend_node_id is not None:
394
+ command['params']['backendNodeId'] = backend_node_id
395
+ command['params']['files'] = cls._ensure_file_exists(files, missing_ok)
396
+
397
+ return command
@@ -1,3 +1,6 @@
1
+ from pydoll.common.keyboard import Keyboard
2
+
3
+
1
4
  class InputCommands:
2
5
  """
3
6
  A class to define input commands for simulating user interactions
@@ -11,6 +14,7 @@ class InputCommands:
11
14
  }
12
15
  KEY_PRESS_TEMPLATE = {'method': 'Input.dispatchKeyEvent', 'params': {}}
13
16
  INSERT_TEXT_TEMPLATE = {'method': 'Input.insertText', 'params': {}}
17
+ KEYBOARD = Keyboard()
14
18
 
15
19
  @classmethod
16
20
  def mouse_press(cls, x: int, y: int) -> dict:
@@ -104,3 +108,88 @@ class InputCommands:
104
108
  'text': text,
105
109
  }
106
110
  return command
111
+
112
+ @classmethod
113
+ def key_down(cls, key: list | tuple, command_id: int) -> dict:
114
+ """
115
+ Generates the command to simulate pressing a key on the keyboard.
116
+
117
+ This method creates a command following the Chrome DevTools
118
+ Protocol (CDP)
119
+ to simulate a "keyDown" event, which represents pressing a key.
120
+
121
+ Args:
122
+ key (list | tuple): The key and code of the key to be pressed.
123
+ command_id (int): The id of the command to be sent.
124
+
125
+ Returns:
126
+ dict: A dictionary containing the command to be sent to
127
+ the browser.
128
+ """
129
+ key, key_code = key
130
+ if key in cls.KEYBOARD.MODIFIER_KEYS:
131
+ cls.KEYBOARD.modifiers.add(key)
132
+
133
+ modifiers = sum(
134
+ cls.KEYBOARD.MODIFIER_KEYS[k] for k in cls.KEYBOARD.modifiers
135
+ )
136
+
137
+ special_key = cls.KEYBOARD.get_special_key(key, modifiers, key_code)
138
+ vk_code = cls.KEYBOARD.SHIFT_SPECIAL.get(special_key, key_code)
139
+ special_code = cls.KEYBOARD.get_special_code(key)
140
+
141
+ key_down = {
142
+ 'type': 'keyDown',
143
+ 'key': key,
144
+ 'code': special_code,
145
+ 'windowsVirtualKeyCode': vk_code,
146
+ 'modifiers': modifiers,
147
+ 'text': special_key,
148
+ }
149
+
150
+ command = cls.KEY_PRESS_TEMPLATE.copy()
151
+ command['id'] = command_id
152
+ command['params'] = key_down
153
+ return command
154
+
155
+ @classmethod
156
+ def key_up(cls, key: list | tuple, command_id) -> dict:
157
+ """
158
+ Generates the command to simulate releasing a key on the keyboard.
159
+
160
+ This method creates a command following the Chrome DevTools
161
+ Protocol (CDP)
162
+ to simulate a "keyUp" event, which represents releasing a key.
163
+
164
+ Args:
165
+ key (list | tuple): The character of the key to be released.
166
+ command_id (int): The id of the command to be sent.
167
+
168
+ Returns:
169
+ dict: A dictionary containing the command to be sent to
170
+ the browser.
171
+ """
172
+ key, key_code = key
173
+ if key in cls.KEYBOARD.MODIFIER_KEYS:
174
+ cls.KEYBOARD.modifiers.discard(key)
175
+
176
+ modifiers = sum(
177
+ cls.KEYBOARD.MODIFIER_KEYS[k] for k in cls.KEYBOARD.modifiers
178
+ )
179
+
180
+ special_key = cls.KEYBOARD.SHIFT_MAP.get(key, key)
181
+ vk_code = cls.KEYBOARD.SHIFT_SPECIAL.get(special_key, key_code)
182
+ special_code = cls.KEYBOARD.get_special_code(key)
183
+
184
+ key_up = {
185
+ 'type': 'keyUp',
186
+ 'key': key,
187
+ 'code': special_code,
188
+ 'windowsVirtualKeyCode': vk_code,
189
+ 'modifiers': modifiers,
190
+ }
191
+
192
+ command = cls.KEY_PRESS_TEMPLATE.copy()
193
+ command['id'] = command_id
194
+ command['params'] = key_up
195
+ return command
@@ -1,3 +1,6 @@
1
+ import copy
2
+
3
+
1
4
  class PageCommands:
2
5
  """
3
6
  PageCommands class provides a set of commands to interact with the
@@ -32,6 +35,10 @@ class PageCommands:
32
35
  }
33
36
  HANDLE_DIALOG = {'method': 'Page.handleJavaScriptDialog', 'params': {}}
34
37
  CLOSE = {'method': 'Page.close'}
38
+ SET_INTERCEPT_FILE_CHOOSER_DIALOG = {
39
+ 'method': 'Page.setInterceptFileChooserDialog',
40
+ 'params': {},
41
+ }
35
42
 
36
43
  @classmethod
37
44
  def handle_dialog(cls, accept: bool = True) -> dict:
@@ -185,3 +192,19 @@ class PageCommands:
185
192
  containing the method to close the current page.
186
193
  """
187
194
  return cls.CLOSE
195
+
196
+ @classmethod
197
+ def set_intercept_file_chooser_dialog(cls, enabled: bool) -> dict:
198
+ """
199
+ Generates the command to set whether to intercept file chooser dialogs.
200
+
201
+ Args:
202
+ enabled: A boolean value indicating whether to enable or disable
203
+ the interception of file chooser dialogs.
204
+
205
+ Returns:
206
+
207
+ """
208
+ command = copy.deepcopy(cls.SET_INTERCEPT_FILE_CHOOSER_DIALOG)
209
+ command['params']['enabled'] = enabled
210
+ return command
@@ -0,0 +1,101 @@
1
+ from pydoll.common.keys import Keys
2
+
3
+
4
+ class Keyboard(Keys):
5
+ MODIFIER_KEYS = {'Alt': 1, 'Control': 2, 'Meta': 4, 'Shift': 8}
6
+ SHIFT_MAP = {
7
+ '1': '!',
8
+ '2': '@',
9
+ '3': '#',
10
+ '4': '$',
11
+ '5': '%',
12
+ '6': '^',
13
+ '7': '&',
14
+ '8': '*',
15
+ '9': '(',
16
+ '0': ')',
17
+ '-': '_',
18
+ '=': '+',
19
+ '[': '{',
20
+ ']': '}',
21
+ '\\': '|',
22
+ ';': ':',
23
+ "'": '"',
24
+ ',': '<',
25
+ '.': '>',
26
+ '/': '?',
27
+ }
28
+ SHIFT_CODES = {
29
+ '\\': 'Backslash',
30
+ '|': 'Backslash',
31
+ '[': 'BracketLeft',
32
+ ']': 'BracketRight',
33
+ ';': 'Semicolon',
34
+ ':': 'Semicolon',
35
+ '/': 'Slash',
36
+ '?': 'Slash',
37
+ '.': 'Period',
38
+ ',': 'Comma',
39
+ '-': 'Minus',
40
+ '+': 'Equal',
41
+ '=': 'Equal',
42
+ }
43
+ SHIFT_SPECIAL = {
44
+ '?': 63,
45
+ '|': 124,
46
+ '~': 126,
47
+ '+': 43,
48
+ '_': 95,
49
+ ':': 58,
50
+ '!': 33,
51
+ '*': 42,
52
+ '(': 57,
53
+ ')': 41,
54
+ '<': 60,
55
+ '>': 62,
56
+ '.': 190,
57
+ }
58
+
59
+ def __init__(self):
60
+ self.modifiers = set()
61
+
62
+ @classmethod
63
+ def get_special_key(cls, key: str, modifiers: int, key_code: int) -> str:
64
+ """
65
+ Return the appropriate text value for special keys
66
+ like Shift, Enter, Space, etc.
67
+
68
+ Args:
69
+ key (str): The key code of the key to be pressed.
70
+ modifiers (int): The modifiers code of the key to be pressed.
71
+ key_code (int): The key code of the key to be pressed.
72
+ """
73
+ if key_code in cls.SPACE:
74
+ return ' '
75
+ elif key_code in cls.ENTER:
76
+ return '\r'
77
+ elif modifiers & 8 and key != 'Shift':
78
+ return cls.SHIFT_MAP.get(key, key.upper())
79
+ elif len(key) == 1 and key.isprintable():
80
+ return key
81
+
82
+ return ''
83
+
84
+ @classmethod
85
+ def get_special_code(cls, key) -> str:
86
+ """
87
+ Return the appropriate text value for special keys
88
+ like Shift, Enter, Space, etc.
89
+
90
+ Args:
91
+ key (str): The key code of the special key.
92
+
93
+ """
94
+ code = cls.SHIFT_CODES.get(key, key)
95
+ key_type = f'Digit{key}' if key.isdigit() else f'Key{key.upper()}'
96
+ if len(key) == 1:
97
+ return key_type
98
+ elif code in cls.MODIFIER_KEYS.keys():
99
+ return f'{code}Left'
100
+
101
+ return code
@@ -0,0 +1,52 @@
1
+ class Keys:
2
+ BACKSPACE = ('Backspace', 8)
3
+ TAB = ('Tab', 9)
4
+ ENTER = ('Enter', 13)
5
+ SHIFT = ('Shift', 16)
6
+ CONTROL = ('Control', 17)
7
+ ALT = ('Alt', 18)
8
+ PAUSE = ('Pause', 19)
9
+ CAPSLOCK = ('CapsLock', 20)
10
+ ESCAPE = ('Escape', 27)
11
+ SPACE = ('Space', 32)
12
+ PAGEUP = ('PageUp', 33)
13
+ PAGEDOWN = ('PageDown', 34)
14
+ END = ('End', 35)
15
+ HOME = ('Home', 36)
16
+ ARROWLEFT = ('ArrowLeft', 37)
17
+ ARROWUP = ('ArrowUp', 38)
18
+ ARROWRIGHT = ('ArrowRight', 39)
19
+ ARROWDOWN = ('ArrowDown', 40)
20
+ PRINTSCREEN = ('PrintScreen', 44)
21
+ INSERT = ('Insert', 45)
22
+ DELETE = ('Delete', 46)
23
+ META = ('Meta', 91)
24
+ METARIGHT = ('MetaRight', 92)
25
+ CONTEXTMENU = ('ContextMenu', 93)
26
+ NUMLOCK = ('NumLock', 144)
27
+ SCROLLLOCK = ('ScrollLock', 145)
28
+
29
+ F1 = ('F1', 112)
30
+ F2 = ('F2', 113)
31
+ F3 = ('F3', 114)
32
+ F4 = ('F4', 115)
33
+ F5 = ('F5', 116)
34
+ F6 = ('F6', 117)
35
+ F7 = ('F7', 118)
36
+ F8 = ('F8', 119)
37
+ F9 = ('F9', 120)
38
+ F10 = ('F10', 121)
39
+ F11 = ('F11', 122)
40
+ F12 = ('F12', 123)
41
+
42
+ SEMICOLON = ('Semicolon', 186)
43
+ EQUALSIGN = ('EqualSign', 187)
44
+ COMMA = ('Comma', 188)
45
+ MINUS = ('Minus', 189)
46
+ PERIOD = ('Period', 190)
47
+ SLASH = ('Slash', 191)
48
+ GRAVEACCENT = ('GraveAccent', 192)
49
+ BRACKETLEFT = ('BracketLeft', 219)
50
+ BACKSLASH = ('Backslash', 220)
51
+ BRACKETRIGHT = ('BracketRight', 221)
52
+ QUOTE = ('Quote', 222)
@@ -180,7 +180,10 @@ class ConnectionHandler:
180
180
  Closes the WebSocket connection and clears all event callbacks.
181
181
  """
182
182
  await self.clear_callbacks()
183
- await self._ws_connection.close()
183
+
184
+ if self._ws_connection is not None:
185
+ await self._ws_connection.close()
186
+
184
187
  logger.info('WebSocket connection closed.')
185
188
 
186
189
  async def _ensure_active_connection(self):
@@ -209,7 +212,8 @@ class ConnectionHandler:
209
212
  ws_address = await self._resolve_ws_address()
210
213
  logger.info(f'Connecting to {ws_address}')
211
214
  self._ws_connection = await self._ws_connector(
212
- ws_address, max_size=1024 * 1024 * 10 # 10MB
215
+ ws_address,
216
+ max_size=1024 * 1024 * 10, # 10MB
213
217
  )
214
218
  self._receive_task = asyncio.create_task(self._receive_events())
215
219
  logger.debug('WebSocket connection established')
@@ -1,5 +1,7 @@
1
1
  import asyncio
2
2
  import json
3
+ from pathlib import Path
4
+ from typing import List, Union
3
5
 
4
6
  import aiofiles
5
7
  from bs4 import BeautifulSoup
@@ -17,7 +19,7 @@ from pydoll.mixins.find_elements import FindElementsMixin
17
19
  from pydoll.utils import decode_image_to_bytes
18
20
 
19
21
 
20
- class WebElement(FindElementsMixin):
22
+ class WebElement(FindElementsMixin): # noqa: PLR0904
21
23
  """
22
24
  Represents a DOM element in the browser.
23
25
 
@@ -26,6 +28,7 @@ class WebElement(FindElementsMixin):
26
28
  attributes, and other common web element interactions. It inherits element
27
29
  finding capabilities from FindElementsMixin.
28
30
  """
31
+
29
32
  def __init__(
30
33
  self,
31
34
  object_id: str,
@@ -53,6 +56,8 @@ class WebElement(FindElementsMixin):
53
56
  self._selector = selector
54
57
  self._connection_handler = connection_handler
55
58
  self._attributes = {}
59
+ self._last_input = ''
60
+ self._command_id = 0
56
61
  self._def_attributes(attributes_list)
57
62
 
58
63
  def __repr__(self):
@@ -316,7 +321,12 @@ class WebElement(FindElementsMixin):
316
321
  'Element is not interactable.'
317
322
  )
318
323
 
319
- async def click(self, x_offset: int = 0, y_offset: int = 0):
324
+ async def click(
325
+ self,
326
+ x_offset: int = 0,
327
+ y_offset: int = 0,
328
+ hold_time: float = 0.1,
329
+ ):
320
330
  """
321
331
  Clicks on the element using mouse events.
322
332
 
@@ -329,6 +339,8 @@ class WebElement(FindElementsMixin):
329
339
  Defaults to 0.
330
340
  y_offset (int): Vertical offset from the center of the element.
331
341
  Defaults to 0.
342
+ hold_time (float, optional): The duration (in seconds) to hold
343
+ the mouse button before releasing.
332
344
 
333
345
  Raises:
334
346
  ElementNotVisible: If the element is not visible on the page.
@@ -363,7 +375,7 @@ class WebElement(FindElementsMixin):
363
375
  press_command = InputCommands.mouse_press(*position_to_click)
364
376
  release_command = InputCommands.mouse_release(*position_to_click)
365
377
  await self._connection_handler.execute_command(press_command)
366
- await asyncio.sleep(0.1)
378
+ await asyncio.sleep(hold_time)
367
379
  await self._connection_handler.execute_command(release_command)
368
380
 
369
381
  async def click_option_tag(self):
@@ -380,7 +392,7 @@ class WebElement(FindElementsMixin):
380
392
  script = Scripts.CLICK_OPTION_TAG.replace('{self.value}', self.value)
381
393
  await self._execute_command(RuntimeCommands.evaluate_script(script))
382
394
 
383
- async def send_keys(self, text: str):
395
+ async def insert_text(self, text: str):
384
396
  """
385
397
  Sends a sequence of keys to the element.
386
398
 
@@ -389,16 +401,94 @@ class WebElement(FindElementsMixin):
389
401
  """
390
402
  await self._execute_command(InputCommands.insert_text(text))
391
403
 
392
- async def type_keys(self, text: str):
404
+ async def set_input_files(
405
+ self, files: Union[str, Path, List[Union[str, Path]]]
406
+ ):
407
+ """
408
+ Sets the value of the file input to these file paths.
409
+
410
+ Args:
411
+ files (Union[str, Path, List[Union[str, Path]]]): Files to upload.
412
+ """
413
+ if (
414
+ self._attributes.get('tag_name', '').lower() != 'input'
415
+ or self._attributes.get('type', '').lower() != 'file'
416
+ ):
417
+ raise exceptions.ElementNotInteractable(
418
+ 'The element is not a file input.'
419
+ )
420
+ await self._execute_command(
421
+ DomCommands.upload_files(files=files, object_id=self._object_id)
422
+ )
423
+
424
+ async def type_keys(self, text: str, interval: float = 0.1):
393
425
  """
394
426
  Types in a realistic manner by sending keys one by one.
395
427
 
396
428
  Args:
397
429
  text (str): The text to send to the element.
430
+ interval (float): The interval between two keys.
398
431
  """
399
432
  for char in text:
400
433
  await self._execute_command(InputCommands.key_press(char))
401
- await asyncio.sleep(0.1)
434
+ await asyncio.sleep(interval)
435
+
436
+ self._last_input = text
437
+
438
+ async def key_down(self, key: list | tuple):
439
+ """
440
+ Simulates pressing down a key.
441
+
442
+ Args:
443
+ key (list|tuple): The key to press.
444
+ """
445
+ self._command_id += 1
446
+ await self._execute_command(
447
+ InputCommands.key_down(key, self._command_id)
448
+ )
449
+
450
+ async def key_up(self, key: list | tuple):
451
+ """
452
+ Simulates releasing a key.
453
+
454
+ Args:
455
+ key (list|tuple): The key to release.
456
+ """
457
+ self._command_id += 1
458
+ await self._execute_command(
459
+ InputCommands.key_up(key, self._command_id)
460
+ )
461
+
462
+ async def send_keys(self, keys: str | tuple, interval: float = 0.1):
463
+ """
464
+ Dispatches an event.
465
+
466
+ Args:
467
+ keys (str | tuple): The event to dispatch.
468
+ interval (float): The interval between two keys.
469
+ """
470
+ if isinstance(keys, str):
471
+ self._last_input = keys
472
+ keys = [(char, ord(char.upper())) for char in keys]
473
+ elif not all(isinstance(k, tuple) for k in keys):
474
+ keys = [keys]
475
+
476
+ for key in keys:
477
+ await self.key_down(key)
478
+ await self.key_up(key)
479
+ await asyncio.sleep(interval)
480
+
481
+ async def backspace(self, interval: float = 0.1):
482
+ """
483
+ Backspaces the key at the element.
484
+
485
+ Args:
486
+ interval (float): The interval between two keys.
487
+ """
488
+ for _ in range(len(self._last_input)):
489
+ await self.send_keys(('Backspace', 8))
490
+ await asyncio.sleep(interval)
491
+ self._last_input = self._last_input[:-1]
402
492
 
403
493
  def _is_option_tag(self):
404
494
  """
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pydoll-python"
3
- version = "1.3.3"
3
+ version = "1.4.0"
4
4
  description = ""
5
5
  authors = ["Thalison Fernandes <thalissfernandes99@gmail.com>"]
6
6
  readme = "README.md"
File without changes
File without changes