httpdbg 1.1.2__tar.gz → 1.2.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 (92) hide show
  1. {httpdbg-1.1.2 → httpdbg-1.2.0}/PKG-INFO +6 -6
  2. {httpdbg-1.1.2 → httpdbg-1.2.0}/README.md +5 -5
  3. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/__init__.py +1 -1
  4. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/all.py +19 -10
  5. httpdbg-1.2.0/httpdbg/hooks/fastapi.py +156 -0
  6. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/flask.py +29 -9
  7. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/socket.py +43 -40
  8. httpdbg-1.2.0/httpdbg/hooks/starlette.py +58 -0
  9. httpdbg-1.2.0/httpdbg/hooks/uvicorn.py +129 -0
  10. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/initiator.py +4 -4
  11. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/records.py +10 -1
  12. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/api.py +1 -1
  13. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg.egg-info/PKG-INFO +6 -6
  14. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg.egg-info/SOURCES.txt +6 -2
  15. httpdbg-1.2.0/tests/test_server_hook_fastapi.py +115 -0
  16. httpdbg-1.1.2/tests/test_hook_flask.py → httpdbg-1.2.0/tests/test_server_hook_flask.py +8 -2
  17. {httpdbg-1.1.2 → httpdbg-1.2.0}/LICENSE +0 -0
  18. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/__main__.py +0 -0
  19. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/args.py +0 -0
  20. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/env.py +0 -0
  21. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/exception.py +0 -0
  22. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/__init__.py +0 -0
  23. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/aiohttp.py +0 -0
  24. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/external.py +0 -0
  25. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/generic.py +0 -0
  26. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/http.py +0 -0
  27. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/httpx.py +0 -0
  28. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/pytest.py +0 -0
  29. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/requests.py +0 -0
  30. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/template_sitecustomize.py +0 -0
  31. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/unittest.py +0 -0
  32. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/urllib3.py +0 -0
  33. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/hooks/utils.py +0 -0
  34. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/log.py +0 -0
  35. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/mode_console.py +0 -0
  36. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/mode_module.py +0 -0
  37. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/mode_script.py +0 -0
  38. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/preview.py +0 -0
  39. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/server.py +0 -0
  40. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/utils.py +0 -0
  41. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/__init__.py +0 -0
  42. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/app.py +0 -0
  43. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/api.js +0 -0
  44. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/column.css +0 -0
  45. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/configuration.css +0 -0
  46. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/configuration.js +0 -0
  47. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/detail.css +0 -0
  48. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/favicon.ico +0 -0
  49. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/filter.js +0 -0
  50. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/chevron-direction-top-outline-icon.svg +0 -0
  51. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/download-install-line-icon.svg +0 -0
  52. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/engine-gears-icon.svg +0 -0
  53. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/eye-blind-icon.svg +0 -0
  54. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/eye-icon.svg +0 -0
  55. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/files-papers-outline-icon.svg +0 -0
  56. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/filter-outline-icon.svg +0 -0
  57. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/information-mark-circle-outline-icon.svg +0 -0
  58. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/lock-line-icon.svg +0 -0
  59. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/lock-open-line-icon.svg +0 -0
  60. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/math-multiplication-icon.svg +0 -0
  61. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/push-pin-green-icon.svg +0 -0
  62. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/question-mark-round-line-icon.svg +0 -0
  63. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/recycle-bin-line-icon.svg +0 -0
  64. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/icons/wait-sandclock-icon.svg +0 -0
  65. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/index.htm +0 -0
  66. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/main.js +0 -0
  67. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/mustache.min.js +0 -0
  68. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/render.js +0 -0
  69. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/style.css +0 -0
  70. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg/webapp/static/table.css +0 -0
  71. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg.egg-info/dependency_links.txt +0 -0
  72. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg.egg-info/entry_points.txt +0 -0
  73. {httpdbg-1.1.2 → httpdbg-1.2.0}/httpdbg.egg-info/top_level.txt +0 -0
  74. {httpdbg-1.1.2 → httpdbg-1.2.0}/pyproject.toml +0 -0
  75. {httpdbg-1.1.2 → httpdbg-1.2.0}/setup.cfg +0 -0
  76. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_api.py +0 -0
  77. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_cli.py +0 -0
  78. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_context_manager.py +0 -0
  79. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_hook_aiohttp.py +0 -0
  80. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_hook_httpx.py +0 -0
  81. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_hook_pytest.py +0 -0
  82. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_hook_requests.py +0 -0
  83. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_hook_unittest.py +0 -0
  84. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_hook_urllib3.py +0 -0
  85. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_initiator.py +0 -0
  86. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_mode_console.py +0 -0
  87. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_mode_module_pytest.py +0 -0
  88. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_mode_script.py +0 -0
  89. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_multiprocess.py +0 -0
  90. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_preview.py +0 -0
  91. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_pyhttpdbg_server.py +0 -0
  92. {httpdbg-1.1.2 → httpdbg-1.2.0}/tests/test_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpdbg
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: A very simple tool to debug HTTP(S) client and server requests.
5
5
  Author-email: cle-b <cle@tictac.pm>
6
6
  License: Apache-2.0
@@ -114,7 +114,6 @@ Press CTRL+C to quit
114
114
 
