browserfetch 0.9.0__tar.gz → 0.10.1__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: browserfetch
3
- Version: 0.9.0
3
+ Version: 0.10.1
4
4
  Summary: fetch in Python using your browser!
5
5
  Keywords: browser,fetch,python,cookies
6
6
  Requires-Python: >=3.11
@@ -8,6 +8,7 @@ Description-Content-Type: text/x-rst
8
8
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
9
9
  Requires-Dist: aiohttp
10
10
  Requires-Dist: pyperclip
11
+ Requires-Dist: cyclopts
11
12
  Project-URL: Homepage, https://github.com/5j9/browserfetch
12
13
 
13
14
  Fetch using your browser.
@@ -1,5 +1,13 @@
1
- __version__ = '0.9.0'
2
-
1
+ __version__ = '0.10.1'
2
+ __all__ = [
3
+ 'BrowserError',
4
+ 'evaluate',
5
+ 'fetch',
6
+ 'get',
7
+ 'post',
8
+ 'Response',
9
+ 'start_server',
10
+ ]
3
11
  import atexit
4
12
  from asyncio import (
5
13
  AbstractEventLoop,
@@ -11,20 +19,27 @@ from asyncio import (
11
19
  )
12
20
  from collections import defaultdict
13
21
  from dataclasses import dataclass
22
+ from html import escape
14
23
  from json import dumps, loads
15
24
  from logging import getLogger
16
- from typing import Any
17
- from urllib.parse import urlencode
25
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
18
26
 
19
27
  from aiohttp import ClientSession, ClientWebSocketResponse
20
- from aiohttp.web import Application, RouteTableDef, WebSocketResponse
28
+ from aiohttp.web import (
29
+ Application,
30
+ Request,
31
+ Response as _Response,
32
+ RouteTableDef,
33
+ WebSocketResponse,
34
+ )
21
35
  from aiohttp.web_runner import AppRunner, TCPSite
22
36
 
23
37
  logger = getLogger(__name__)
24
38
  # maps host to its host_ready event or its websocket
25
- hosts: dict[str, Event | WebSocketResponse | ClientWebSocketResponse] = (
26
- defaultdict(Event)
27
- )
39
+ hosts: defaultdict[
40
+ str, Event | WebSocketResponse | ClientWebSocketResponse
41
+ ] = defaultdict(Event)
42
+
28
43
  # maps response event id to its response event or response dict
29
44
  responses: dict[int, Event | dict] = {}
30
45
 
@@ -81,12 +96,15 @@ async def _request(
81
96
  if body is not None:
82
97
  bytes_ += b'\0' + body
83
98
 
84
- ws = hosts[host]
85
- if isinstance(ws, Event):
86
- await ws.wait()
87
- ws = hosts[host]
88
-
89
- await ws.send_bytes(bytes_)
99
+ value = hosts[host]
100
+ match value:
101
+ case Event():
102
+ # wait for the Event to be turned into a websocket response
103
+ await value.wait()
104
+ ws = hosts[host]
105
+ await ws.send_bytes(bytes_) # type: ignore
106
+ case WebSocketResponse() | ClientWebSocketResponse():
107
+ await value.send_bytes(bytes_)
90
108
 
91
109
  try:
92
110
  await wait_for(response_ready.wait(), data['timeout'])
@@ -94,7 +112,8 @@ async def _request(
94
112
  responses.pop(event_id, None)
95
113
  raise
96
114
 
97
- return responses.pop(event_id)
115
+ # this must return a dict at this point, not an Event
116
+ return responses.pop(event_id) # type: ignore
98
117
 
99
118
 
100
119
  async def receive_responses(ws: WebSocketResponse | ClientWebSocketResponse):
@@ -105,15 +124,17 @@ async def receive_responses(ws: WebSocketResponse | ClientWebSocketResponse):
105
124
  j['body'] = body
106
125
  event_id = j.pop('event_id')
107
126
  try:
108
- event = responses[event_id]
127
+ # We expect only one response to be recieved for each event,
128
+ # therefore this must be an Event, not a dict.
129
+ response_ready: Event = responses[event_id] # type: ignore
109
130
  except KeyError: # lock has reached timeout already
110
131
  continue
111
132
  responses[event_id] = j
112
- event.set()
133
+ response_ready.set()
113
134
 
114
135
 
115
136
  routes = RouteTableDef()
116
- PROTOCOL = '2'
137
+ PROTOCOL = '3'
117
138
 
118
139
 
119
140
  @routes.get('/ws')
@@ -121,10 +142,10 @@ async def _(request):
121
142
  ws = WebSocketResponse()
122
143
  await ws.prepare(request)
123
144
 
124
- host, _, version = (await ws.receive_str()).partition(' ')
125
- assert (
126
- version == PROTOCOL
127
- ), f'JavaScript protocol version: {version}, expected: {PROTOCOL}'
145
+ version, _, host = (await ws.receive_str()).partition(' ')
146
+ assert version == PROTOCOL, (
147
+ f'JavaScript protocol version: {version}, expected: {PROTOCOL}'
148
+ )
128
149
  logger.info('registering host %s', host)
129
150
 
130
151
  ws_or_e = hosts[host]
@@ -135,13 +156,13 @@ async def _(request):
135
156
  try:
136
157
  await receive_responses(ws)
137
158
  except TypeError:
138
- logger.info('host WebSocket was closed')
159
+ logger.info('WebSocket was closed by browser')
139
160
  hosts[host] = Event()
140
161
  return ws
141
162
 
142
163
 
143
164
  @routes.get('/relay')
144
- async def _(request):
165
+ async def _(request: Request) -> WebSocketResponse:
145
166
  ws = WebSocketResponse()
146
167
  await ws.prepare(request)
147
168
 
@@ -149,13 +170,15 @@ async def _(request):
149
170
  try:
150
171
  bytes_ = await ws.receive_bytes()
151
172
  except TypeError: # ws closed
152
- return
173
+ return ws
153
174
  data, null, body = bytes_.partition(b'\0')
154
175
  data = loads(data)
155
176
  relay_event_id = data['event_id']
156
177
 
157
178
  try:
158
- r = await _request(data.pop('host'), data, body if null else None)
179
+ r: dict = await _request(
180
+ data.pop('host'), data, body if null else None
181
+ )
159
182
  except TimeoutError:
160
183
  r = {'error': 'TimeoutError in relay'}
161
184
 
@@ -164,6 +187,22 @@ async def _(request):
164
187
  await ws.send_bytes(dumps(r).encode() + b'\0' + body)
165
188
 
166
189
 
190
+ @routes.get('/')
191
+ async def _(_) -> _Response:
192
+ hosts_html = '\n'.join(
193
+ [f'<li>{k}: {escape(str(v))}</li>' for k, v in hosts.items()]
194
+ )
195
+ responses_html = '\n'.join(
196
+ [f'<li>{k}: {escape(str(v))}</li>' for k, v in responses.items()]
197
+ )
198
+ return _Response(
199
+ body='<meta charset="utf-8">\n<title>browserfetch</title>\n'
200
+ f'Hosts:\n{hosts_html}\n'
201
+ f'Responses:\n{responses_html}',
202
+ content_type='text/html',
203
+ )
204
+
205
+
167
206
  async def relay_client(server_host, server_port):
168
207
  async with ClientSession() as session:
169
208
  relay_url = f'ws://{server_host}:{server_port}/relay'
@@ -221,7 +260,14 @@ async def fetch(
221
260
  :return: a dict of response values.
222
261
  """
223
262
  if params is not None:
224
- url += urlencode(params)
263
+ parsed_url = urlparse(url)
264
+ url = urlunparse(
265
+ parsed_url._replace(
266
+ query=urlencode(
267
+ parse_qsl(parsed_url.query) + [*params.items()]
268
+ )
269
+ )
270
+ )
225
271
 
226
272
  d = await _request(
227
273
  host,
@@ -261,14 +307,14 @@ async def post(
261
307
  url: str,
262
308
  *,
263
309
  params: dict | None = None,
264
- data: bytes | dict | str | None = None,
310
+ data=None,
265
311
  form: dict | None = None,
266
312
  timeout: int | float = 95,
267
313
  options: dict | None = None,
268
314
  host: str | None = None,
269
315
  ) -> Response:
270
316
  if options is None:
271
- options: dict[str, Any] = {'method': 'POST'}
317
+ options = {'method': 'POST'}
272
318
  else:
273
319
  options['method'] = 'POST'
274
320
 
@@ -281,12 +327,12 @@ async def post(
281
327
  body = dumps(data).encode()
282
328
  headers = options.setdefault('headers', {})
283
329
  headers['Content-Type'] = 'application/json'
284
-
285
- if form is not None:
286
- assert data is None
330
+ elif form is not None:
287
331
  body = urlencode(form).encode()
288
332
  headers = options.setdefault('headers', {})
289
333
  headers['Content-Type'] = 'application/x-www-form-urlencoded'
334
+ else:
335
+ body = None
290
336
 
291
337
  return await fetch(
292
338
  url,
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def read_js(host_name_generator: str | None):
5
+ js = (Path(__file__).parent / 'browserfetch.js').read_bytes().decode()
6
+ if host_name_generator is not None:
7
+ return js.replace(
8
+ 'async function generateHostName() { return location.host };',
9
+ host_name_generator,
10
+ 1,
11
+ )
12
+ return js
13
+
14
+
15
+ def copyjs(*, host_name_generator: str | None = None):
16
+ """Copy contents of browserfetch.js to clipboard.
17
+
18
+ `host_name_generator` should be a string containing
19
+ an async JavaScript function named `generateHostName()`. This file's
20
+ contents will be copied into the generated js script and will be used
21
+ to generate a host name for connections. The default
22
+ `generateHostName` function returns `location.host`.
23
+ """
24
+ from pyperclip import copy
25
+
26
+ copy(read_js(host_name_generator))
27
+
28
+
29
+ if __name__ == '__main__':
30
+ from cyclopts import App
31
+
32
+ from browserfetch import __version__
33
+
34
+ app = App(version=__version__)
35
+ app.command(copyjs)
36
+ app()
@@ -5,7 +5,7 @@
5
5
  // @grant GM_registerMenuCommand
6
6
  // ==/UserScript==
7
7
  // @ts-check
8
- (() => {
8
+ (async () => {
9
9
  /**
10
10
  * @param {Uint8Array | null} body
11
11
  * @param {Object} req
@@ -93,13 +93,19 @@
93
93
  return [blob, JSON.parse(new TextDecoder().decode(jArray))]
94
94
  }
95
95
 
96
+ async function generateHostName() { return location.host };
97
+ var hostName;
98
+
96
99
  function connect() {
97
- var protocol = '2'
100
+ var protocol = '3'
98
101
  var ws = new WebSocket("ws://127.0.0.1:9404/ws");
99
102
  ws.binaryType = "arraybuffer";
100
103
 
101
- ws.onopen = () => {
102
- ws.send(location.host + ' ' + protocol);
104
+ ws.onopen = async () => {
105
+ if (!hostName) {
106
+ hostName = await generateHostName();
107
+ }
108
+ ws.send(protocol + ' ' + hostName);
103
109
  }
104
110
 
105
111
  ws.onclose = () => {
@@ -1,40 +1,42 @@
1
- [build-system]
2
- requires = ['flit_core >=3.2,<4']
3
- build-backend = 'flit_core.buildapi'
4
-
5
- [project]
6
- name = "browserfetch"
7
- description = "fetch in Python using your browser! "
8
- keywords = ["browser", "fetch", "python", "cookies"]
9
- classifiers = [
10
- "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
11
- ]
12
- requires-python = ">=3.11"
13
- dynamic = ["version"]
14
- dependencies = [
15
- "aiohttp",
16
- "pyperclip",
17
- ]
18
-
19
- [[authors]]
20
- name = "5j9"
21
- email = "5j9@users.noreply.github.com"
22
-
23
- [project.license]
24
- text = "GNU General Public License v3 (GPLv3)"
25
-
26
- [project.readme]
27
- file = "README.rst"
28
- content-type = "text/x-rst"
29
-
30
- [project.urls]
31
- Homepage = "https://github.com/5j9/browserfetch"
32
-
33
- [tool.ruff]
1
+ [build-system]
2
+ requires = ['flit_core >=3.2,<4']
3
+ build-backend = 'flit_core.buildapi'
4
+
5
+ [project]
6
+ name = "browserfetch"
7
+ description = "fetch in Python using your browser! "
8
+ keywords = ["browser", "fetch", "python", "cookies"]
9
+ classifiers = [
10
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
11
+ ]
12
+ requires-python = ">=3.11"
13
+ dynamic = ["version"]
14
+ dependencies = [
15
+ "aiohttp",
16
+ "pyperclip",
17
+ "cyclopts",
18
+ ]
19
+
20
+ [[authors]]
21
+ name = "5j9"
22
+ email = "5j9@users.noreply.github.com"
23
+
24
+ [project.license]
25
+ text = "GNU General Public License v3 (GPLv3)"
26
+
27
+ [project.readme]
28
+ file = "README.rst"
29
+ content-type = "text/x-rst"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/5j9/browserfetch"
33
+
34
+ [tool.ruff]
34
35
  line-length = 79
35
36
  format.quote-style = 'single'
36
37
  lint.isort.combine-as-imports = true
37
38
  lint.extend-select = [
39
+ 'W605', # invalid-escape-sequence
38
40
  'FA', # flake8-future-annotations
39
41
  'I', # isort
40
42
  'UP', # pyupgrade
@@ -54,3 +56,7 @@ reportUnnecessaryCast = "warning"
54
56
  reportUnnecessaryContains = "warning"
55
57
  reportUnnecessaryIsInstance = "warning"
56
58
  reportUnnecessaryTypeIgnoreComment = true
59
+ reportInvalidStringEscapeSequence = false
60
+ reportConstantRedefinition = 'error'
61
+ reportTypeCommentUsage = 'warning'
62
+ reportUnnecessaryComparison = 'warning'
@@ -0,0 +1,25 @@
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
+ )
@@ -1,24 +0,0 @@
1
- from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
2
- from pathlib import Path
3
-
4
- from pyperclip import copy
5
-
6
-
7
- def main():
8
- parser = ArgumentParser(
9
- description='The command-line entry for browserfetch.',
10
- formatter_class=ArgumentDefaultsHelpFormatter,
11
- )
12
-
13
- parser.add_argument(
14
- 'copyjs', help='copy contents of browserfetch.js to clipboard'
15
- )
16
- args = parser.parse_args()
17
-
18
- if args.copyjs:
19
- with (Path(__file__).parent / 'browserfetch.js').open() as f:
20
- copy(f.read())
21
-
22
-
23
- if __name__ == '__main__':
24
- main()
File without changes
File without changes