browserfetch 0.10.1__tar.gz → 0.12.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.
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: browserfetch
3
- Version: 0.10.1
3
+ Version: 0.12.0
4
4
  Summary: fetch in Python using your browser!
5
5
  Keywords: browser,fetch,python,cookies
6
6
  Requires-Python: >=3.11
7
7
  Description-Content-Type: text/x-rst
8
8
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
9
+ License-File: LICENSE
9
10
  Requires-Dist: aiohttp
10
11
  Requires-Dist: pyperclip
11
12
  Requires-Dist: cyclopts
@@ -1,11 +1,11 @@
1
- __version__ = '0.10.1'
1
+ __version__ = '0.12.0'
2
2
  __all__ = [
3
3
  'BrowserError',
4
+ 'Response',
4
5
  'evaluate',
5
6
  'fetch',
6
7
  'get',
7
8
  'post',
8
- 'Response',
9
9
  'start_server',
10
10
  ]
11
11
  import atexit
@@ -19,9 +19,11 @@ from asyncio import (
19
19
  )
20
20
  from collections import defaultdict
21
21
  from dataclasses import dataclass
22
+ from functools import partial as _partial
22
23
  from html import escape
23
- from json import dumps, loads
24
+ from json import dumps as _dumps, loads as _loads
24
25
  from logging import getLogger
26
+ from typing import Any as _Any
25
27
  from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
26
28
 
27
29
  from aiohttp import ClientSession, ClientWebSocketResponse
@@ -43,6 +45,8 @@ hosts: defaultdict[
43
45
  # maps response event id to its response event or response dict
44
46
  responses: dict[int, Event | dict] = {}
45
47
 
48
+ _jdumps = _partial(_dumps, separators=(',', ':'), ensure_ascii=False)
49
+
46
50
 
47
51
  class BrowserError(Exception):
48
52
  pass
@@ -69,8 +73,8 @@ class Response:
69
73
 
70
74
  def json(self, encoding=None, errors='strict'):
71
75
  if encoding is None:
72
- return loads(self.body)
73
- return loads(self.text(encoding=encoding, errors=errors))
76
+ return _loads(self.body)
77
+ return _loads(self.text(encoding=encoding, errors=errors))
74
78
 
75
79
 
76
80
  def extract_host(url: str) -> str:
@@ -92,7 +96,7 @@ async def _request(
92
96
  data['event_id'] = event_id
93
97
  responses[event_id] = response_ready
94
98
 
95
- bytes_ = dumps(data).encode()
99
+ bytes_ = _jdumps(data).encode()
96
100
  if body is not None:
97
101
  bytes_ += b'\0' + body
98
102
 
@@ -120,7 +124,7 @@ async def receive_responses(ws: WebSocketResponse | ClientWebSocketResponse):
120
124
  while True:
121
125
  blob = await ws.receive_bytes()
122
126
  json_blob, _, body = blob.partition(b'\0')
123
- j = loads(json_blob)
127
+ j = _loads(json_blob)
124
128
  j['body'] = body
125
129
  event_id = j.pop('event_id')
126
130
  try:
@@ -172,7 +176,7 @@ async def _(request: Request) -> WebSocketResponse:
172
176
  except TypeError: # ws closed
173
177
  return ws
174
178
  data, null, body = bytes_.partition(b'\0')
175
- data = loads(data)
179
+ data = _loads(data)
176
180
  relay_event_id = data['event_id']
177
181
 
178
182
  try:
@@ -184,7 +188,7 @@ async def _(request: Request) -> WebSocketResponse:
184
188
 
185
189
  r['event_id'] = relay_event_id
186
190
  body = r.pop('body')
187
- await ws.send_bytes(dumps(r).encode() + b'\0' + body)
191
+ await ws.send_bytes(_jdumps(r).encode() + b'\0' + body)
188
192
 
189
193
 
190
194
  @routes.get('/')
@@ -228,14 +232,18 @@ async def relay_client(server_host, server_port):
228
232
 
229
233
 
230
234
  async def evaluate(
231
- string: str,
235
+ expression: str,
236
+ /,
237
+ *,
232
238
  host: str,
233
239
  timeout: int | float = 95,
240
+ arg: _Any = None,
234
241
  ):
235
242
  """Evaluate string in browser context and return JSON.stringify(result)."""
236
- d = await _request(
237
- host, {'action': 'eval', 'string': string, 'timeout': timeout}, None
238
- )
243
+ data = {'action': 'eval', 'string': expression, 'timeout': timeout}
244
+ if arg is not None:
245
+ data['arg'] = arg
246
+ d = await _request(host, data, None)
239
247
  return d['result']
240
248
 
241
249
 
@@ -324,7 +332,7 @@ async def post(
324
332
  elif isinstance(data, bytes):
325
333
  body = data
326
334
  else:
327
- body = dumps(data).encode()
335
+ body = _jdumps(data).encode()
328
336
  headers = options.setdefault('headers', {})
329
337
  headers['Content-Type'] = 'application/json'
330
338
  elif form is not None:
@@ -349,18 +357,9 @@ app.add_routes(routes)
349
357
  app_runner = AppRunner(app)
350
358
 
351
359
 
352
- def _shutdown_server(loop: AbstractEventLoop):
353
- logger.info('waiting for app_runner.cleanup()')
354
- loop.run_until_complete(app_runner.cleanup())
355
-
356
-
357
360
  def _cancel_relay_task(loop: AbstractEventLoop, task: Task):
358
361
  logger.info('cancelling relay task')
359
362
  task.cancel()
360
- try:
361
- loop.run_until_complete(task)
362
- except CancelledError:
363
- pass
364
363
 
365
364
 
366
365
  _server = False
@@ -386,5 +385,4 @@ async def start_server(*, host=_host, port=_port):
386
385
  atexit.register(_cancel_relay_task, loop, relay_task)
387
386
  else:
388
387
  _server = True
389
- atexit.register(_shutdown_server, loop)
390
388
  logger.info('server started at http://%s:%s', host, port)
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.1
2
+ Name: browserfetch
3
+ Version: 0.1.1.dev0
4
+ Summary: fetch in Python using your browser!
5
+ License: GNU General Public License v3 (GPLv3)
6
+ Project-URL: Homepage, https://github.com/5j9/browserfetch
7
+ Keywords: browser,fetch,python,cookies
8
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/x-rst
11
+ License-File: LICENSE
12
+ Requires-Dist: aiohttp
13
+ Requires-Dist: pyperclip
14
+
15
+ Fetch using your browser.
16
+
17
+ Let the browser manage cookies for you.
18
+
19
+ ⚠️ This project is a very simple implementation. Not tested thoroughly. Consider it a proof of concept.
20
+
21
+ Usage
22
+ -----
23
+ 1. You'll run a Python script containing some code like this:
24
+
25
+ .. code-block:: python
26
+
27
+ from asyncio import gather, get_event_loop
28
+
29
+ from browserfetch import fetch, run_server
30
+
31
+
32
+ async def main():
33
+ response1, response2, reponse3 = await gather(
34
+ fetch('https://example.com/path1'),
35
+ fetch('https://example.com/image.png'),
36
+ fetch('https://example.com/path2'),
37
+ )
38
+ # do stuff with retrieved responses
39
+
40
+
41
+ loop = get_event_loop()
42
+ loop.create_task(main())
43
+ run_server(loop=loop)
44
+
45
+
46
+ 2. Open your browser, goto http://example.com (perhaps solve a captcha and log in).
47
+ 3. Copy the contents of `browserfetch.js`_ file and paste it in browser's console. (You can use a browser extensions like violentmonkey_/tampermonkey_ to do this step for you.)
48
+
49
+ That's it! Your Python script starts handling requests.
50
+ The browser tab should remain open of-coarse.
51
+
52
+ The server can handle multiple websocket connections from different websites simultaneously.
53
+
54
+ How it works
55
+ ------------
56
+ ``browserfetch`` communicates with your browser using a websocket. The ``fetch`` function just passes the request to browser and it is the browser that handles the actual request. Response data is sent back to Python using the same WebSocket connection.
57
+
58
+ Motivations
59
+ -----------
60
+ * `browser_cookie3 stopped working on Chrome-based browsers`_. There is a workaround: ShadowCopy, but it requires admin privilege.
61
+ * Another issue with browser_cookie's approach is that it retrieves cookies from cookie files, but these files are not updated instantly. Thus, you might have to wait or retry a few times before you can successfully access newly set cookies.
62
+ * ShadowCopying and File access are slow and inefficient operations.
63
+
64
+ Downsides
65
+ ---------
66
+ * Setting up ``browserfetch`` is a more cumbersome since it requires running a Python server and also injecting a small script into the webpage. Using ``browser_cookie3`` might be a better choice if there are many websites that you need to communicate with.
67
+
68
+ .. _`browser_cookie3 stopped working on Chrome-based browsers`: https://github.com/borisbabic/browser_cookie3/issues/180
69
+ .. _tampermonkey: https://github.com/Tampermonkey/tampermonkey
70
+ .. _violentmonkey: https://github.com/violentmonkey/violentmonkey
71
+ .. _browserfetch.js: https://github.com/5j9/browserfetch/blob/master/browserfetch/browserfetch.js
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.rst
3
+ pyproject.toml
4
+ browserfetch/browserfetch.egg-info/PKG-INFO
5
+ browserfetch/browserfetch.egg-info/SOURCES.txt
6
+ browserfetch/browserfetch.egg-info/dependency_links.txt
7
+ browserfetch/browserfetch.egg-info/not-zip-safe
8
+ browserfetch/browserfetch.egg-info/requires.txt
9
+ browserfetch/browserfetch.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ aiohttp
2
+ pyperclip
@@ -57,13 +57,13 @@
57
57
  evalled = eval(req['string']);
58
58
  switch (evalled.constructor.name) {
59
59
  case 'AsyncFunction':
60
- evalled = await evalled;
60
+ evalled = await evalled(req['arg']);
61
61
  break;
62
62
  case 'Promise':
63
63
  evalled = await evalled;
64
64
  break;
65
65
  case 'Function':
66
- evalled = evalled();
66
+ evalled = evalled(req['arg']);
67
67
  break;
68
68
  }
69
69
  resp = { 'result': evalled, 'event_id': req['event_id'] };
@@ -40,13 +40,21 @@ lint.extend-select = [
40
40
  'FA', # flake8-future-annotations
41
41
  'I', # isort
42
42
  'UP', # pyupgrade
43
+ 'RUF', # Ruff-specific rules (RUF)
43
44
  ]
44
45
  lint.ignore = [
45
46
  'E721', # Do not compare types, use `isinstance()`
47
+ 'RUF001', # ambiguous-unicode-character-string
48
+ 'RUF002', # ambiguous-unicode-character-docstring
49
+ 'RUF003', # ambiguous-unicode-character-comment
50
+ 'RUF012', # mutable-class-default
46
51
  ]
47
52
 
48
53
  [tool.pytest.ini_options]
49
54
  addopts = '--quiet --tb=short'
55
+ asyncio_mode = "auto"
56
+ asyncio_default_test_loop_scope = "session"
57
+ asyncio_default_fixture_loop_scope = "session"
50
58
 
51
59
  [tool.pyright]
52
60
  typeCheckingMode = 'standard'
@@ -60,3 +68,10 @@ reportInvalidStringEscapeSequence = false
60
68
  reportConstantRedefinition = 'error'
61
69
  reportTypeCommentUsage = 'warning'
62
70
  reportUnnecessaryComparison = 'warning'
71
+
72
+ [dependency-groups]
73
+ dev = [
74
+ "playwright>=1.52.0",
75
+ "pytest>=8.4.0",
76
+ "pytest-asyncio>=1.0.0",
77
+ ]
@@ -1,25 +0,0 @@
1
- from contextlib import suppress
2
- from unittest.mock import Mock, patch
3
-
4
- from browserfetch import fetch
5
-
6
- request_mock = Mock()
7
-
8
-
9
- @patch('browserfetch._request', new=request_mock)
10
- async def test_params():
11
- with suppress(TypeError):
12
- await fetch(
13
- url='http://stackoverflow.com/search?q=question',
14
- params={'lang': 'en', 'tag': 'python', 'q': True},
15
- )
16
- request_mock.assert_called_once_with(
17
- None, # host
18
- {
19
- 'action': 'fetch',
20
- 'url': 'http://stackoverflow.com/search?q=question&lang=en&tag=python&q=True',
21
- 'options': None,
22
- 'timeout': 95,
23
- }, # data
24
- None, # body
25
- )
File without changes
File without changes