115
115
  ![httpdbg 1.1.0 - pyhttpdbg -m flask --app demoflask run](https://github.com/cle-b/httpdbg/blob/main/ui_server.png?raw=true)
116
116
 
117
- This feature is new and not fully implemented. See [limitations](#limitations) for more details.
118
117
 
119
118
  ## initiators and groups
120
119
 
@@ -146,7 +145,8 @@ The initiator is the low-level socket method that receives the data. The group i
146
145
 
147
146
  | packages | status | initiator | group |
148
147
  |----------------|-------------------------------------|-----------|-----------|
149
- | flask | supported | no | yes |
148
+ | flask | supported | - | yes |
149
+ | fastapi | supported | - | yes |
150
150
 
151
151
  ### supported test framework
152
152
 
@@ -154,8 +154,8 @@ The requests are grouped by tests.
154
154
 
155
155
  | packages | status | initiator | group |
156
156
  |----------------|-------------------------------------|-----------|-----------|
157
- | pytest | supported | no | yes |
158
- | unittest | supported | no | yes |
157
+ | pytest | supported | - | yes |
158
+ | unittest | supported | - | yes |
159
159
 
160
160
  ## configuration
161
161
 
@@ -218,7 +218,7 @@ The requests:
218
218
 
219
219
  Theoretically, if your HTTP client or server uses a standard Python socket, the HTTP requests will be recorded.
220
220
 
221
- Support for recording requests on the server side is new in `httpdbg 1.0.0` and is currently limited. For example, it doesn't work with `FastAPI`, which uses `uvloop`, but it does work with `Uvicorn` if the loop is `asyncio`. Work is in progress to improve this.
221
+ Support for recording requests on the server side is new in `httpdbg 1.0.0`. In some cases, like with |FastAPI| using |uvloop|, we had to implement a new mechanism to make request recording possible. This is still a work in progress as we aim to support more frameworks.
222
222
 
223
223
  ## documentation
224
224
 
@@ -87,7 +87,6 @@ Press CTRL+C to quit
87
87
 
88
88
  ![httpdbg 1.1.0 - pyhttpdbg -m flask --app demoflask run](https://github.com/cle-b/httpdbg/blob/main/ui_server.png?raw=true)
89
89
 
90
- This feature is new and not fully implemented. See [limitations](#limitations) for more details.
91
90
 
92
91
  ## initiators and groups
93
92
 
@@ -119,7 +118,8 @@ The initiator is the low-level socket method that receives the data. The group i
119
118
 
120
119
  | packages | status | initiator | group |
121
120
  |----------------|-------------------------------------|-----------|-----------|
122
- | flask | supported | no | yes |
121
+ | flask | supported | - | yes |
122
+ | fastapi | supported | - | yes |
123
123
 
124
124
  ### supported test framework
125
125
 
@@ -127,8 +127,8 @@ The requests are grouped by tests.
127
127
 
128
128
  | packages | status | initiator | group |
129
129
  |----------------|-------------------------------------|-----------|-----------|
130
- | pytest | supported | no | yes |
131
- | unittest | supported | no | yes |
130
+ | pytest | supported | - | yes |
131
+ | unittest | supported | - | yes |
132
132
 
133
133
  ## configuration
134
134
 
@@ -191,7 +191,7 @@ The requests:
191
191
 
192
192
  Theoretically, if your HTTP client or server uses a standard Python socket, the HTTP requests will be recorded.
193
193
 
194
- Support for recording requests on the server side is new in `httpdbg 1.0.0` and is currently limited. For example, it doesn't work with `FastAPI`, which uses `uvloop`, but it does work with `Uvicorn` if the loop is `asyncio`. Work is in progress to improve this.
194
+ Support for recording requests on the server side is new in `httpdbg 1.0.0`. In some cases, like with |FastAPI| using |uvloop|, we had to implement a new mechanism to make request recording possible. This is still a work in progress as we aim to support more frameworks.
195
195
 
196
196
  ## documentation
197
197
 
@@ -3,6 +3,6 @@ from httpdbg.hooks.all import httprecord
3
3
  from httpdbg.records import HTTPRecords
4
4
 
5
5
 
6
- __version__ = "1.1.2"
6
+ __version__ = "1.2.0"
7
7
 
8
8
  __all__ = ["httprecord", "HTTPRecords"]
@@ -7,15 +7,19 @@ from typing import Union
7
7
 
8
8
  from httpdbg.hooks.aiohttp import hook_aiohttp
9
9
  from httpdbg.hooks.external import watcher_external
10
+ from httpdbg.hooks.fastapi import hook_fastapi
11
+ from httpdbg.hooks.flask import hook_flask
10
12
  from httpdbg.hooks.generic import hook_generic
11
13
  from httpdbg.hooks.http import hook_http
12
14
  from httpdbg.hooks.httpx import hook_httpx
13
15
  from httpdbg.hooks.pytest import hook_pytest
14
16
  from httpdbg.hooks.requests import hook_requests
15
17
  from httpdbg.hooks.socket import hook_socket
18
+ from httpdbg.hooks.starlette import hook_starlette
16
19
  from httpdbg.hooks.unittest import hook_unittest
17
20
  from httpdbg.hooks.urllib3 import hook_urllib3
18
- from httpdbg.hooks.flask import hook_flask
21
+ from httpdbg.hooks.uvicorn import hook_uvicorn
22
+
19
23
  from httpdbg.records import HTTPRecords
20
24
 
21
25
 
@@ -33,12 +37,17 @@ def httprecord(
33
37
  with watcher_external(records, initiators, server):
34
38
  with hook_flask(records):
35
39
  with hook_socket(records):
36
- with hook_http(records):
37
- with hook_httpx(records):
38
- with hook_requests(records):
39
- with hook_urllib3(records):
40
- with hook_aiohttp(records):
41
- with hook_pytest(records):
42
- with hook_unittest(records):
43
- with hook_generic(records, initiators):
44
- yield records
40
+ with hook_fastapi(records):
41
+ with hook_starlette(records):
42
+ with hook_uvicorn(records):
43
+ with hook_http(records):
44
+ with hook_httpx(records):
45
+ with hook_requests(records):
46
+ with hook_urllib3(records):
47
+ with hook_aiohttp(records):
48
+ with hook_pytest(records):
49
+ with hook_unittest(records):
50
+ with hook_generic(
51
+ records, initiators
52
+ ):
53
+ yield records
@@ -0,0 +1,156 @@
1
+ # -*- coding: utf-8 -*-
2
+ from collections.abc import Callable
3
+ from contextlib import contextmanager
4
+ import functools
5
+ from functools import wraps
6
+ from typing import Dict
7
+ from typing import Generator
8
+ from typing import Union
9
+
10
+ import datetime
11
+
12
+ from httpdbg.hooks.utils import decorate
13
+ from httpdbg.hooks.utils import getcallargs
14
+ from httpdbg.hooks.utils import undecorate
15
+ from httpdbg.initiator import httpdbg_endpoint
16
+ from httpdbg.records import HTTPRecords
17
+
18
+
19
+ # decorate the function decorated by @app.get; @app.post etc.
20
+ def set_hook_fastapi_endpoint(records: HTTPRecords, method: Callable):
21
+
22
+ @wraps(method)
23
+ def hook(*args, **kwargs):
24
+
25
+ with httpdbg_endpoint(
26
+ records,
27
+ method,
28
+ *args,
29
+ **kwargs,
30
+ ):
31
+ ret = method(*args, **kwargs)
32
+ return ret
33
+
34
+ return hook
35
+
36
+
37
+ def set_hook_fastapi_apirouter_add_api_route(
38
+ records: HTTPRecords,
39
+ method: Callable,
40
+ already_mapped: Union[Dict[Callable, Callable], None] = None,
41
+ ):
42
+
43
+ @wraps(method)
44
+ def hook(*args, **kwargs):
45
+ callargs = getcallargs(method, *args, **kwargs)
46
+
47
+ param = "endpoint"
48
+
49
+ if param in callargs:
50
+ original_view_func = callargs[param]
51
+ if original_view_func not in already_mapped:
52
+ callargs[param] = decorate(
53
+ records, callargs[param], set_hook_fastapi_endpoint
54
+ )
55
+ already_mapped[original_view_func] = callargs[param]
56
+ else:
57
+ callargs[param] = already_mapped[original_view_func]
58
+
59
+ args = [callargs[param] if x == original_view_func else x for x in args]
60
+ if param in kwargs:
61
+ kwargs[param] = callargs[param]
62
+
63
+ return method(*args, **kwargs)
64
+
65
+ return hook
66
+
67
+
68
+ # decorate the "app" returned by fastapi.routing.get_request_handler to link the socketdata and the endpoint group
69
+ def set_hook_fastapi_app(records: HTTPRecords, method: Callable):
70
+
71
+ @wraps(method)
72
+ async def hook(*args, **kwargs):
73
+
74
+ socketdata_id = None
75
+
76
+ callargs = getcallargs(method, *args, **kwargs)
77
+
78
+ if "request" in callargs:
79
+ if hasattr(callargs["request"], "__httpdbg_socketdata_id"):
80
+ socketdata_id = callargs["request"].__httpdbg_socketdata_id
81
+
82
+ with httpdbg_endpoint(
83
+ records,
84
+ method,
85
+ *args,
86
+ **kwargs,
87
+ ) as group:
88
+ if group and (group.id in records.groups):
89
+ if socketdata_id and (socketdata_id in records._sockets):
90
+ records.groups[group.id].tbegin = records._sockets[
91
+ socketdata_id
92
+ ].record.tbegin - datetime.timedelta(milliseconds=1)
93
+ records._sockets[socketdata_id].record.group_id = group.id
94
+
95
+ ret = await method(*args, **kwargs)
96
+
97
+ return ret
98
+
99
+ return hook
100
+
101
+
102
+ def set_hook_fastapi_routing_get_request_handler(
103
+ records: HTTPRecords, method: Callable
104
+ ):
105
+
106
+ @wraps(method)
107
+ def hook(*args, **kwargs):
108
+
109
+ app = method(*args, **kwargs)
110
+
111
+ app = decorate(records, app, set_hook_fastapi_app)
112
+
113
+ return app
114
+
115
+ return hook
116
+
117
+
118
+ @contextmanager
119
+ def hook_fastapi(records: HTTPRecords) -> Generator[None, None, None]:
120
+ hooks = False
121
+ try:
122
+ import fastapi.routing
123
+
124
+ already_mapped: Dict[Callable, Callable] = {}
125
+
126
+ set_hook_fastapi_apirouter_add_api_route_with_already_mapped = (
127
+ functools.partial(
128
+ set_hook_fastapi_apirouter_add_api_route, already_mapped=already_mapped
129
+ )
130
+ )
131
+
132
+ fastapi.routing.APIRouter.add_api_route = decorate(
133
+ records,
134
+ fastapi.routing.APIRouter.add_api_route,
135
+ set_hook_fastapi_apirouter_add_api_route_with_already_mapped,
136
+ )
137
+
138
+ fastapi.routing.get_request_handler = decorate(
139
+ records,
140
+ fastapi.routing.get_request_handler,
141
+ set_hook_fastapi_routing_get_request_handler,
142
+ )
143
+
144
+ hooks = True
145
+ except ImportError:
146
+ pass
147
+
148
+ yield
149
+
150
+ if hooks:
151
+ fastapi.routing.APIRouter.add_api_route = undecorate(
152
+ fastapi.routing.APIRouter.add_api_route
153
+ )
154
+ fastapi.routing.get_request_handler = undecorate(
155
+ fastapi.routing.get_request_handler
156
+ )
@@ -1,8 +1,11 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  from collections.abc import Callable
3
3
  from contextlib import contextmanager
4
+ import functools
4
5
  from functools import wraps
6
+ from typing import Dict
5
7
  from typing import Generator
8
+ from typing import Union
6
9
 
7
10
  from httpdbg.hooks.utils import getcallargs
8
11
  from httpdbg.hooks.utils import decorate
@@ -30,12 +33,11 @@ def set_hook_flask_endpoint(records: HTTPRecords, method: Callable):
30
33
  return hook
31
34
 
32
35
 
33
- # we must not apply the hook more than once on a mapped endpoint function
34
- # AssertionError: View function mapping is overwriting an existing endpoint function: xxxx
35
- already_mapped = {}
36
-
37
-
38
- def set_hook_flask_add_url_rule(records: HTTPRecords, method: Callable):
36
+ def set_hook_flask_add_url_rule(
37
+ records: HTTPRecords,
38
+ method: Callable,
39
+ already_mapped: Union[Dict[Callable, Callable], None] = None,
40
+ ):
39
41
 
40
42
  def hook(*args, **kwargs):
41
43
 
@@ -62,7 +64,11 @@ def set_hook_flask_add_url_rule(records: HTTPRecords, method: Callable):
62
64
  return hook
63
65
 
64
66
 
65
- def set_hook_flask_register_error_handler(records: HTTPRecords, method: Callable):
67
+ def set_hook_flask_register_error_handler(
68
+ records: HTTPRecords,
69
+ method: Callable,
70
+ already_mapped: Union[Dict[Callable, Callable], None] = None,
71
+ ):
66
72
 
67
73
  def hook(*args, **kwargs):
68
74
 
@@ -95,14 +101,28 @@ def hook_flask(records: HTTPRecords) -> Generator[None, None, None]:
95
101
  try:
96
102
  import flask
97
103
 
104
+ # we must not apply the hook more than once on a mapped endpoint function
105
+ # AssertionError: View function mapping is overwriting an existing endpoint function: xxxx
106
+ already_mapped: Dict[Callable, Callable] = {}
107
+
108
+ set_hook_flask_add_url_rule_with_already_mapped = functools.partial(
109
+ set_hook_flask_add_url_rule, already_mapped=already_mapped
110
+ )
111
+
98
112
  flask.Flask.add_url_rule = decorate(
99
- records, flask.Flask.add_url_rule, set_hook_flask_add_url_rule
113
+ records,
114
+ flask.Flask.add_url_rule,
115
+ set_hook_flask_add_url_rule_with_already_mapped,
116
+ )
117
+
118
+ set_hook_flask_register_error_handler_with_already_mapped = functools.partial(
119
+ set_hook_flask_register_error_handler, already_mapped=already_mapped
100
120
  )
101
121
 
102
122
  flask.Flask.register_error_handler = decorate(
103
123
  records,
104
124
  flask.Flask.register_error_handler,
105
- set_hook_flask_register_error_handler,
125
+ set_hook_flask_register_error_handler_with_already_mapped,
106
126
  )
107
127
  flask.Flask.handle_exception = decorate(
108
128
  records, flask.Flask.handle_exception, set_hook_flask_endpoint
@@ -186,50 +186,53 @@ def set_hook_for_socket_recv_into(records: HTTPRecords, method: Callable):
186
186
 
187
187
  nbytes = method(self, buffer, *args, **kwargs)
188
188
 
189
- if socketdata:
190
- if socketdata.record:
191
- logger().info(
192
- f"RECV_INTO (after) - id={id(self)} buffer={(b''+buffer)[:20]}"
193
- )
194
- socketdata.record.receive_data(buffer[:nbytes])
195
- else:
196
- socketdata.rawdata += buffer[:nbytes]
197
- http_detected = socketdata.http_detected()
198
- if http_detected:
199
- logger().info("RECV_INTO - http detected")
189
+ if buffer: # it appears that the buffer may be None (observed on Windows).
190
+ if socketdata:
191
+ if socketdata.record:
200
192
  logger().info(
201
193
  f"RECV_INTO (after) - id={id(self)} buffer={(b''+buffer)[:20]}"
202
194
  )
203
- with httpdbg_initiator(
204
- records,
205
- traceback.extract_stack(),
206
- method,
207
- self,
208
- buffer,
209
- *args,
210
- **kwargs,
211
- ) as initiator_and_group:
212
- initiator, group, is_new = initiator_and_group
213
- if is_new:
214
- tbegin = socketdata.tbegin - datetime.timedelta(
215
- milliseconds=1
216
- )
217
- initiator.tbegin = tbegin
218
- group.tbegin = tbegin
219
- socketdata.record = HTTPRecord(
220
- records.current_initiator,
221
- records.current_group,
222
- records.current_tag,
223
- tbegin=socketdata.tbegin,
224
- is_client=False,
195
+ socketdata.record.receive_data(buffer[:nbytes])
196
+ else:
197
+ socketdata.rawdata += buffer[:nbytes]
198
+ http_detected = socketdata.http_detected()
199
+ if http_detected:
200
+ logger().info("RECV_INTO - http detected")
201
+ logger().info(
202
+ f"RECV_INTO (after) - id={id(self)} buffer={(b''+buffer)[:20]}"
225
203
  )
226
- socketdata.record.address = socketdata.address
227
- socketdata.record.ssl = socketdata.ssl
228
- socketdata.record.receive_data(socketdata.rawdata)
229
- if records.server:
230
- records.requests[socketdata.record.id] = socketdata.record
231
- elif http_detected is False: # if None, there is nothing to do
232
- records._sockets[id(self)] = None
204
+ with httpdbg_initiator(
205
+ records,
206
+ traceback.extract_stack(),
207
+ method,
208
+ self,
209
+ buffer,
210
+ *args,
211
+ **kwargs,
212
+ ) as initiator_and_group:
213
+ initiator, group, is_new = initiator_and_group
214
+ if is_new:
215
+ tbegin = socketdata.tbegin - datetime.timedelta(
216
+ milliseconds=1
217
+ )
218
+ initiator.tbegin = tbegin
219
+ group.tbegin = tbegin
220
+ socketdata.record = HTTPRecord(
221
+ records.current_initiator,
222
+ records.current_group,
223
+ records.current_tag,
224
+ tbegin=socketdata.tbegin,
225
+ is_client=False,
226
+ )
227
+ socketdata.record.address = socketdata.address
228
+ socketdata.record.ssl = socketdata.ssl
229
+ socketdata.record.receive_data(socketdata.rawdata)
230
+ if records.server:
231
+ records.requests[socketdata.record.id] = (
232
+ socketdata.record
233
+ )
234
+ elif http_detected is False: # if None, there is nothing to do
235
+ records._sockets[id(self)] = None
233
236
 
234
237
  return nbytes
235
238
 
@@ -0,0 +1,58 @@
1
+ # -*- coding: utf-8 -*-
2
+ from collections.abc import Callable
3
+ from contextlib import contextmanager
4
+ from functools import wraps
5
+ from typing import Generator
6
+
7
+ from httpdbg.hooks.utils import decorate
8
+ from httpdbg.hooks.utils import getcallargs
9
+ from httpdbg.hooks.utils import undecorate
10
+ from httpdbg.records import HTTPRecords
11
+
12
+
13
+ def set_hook_starlette_request_init(records: HTTPRecords, method: Callable):
14
+
15
+ @wraps(method)
16
+ def hook(self, *args, **kwargs):
17
+
18
+ callargs = getcallargs(method, self, *args, **kwargs)
19
+
20
+ if "receive" in callargs:
21
+ if hasattr(callargs["receive"], "__self__"):
22
+ if hasattr(callargs["receive"].__self__, "transport"):
23
+ if hasattr(
24
+ callargs["receive"].__self__.transport,
25
+ "_TCPTransport__httpdbg_socketdata",
26
+ ):
27
+ # make the link between starlette.requests.Request (self) and uvicorn.protocols.http.httptools_impl.HttpToolsProtocol (socketdata)
28
+ self.__httpdbg_socketdata_id = callargs[
29
+ "receive"
30
+ ].__self__.transport._TCPTransport__httpdbg_socketdata.id
31
+
32
+ return method(self, *args, **kwargs)
33
+
34
+ return hook
35
+
36
+
37
+ @contextmanager
38
+ def hook_starlette(records: HTTPRecords) -> Generator[None, None, None]:
39
+ hooks = False
40
+ try:
41
+ import starlette.requests
42
+
43
+ starlette.requests.Request.__init__ = decorate(
44
+ records,
45
+ starlette.requests.Request.__init__,
46
+ set_hook_starlette_request_init,
47
+ )
48
+
49
+ hooks = True
50
+ except ImportError:
51
+ pass
52
+
53
+ yield
54
+
55
+ if hooks:
56
+ starlette.requests.Request.__init__ = undecorate(
57
+ starlette.requests.Request.__init__
58
+ )
@@ -0,0 +1,129 @@
1
+ # -*- coding: utf-8 -*-
2
+ from collections.abc import Callable
3
+ from contextlib import contextmanager
4
+ import datetime
5
+ import traceback
6
+ from typing import Generator
7
+
8
+ from httpdbg.hooks.utils import decorate
9
+ from httpdbg.hooks.utils import undecorate
10
+ from httpdbg.initiator import httpdbg_initiator
11
+ from httpdbg.log import logger
12
+ from httpdbg.records import HTTPRecord
13
+ from httpdbg.records import HTTPRecords
14
+ from httpdbg.records import SocketRawData
15
+
16
+
17
+ # we use a proxy class to be able to override the write method
18
+ class TCPTransport:
19
+ def __init__(self, transport, socketdata: SocketRawData):
20
+ self.__httpdbg_original_transport = transport
21
+ self.__httpdbg_socketdata = socketdata
22
+
23
+ def write(self, buf):
24
+ if self.__httpdbg_socketdata:
25
+ if self.__httpdbg_socketdata.record:
26
+ self.__httpdbg_socketdata.record.send_data(buf)
27
+ return self.__httpdbg_original_transport.write(buf)
28
+
29
+ def __getattr__(self, attr):
30
+ return getattr(self.__httpdbg_original_transport, attr)
31
+
32
+
33
+ def set_hook_uvicorn_connection_made(records: HTTPRecords, method: Callable):
34
+
35
+ def hook(self, transport):
36
+
37
+ socketdata = records.get_socket_data(self, force_new=True, is_uvicorn=True)
38
+ logger().debug(f"UVICORN - connection made - {socketdata}")
39
+
40
+ return method(self, TCPTransport(transport, socketdata))
41
+
42
+ return hook
43
+
44
+
45
+ def set_hook_uvicorn_data_received(records: HTTPRecords, method: Callable):
46
+
47
+ def hook(self, data):
48
+
49
+ socketdata = records.get_socket_data(self)
50
+ if socketdata:
51
+ if socketdata.record:
52
+ socketdata.record.receive_data(data)
53
+ else:
54
+ socketdata.rawdata += data
55
+ http_detected = socketdata.http_detected()
56
+ if http_detected:
57
+ logger().info("UVICORN - http detected")
58
+ with httpdbg_initiator(
59
+ records,
60
+ traceback.extract_stack(),
61
+ method,
62
+ self,
63
+ data,
64
+ ) as initiator_and_group:
65
+ initiator, group, is_new = initiator_and_group
66
+ if is_new:
67
+ tbegin = socketdata.tbegin - datetime.timedelta(
68
+ milliseconds=1
69
+ )
70
+ initiator.tbegin = tbegin
71
+ group.tbegin = tbegin
72
+ socketdata.record = HTTPRecord(
73
+ initiator.id,
74
+ group.id,
75
+ tbegin=socketdata.tbegin,
76
+ is_client=False,
77
+ )
78
+ socketdata.record.address = socketdata.address
79
+ socketdata.record.ssl = socketdata.ssl
80
+ socketdata.record.receive_data(socketdata.rawdata)
81
+ if records.server:
82
+ records.requests[socketdata.record.id] = socketdata.record
83
+ elif http_detected is False: # if None, there is nothing to do
84
+ records._sockets[id(self)] = None
85
+
86
+ return method(self, data)
87
+
88
+ return hook
89
+
90
+
91
+ @contextmanager
92
+ def hook_uvicorn(records: HTTPRecords) -> Generator[None, None, None]:
93
+ hooks = False
94
+ try:
95
+ import uvicorn.protocols.http.httptools_impl
96
+
97
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.connection_made = (
98
+ decorate(
99
+ records,
100
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.connection_made,
101
+ set_hook_uvicorn_connection_made,
102
+ )
103
+ )
104
+
105
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.data_received = (
106
+ decorate(
107
+ records,
108
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.data_received,
109
+ set_hook_uvicorn_data_received,
110
+ )
111
+ )
112
+
113
+ hooks = True
114
+ except ImportError:
115
+ pass
116
+
117
+ yield
118
+
119
+ if hooks:
120
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.connection_made = (
121
+ undecorate(
122
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.connection_made
123
+ )
124
+ )
125
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.data_received = (
126
+ undecorate(
127
+ uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.data_received
128
+ )
129
+ )
@@ -341,7 +341,7 @@ def httpdbg_group(
341
341
  @contextmanager
342
342
  def httpdbg_endpoint(
343
343
  records: "HTTPRecords", original_method: Callable, *args, **kwargs
344
- ) -> Generator[None, None, None]:
344
+ ) -> Generator[Union[Group, None], None, None]:
345
345
 
346
346
  filename = inspect.getsourcefile(original_method)
347
347
  if filename:
@@ -355,7 +355,7 @@ def httpdbg_endpoint(
355
355
  original_method, *args, **kwargs
356
356
  )
357
357
 
358
- with httpdbg_group(records, instruction, full_label, update=True):
359
- yield
358
+ with httpdbg_group(records, instruction, full_label, update=True) as group:
359
+ yield group
360
360
  else:
361
- yield
361
+ yield None
@@ -353,7 +353,7 @@ class HTTPRecords:
353
353
  return len(self.requests)
354
354
 
355
355
  def get_socket_data(
356
- self, obj, extra_sock=None, force_new=False, request=None
356
+ self, obj, extra_sock=None, force_new=False, request=None, is_uvicorn=False
357
357
  ) -> Union[SocketRawData, None]:
358
358
  """Record a new SocketRawData (or get an existing one) and return it."""
359
359
  socketdata = None
@@ -397,6 +397,9 @@ class HTTPRecords:
397
397
  # only for async HTTP requests (not HTTPS) on Windows
398
398
  self._sockets[id(obj)] = SocketRawData(id(obj), ("", 0), False)
399
399
  socketdata = self._sockets[id(obj)]
400
+ elif is_uvicorn:
401
+ self._sockets[id(obj)] = SocketRawData(id(obj), ("", 0), False)
402
+ socketdata = self._sockets[id(obj)]
400
403
  else:
401
404
  if extra_sock:
402
405
  try:
@@ -453,3 +456,9 @@ class HTTPRecords:
453
456
  def add_group(self, group: Group):
454
457
  self.groups[group.id] = group
455
458
  self.current_group = group.id
459
+
460
+ def _print_for_debug(self):
461
+ for request in self.requests.values():
462
+ print(f"+ {request.url}")
463
+ print(f" - initiator: {self.initiators[request.initiator_id].label}")
464
+ print(f" - group: {self.groups[request.group_id].label}")
@@ -102,7 +102,7 @@ class RequestListPayload(JSONEncoder):
102
102
  }
103
103
 
104
104
  for id, initiator in records.initiators.items():
105
- payload["initiators"][id] = initiator.to_json()
105
+ payload["initiators"][id] = initiator.to_json(full=True)
106
106
 
107
107
  for id, group in records.groups.items():
108
108
  payload["groups"][id] = group.to_json()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpdbg
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: A very simple tool to debug HTTP(S) client and server requests.
5
5
  Author-email: cle-b <cle@tictac.pm>
6
6
  License: Apache-2.0
@@ -114,7 +114,6 @@ Press CTRL+C to quit
114
114
 
115
115
  ![httpdbg 1.1.0 - pyhttpdbg -m flask --app demoflask run](https://github.com/cle-b/httpdbg/blob/main/ui_server.png?raw=true)
116
116
 
117
- This feature is new and not fully implemented. See [limitations](#limitations) for more details.
118
117
 
119
118
  ## initiators and groups
120
119
 
@@ -146,7 +145,8 @@ The initiator is the low-level socket method that receives the data. The group i
146
145
 
147
146
  | packages | status | initiator | group |
148
147
  |----------------|-------------------------------------|-----------|-----------|
149
- | flask | supported | no | yes |
148
+ | flask | supported | - | yes |
149
+ | fastapi | supported | - | yes |
150
150
 
151
151
  ### supported test framework
152
152
 
@@ -154,8 +154,8 @@ The requests are grouped by tests.
154
154
 
155
155
  | packages | status | initiator | group |
156
156
  |----------------|-------------------------------------|-----------|-----------|
157
- | pytest | supported | no | yes |
158
- | unittest | supported | no | yes |
157
+ | pytest | supported | - | yes |
158
+ | unittest | supported | - | yes |
159
159
 
160
160
  ## configuration
161
161
 
@@ -218,7 +218,7 @@ The requests:
218
218
 
219
219
  Theoretically, if your HTTP client or server uses a standard Python socket, the HTTP requests will be recorded.
220
220
 
221
- Support for recording requests on the server side is new in `httpdbg 1.0.0` and is currently limited. For example, it doesn't work with `FastAPI`, which uses `uvloop`, but it does work with `Uvicorn` if the loop is `asyncio`. Work is in progress to improve this.
221
+ Support for recording requests on the server side is new in `httpdbg 1.0.0`. In some cases, like with |FastAPI| using |uvloop|, we had to implement a new mechanism to make request recording possible. This is still a work in progress as we aim to support more frameworks.
222
222
 
223
223
  ## documentation
224
224
 
@@ -25,6 +25,7 @@ httpdbg/hooks/__init__.py
25
25
  httpdbg/hooks/aiohttp.py
26
26
  httpdbg/hooks/all.py
27
27
  httpdbg/hooks/external.py
28
+ httpdbg/hooks/fastapi.py
28
29
  httpdbg/hooks/flask.py
29
30
  httpdbg/hooks/generic.py
30
31
  httpdbg/hooks/http.py
@@ -32,10 +33,12 @@ httpdbg/hooks/httpx.py
32
33
  httpdbg/hooks/pytest.py
33
34
  httpdbg/hooks/requests.py
34
35
  httpdbg/hooks/socket.py
36
+ httpdbg/hooks/starlette.py
35
37
  httpdbg/hooks/template_sitecustomize.py
36
38
  httpdbg/hooks/unittest.py
37
39
  httpdbg/hooks/urllib3.py
38
40
  httpdbg/hooks/utils.py
41
+ httpdbg/hooks/uvicorn.py
39
42
  httpdbg/webapp/__init__.py
40
43
  httpdbg/webapp/api.py
41
44
  httpdbg/webapp/app.py
@@ -71,7 +74,6 @@ tests/test_api.py
71
74
  tests/test_cli.py
72
75
  tests/test_context_manager.py
73
76
  tests/test_hook_aiohttp.py
74
- tests/test_hook_flask.py
75
77
  tests/test_hook_httpx.py
76
78
  tests/test_hook_pytest.py
77
79
  tests/test_hook_requests.py
@@ -84,4 +86,6 @@ tests/test_mode_script.py
84
86
  tests/test_multiprocess.py
85
87
  tests/test_preview.py
86
88
  tests/test_pyhttpdbg_server.py
87
- tests/test_server.py
89
+ tests/test_server.py
90
+ tests/test_server_hook_fastapi.py
91
+ tests/test_server_hook_flask.py
@@ -0,0 +1,115 @@
1
+ import contextlib
2
+ import platform
3
+ import threading
4
+ import time
5
+
6
+ import pytest
7
+ import requests
8
+ import uvicorn
9
+
10
+ from httpdbg.hooks.all import httprecord
11
+
12
+
13
+ @contextlib.contextmanager
14
+ def fastapi_app(port):
15
+ try:
16
+ config = uvicorn.Config("tests.demo_fastapi:app", port=port, log_level="debug")
17
+ server = uvicorn.Server(config)
18
+
19
+ thread = threading.Thread(target=server.run)
20
+ thread.start()
21
+
22
+ time.sleep(5)
23
+ yield
24
+ finally:
25
+ server.should_exit = True
26
+ thread.join(timeout=5)
27
+
28
+
29
+ # For some reason I don't yet understand, it looks like the hook for fastapi.routing.get_request_handler
30
+ # is applied only once when starting multiple FastAPI apps sequentially.
31
+ @pytest.mark.xfail(
32
+ platform.system().lower() == "windows",
33
+ reason="on windows, the server requests are recorded twice for fastapi",
34
+ )
35
+ def test_fastapi_endpoint(httpdbg_port):
36
+
37
+ with httprecord(client=False, server=True) as records:
38
+ with fastapi_app(httpdbg_port):
39
+ requests.get(f"http://localhost:{httpdbg_port}/")
40
+
41
+ # endpoint no parameters
42
+ assert len(records) == 1, records._print_for_debug()
43
+ record = records[0]
44
+ group = records.groups[record.group_id]
45
+
46
+ assert record.is_client is False
47
+ assert record.response.content == b'"Hello, World!"'
48
+ assert group.label == '@app.get("/")'
49
+ assert "hello_world()" in group.full_label
50
+
51
+ records.reset()
52
+
53
+ # endpoint with parameters
54
+
55
+ requests.get(f"http://localhost:{httpdbg_port}/items/123")
56
+
57
+ assert len(records) == 1, records._print_for_debug()
58
+ record = records[0]
59
+ group = records.groups[record.group_id]
60
+
61
+ assert record.is_client is False
62
+ assert record.response.content == b'{"item_id":123,"q":null}'
63
+ assert group.label == '@app.get("/items/{item_id}")'
64
+ full_label = (
65
+ group.full_label.replace(" ", "").replace("\t", "").replace("\n", "")
66
+ )
67
+ assert "get_item(item_id=123,q=None,)" in full_label
68
+
69
+ records.reset()
70
+
71
+ # http error
72
+ requests.get(f"http://localhost:{httpdbg_port}/items/456")
73
+
74
+ assert len(records) == 1
75
+ record = records[0]
76
+ group = records.groups[record.group_id]
77
+
78
+ assert record.is_client is False
79
+ assert record.status_code == 456
80
+ assert record.response.content == b'{"detail":"custom exception"}'
81
+ assert group.label == '@app.get("/items/{item_id}")'
82
+ full_label = (
83
+ group.full_label.replace(" ", "").replace("\t", "").replace("\n", "")
84
+ )
85
+ assert "get_item(item_id=456,q=None,)" in full_label
86
+
87
+
88
+ @pytest.mark.xfail(
89
+ platform.system().lower() == "windows",
90
+ reason="[#188] on windows, the server requests are recorded twice for fastapi",
91
+ )
92
+ def test_fastapi_client_request_in_endpoint(httpdbg_port, httpbin):
93
+
94
+ with httprecord(
95
+ client=True,
96
+ server=True,
97
+ ignore=[
98
+ (httpbin.host, httpbin.port),
99
+ ],
100
+ ) as records:
101
+ with fastapi_app(httpdbg_port):
102
+ requests.post(
103
+ f"http://localhost:{httpdbg_port}/withclientrequest",
104
+ json={"port": httpbin.port},
105
+ )
106
+
107
+ assert len(records) == 3 # 2 + 1
108
+
109
+ assert records[0].is_client is True # the client request to /withclientrequest
110
+ assert records[1].is_client is False # the same request, received by the server
111
+ assert records[2].is_client is True # the request made inside the endpoint method
112
+ assert records[0].group_id == records[1].group_id
113
+ assert records[1].group_id == records[2].group_id
114
+
115
+ assert records[2].url == f"http://localhost:{httpbin.port}/"
@@ -1,8 +1,10 @@
1
1
  import contextlib
2
- import time
2
+ import platform
3
3
  import threading
4
+ import time
4
5
 
5
6
  from flask import Flask
7
+ import pytest
6
8
  import requests
7
9
 
8
10
  from httpdbg.hooks.all import httprecord
@@ -118,6 +120,10 @@ def test_flask_not_found(httpdbg_port):
118
120
  assert "not_found(e=" in full_label
119
121
 
120
122
 
123
+ @pytest.mark.xfail(
124
+ platform.system().lower() == "windows",
125
+ reason="[#189] on Windows, the group label is empty in case of Internal Server Error in a Flask endpoint",
126
+ )
121
127
  def test_flask_internal_server_error(httpdbg_port):
122
128
 
123
129
  with httprecord(client=False, server=True) as records:
@@ -131,7 +137,7 @@ def test_flask_internal_server_error(httpdbg_port):
131
137
  assert record.is_client is False
132
138
  assert record.status_code == 500
133
139
  assert b"Internal Server Error" in record.response.content
134
- assert "def handle_exception" in group.label
140
+ assert "def handle_exception" in group.label, records._print_for_debug()
135
141
  assert "division by zero" in group.full_label
136
142
 
137
143
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes