quantpad-data 0.3.0__tar.gz → 0.3.2__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.
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/PKG-INFO +1 -1
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/pyproject.toml +1 -1
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/quantpad_data/client.py +42 -10
- quantpad_data-0.3.2/tests/test_message_encoding.py +85 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/tests/test_v2_gate.py +98 -2
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/.gitignore +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/README.md +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/quantpad_data/v2.py +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/tests/test_client.py +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/tests/test_lean.py +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/tests/test_v2.py +0 -0
- {quantpad_data-0.3.0 → quantpad_data-0.3.2}/tests/test_version.py +0 -0
|
@@ -25,7 +25,21 @@ from .errors import (
|
|
|
25
25
|
ValidationError,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
#:
|
|
28
|
+
#: Header by which the server names this refusal as the gate.
|
|
29
|
+
#:
|
|
30
|
+
#: Preferred over every message match below, and it exists because matching
|
|
31
|
+
#: prose was load-bearing and should not have been. Two things broke it at
|
|
32
|
+
#: once. The wording had to change — ``{"error": "Not Found"}`` is
|
|
33
|
+
#: indistinguishable from an empty result, and a paying subscriber lost two
|
|
34
|
+
#: days to exactly that confusion — and matching on it made the wording
|
|
35
|
+
#: unimprovable, since any text that helps a human stops this SDK
|
|
36
|
+
#: recognising the gate. Worse in the other direction: the moment the
|
|
37
|
+
#: allowlist opens, a *genuine* ``/v2`` 404 renders the same two words, so
|
|
38
|
+
#: this would tell a caller standing right through the gate that it was shut.
|
|
39
|
+
_GATE_HEADER = "X-QP-Gate"
|
|
40
|
+
|
|
41
|
+
#: What the two ``/v2`` gates answer, verbatim — **on servers predating
|
|
42
|
+
#: ``_GATE_HEADER`` only.**
|
|
29
43
|
#:
|
|
30
44
|
#: Matched on the exact message rather than on the status alone, because
|
|
31
45
|
#: ``/v2`` returns both of these for real reasons too: a batch job that
|
|
@@ -56,16 +70,26 @@ _V2_GATE_REPLIES = frozenset(
|
|
|
56
70
|
}
|
|
57
71
|
)
|
|
58
72
|
|
|
73
|
+
#: Fallback wording, used only when the server sent no message of its own.
|
|
74
|
+
#:
|
|
75
|
+
#: Deliberately does **not** say "/v2 is not open yet" any more, and that
|
|
76
|
+
#: sentence is why this constant is now a fallback rather than the answer.
|
|
77
|
+
#: Rollout state is the server's to know and this string is compiled into a
|
|
78
|
+
#: released wheel: it was true when 0.3.1 shipped and becomes a lie the hour
|
|
79
|
+
#: the allowlist opens, at which point every pinned install would tell a
|
|
80
|
+
#: subscriber who *has* access that they do not. Prefer the server's own
|
|
81
|
+
#: ``detail``, which cannot go stale because it is written by the thing
|
|
82
|
+
#: making the decision.
|
|
59
83
|
V2_NOT_AVAILABLE = (
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
84
|
+
"This QuantPad /v2 request was refused by an access gate rather than by "
|
|
85
|
+
"anything about the request: your credentials are valid, and no change "
|
|
86
|
+
"of dataset, symbol or date range will get past it. The documented /v1 "
|
|
87
|
+
"helpers (get_bars, get_ticks, get_universe, get_coverage, "
|
|
88
|
+
"symbology_resolve) carry the same data and are unaffected. Contact "
|
|
89
|
+
"QuantPad support about /v2 access."
|
|
66
90
|
)
|
|
67
91
|
|
|
68
|
-
VERSION = "0.3.
|
|
92
|
+
VERSION = "0.3.2"
|
|
69
93
|
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
70
94
|
TICK_SCHEMAS = (
|
|
71
95
|
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
@@ -152,8 +176,16 @@ class Client:
|
|
|
152
176
|
except (KeyError, TypeError, ValueError):
|
|
153
177
|
pass
|
|
154
178
|
status = response.status_code
|
|
155
|
-
if
|
|
156
|
-
|
|
179
|
+
if path.startswith("/v2/"):
|
|
180
|
+
# The header is authoritative when present: the server declares
|
|
181
|
+
# that this refusal is the gate, so no prose has to be matched
|
|
182
|
+
# and a *genuine* /v2 404 is never mistaken for it.
|
|
183
|
+
if response.headers.get(_GATE_HEADER):
|
|
184
|
+
raise V2NotAvailableError(message or V2_NOT_AVAILABLE, status)
|
|
185
|
+
# Older servers do not send it, so keep matching what they say --
|
|
186
|
+
# but only while they are old. See _V2_GATE_REPLIES.
|
|
187
|
+
if (status, message) in _V2_GATE_REPLIES:
|
|
188
|
+
raise V2NotAvailableError(V2_NOT_AVAILABLE, status)
|
|
157
189
|
if status == 400:
|
|
158
190
|
raise ValidationError(message)
|
|
159
191
|
if status == 401:
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Runtime strings stay ASCII, because they are read on real terminals.
|
|
2
|
+
|
|
3
|
+
Found by running the SDK against production and watching the em-dash in
|
|
4
|
+
``V2_NOT_AVAILABLE`` come back as a replacement character on a Windows
|
|
5
|
+
console. The message exists to be legible; a message that renders as
|
|
6
|
+
mojibake on a chunk of our users' terminals is not doing its job, and
|
|
7
|
+
the irony of it being the "here is what actually went wrong" string was
|
|
8
|
+
the tell.
|
|
9
|
+
|
|
10
|
+
Scoped to runtime string *literals*. Docstrings and comments are read in
|
|
11
|
+
an editor and keep their typography.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import pathlib
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
PACKAGE = pathlib.Path(__file__).resolve().parent.parent / "quantpad_data"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _runtime_strings(path: pathlib.Path):
|
|
25
|
+
"""Every string constant that is not a docstring, with its line."""
|
|
26
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
27
|
+
docstrings = set()
|
|
28
|
+
for node in ast.walk(tree):
|
|
29
|
+
if isinstance(
|
|
30
|
+
node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
31
|
+
):
|
|
32
|
+
text = ast.get_docstring(node, clean=False)
|
|
33
|
+
if text:
|
|
34
|
+
docstrings.add(text)
|
|
35
|
+
for node in ast.walk(tree):
|
|
36
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
37
|
+
if node.value not in docstrings:
|
|
38
|
+
yield node.lineno, node.value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize(
|
|
42
|
+
"module", sorted(PACKAGE.rglob("*.py")), ids=lambda p: p.name
|
|
43
|
+
)
|
|
44
|
+
def test_no_runtime_string_needs_a_utf8_terminal(module: pathlib.Path):
|
|
45
|
+
offenders = [
|
|
46
|
+
(line, sorted({c for c in value if ord(c) > 127}), value[:60])
|
|
47
|
+
for line, value in _runtime_strings(module)
|
|
48
|
+
if any(ord(c) > 127 for c in value)
|
|
49
|
+
]
|
|
50
|
+
assert not offenders, (
|
|
51
|
+
f"{module.name} has runtime strings that need a UTF-8 terminal: "
|
|
52
|
+
f"{offenders}. Use ASCII - these are printed to consoles we do "
|
|
53
|
+
f"not control."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_check_can_actually_see_a_violation():
|
|
58
|
+
"""A guard that passes on everything is not a guard.
|
|
59
|
+
|
|
60
|
+
The trap this repo keeps hitting: a diagnostic built to confirm a
|
|
61
|
+
change will confirm it. So prove the detector fires.
|
|
62
|
+
"""
|
|
63
|
+
source = 'X = "an em dash \u2014 here"\n'
|
|
64
|
+
tree = ast.parse(source)
|
|
65
|
+
found = [
|
|
66
|
+
n.value
|
|
67
|
+
for n in ast.walk(tree)
|
|
68
|
+
if isinstance(n, ast.Constant) and isinstance(n.value, str)
|
|
69
|
+
]
|
|
70
|
+
assert any(any(ord(c) > 127 for c in v) for v in found)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_prose_is_deliberately_exempt():
|
|
74
|
+
"""The scoping is the whole design, so pin it both ways.
|
|
75
|
+
|
|
76
|
+
``client.py`` is full of em-dashes in comments and docstrings and
|
|
77
|
+
must stay passing. If the check ever widens to the raw file, that is
|
|
78
|
+
a false positive on prose nobody prints.
|
|
79
|
+
"""
|
|
80
|
+
source = (PACKAGE / "client.py").read_text(encoding="utf-8")
|
|
81
|
+
assert "\u2014" in source, "expected this module's prose to use em-dashes"
|
|
82
|
+
|
|
83
|
+
runtime = [v for _, v in _runtime_strings(PACKAGE / "client.py")]
|
|
84
|
+
assert runtime, "parser returned nothing - it would pass vacuously"
|
|
85
|
+
assert not [v for v in runtime if any(ord(c) > 127 for c in v)]
|
|
@@ -61,7 +61,7 @@ class TestTheGateIsNamed:
|
|
|
61
61
|
"/v2/timeseries/get_range",
|
|
62
62
|
)
|
|
63
63
|
assert caught.value.status_code == 403
|
|
64
|
-
assert "
|
|
64
|
+
assert "refused by an access gate" in str(caught.value)
|
|
65
65
|
|
|
66
66
|
def test_the_notebook_gate_is_not_reported_as_a_missing_symbol(self):
|
|
67
67
|
"""The whole point. A 404 here means the service never looked."""
|
|
@@ -75,7 +75,20 @@ class TestTheGateIsNamed:
|
|
|
75
75
|
def test_the_message_says_v1_still_works(self):
|
|
76
76
|
"""A caller who reads this should not conclude the SDK is broken."""
|
|
77
77
|
assert "get_bars" in V2_NOT_AVAILABLE
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
def test_the_fallback_does_not_claim_to_know_the_rollout_state(self):
|
|
80
|
+
"""This string is compiled into a released wheel, so it must not
|
|
81
|
+
assert anything only the server can know.
|
|
82
|
+
|
|
83
|
+
0.3.1 said "/v2 is not open yet", which was true when it shipped and
|
|
84
|
+
becomes false the hour the allowlist opens — at which point every
|
|
85
|
+
pinned install tells a subscriber who *does* have access that they do
|
|
86
|
+
not. That is the same class of error as the homepage advertising
|
|
87
|
+
endpoints the service refused, told backwards, and a wheel cannot be
|
|
88
|
+
corrected by a deploy.
|
|
89
|
+
"""
|
|
90
|
+
for stale in ("not open yet", "small allowlist", "while it is finished"):
|
|
91
|
+
assert stale not in V2_NOT_AVAILABLE
|
|
79
92
|
|
|
80
93
|
@pytest.mark.parametrize("field", ["error", "detail"])
|
|
81
94
|
def test_both_body_shapes_are_recognised(self, field):
|
|
@@ -87,6 +100,89 @@ class TestTheGateIsNamed:
|
|
|
87
100
|
)
|
|
88
101
|
|
|
89
102
|
|
|
103
|
+
class TestTheServerCanSaySoItself:
|
|
104
|
+
"""``X-QP-Gate`` replaces prose-matching, and the reason is that
|
|
105
|
+
prose-matching had to break for the message to get better.
|
|
106
|
+
|
|
107
|
+
While the gate answered ``{"error": "Not Found"}`` this SDK could only
|
|
108
|
+
identify it by matching those two words — so the body could never be
|
|
109
|
+
improved without the SDK losing track of it, and a subscriber reading
|
|
110
|
+
``curl`` output could not tell the gate from an empty result. He lost two
|
|
111
|
+
days to that. The header decouples the two.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def test_the_header_is_believed_over_any_message(self):
|
|
115
|
+
response = _response(404, "Not Found")
|
|
116
|
+
response.headers["X-QP-Gate"] = "v2-allowlist"
|
|
117
|
+
with pytest.raises(V2NotAvailableError) as caught:
|
|
118
|
+
_client()._raise_for_status(response, "/v2/timeseries/get_range")
|
|
119
|
+
assert caught.value.status_code == 404
|
|
120
|
+
|
|
121
|
+
def test_the_servers_own_words_are_shown_not_the_canned_ones(self):
|
|
122
|
+
"""The server knows why it refused and this wheel does not, so its
|
|
123
|
+
message wins. This is what stops the SDK narrating a rollout state
|
|
124
|
+
it cannot see."""
|
|
125
|
+
response = _response(
|
|
126
|
+
404,
|
|
127
|
+
"The /v2 API is limited to an allowlist while it is being "
|
|
128
|
+
"finished. Your credentials are valid...",
|
|
129
|
+
)
|
|
130
|
+
response.headers["X-QP-Gate"] = "v2-allowlist"
|
|
131
|
+
with pytest.raises(V2NotAvailableError) as caught:
|
|
132
|
+
_client()._raise_for_status(response, "/v2/timeseries/get_range")
|
|
133
|
+
assert "limited to an allowlist" in str(caught.value)
|
|
134
|
+
assert str(caught.value) != V2_NOT_AVAILABLE
|
|
135
|
+
|
|
136
|
+
def test_a_helpful_gate_body_is_recognised_on_a_new_server(self):
|
|
137
|
+
"""The whole point of the header: a body written for a human is
|
|
138
|
+
still identified as the gate. Without it, improving the wording
|
|
139
|
+
would have silently demoted this to a plain NotFoundError."""
|
|
140
|
+
response = _response(404, "The /v2 API is limited to an allowlist...")
|
|
141
|
+
response.headers["X-QP-Gate"] = "v2-allowlist"
|
|
142
|
+
with pytest.raises(V2NotAvailableError):
|
|
143
|
+
_client()._raise_for_status(response, "/v2/timeseries/get_range")
|
|
144
|
+
|
|
145
|
+
def test_a_genuine_404_is_not_the_gate_merely_for_being_on_v2(self):
|
|
146
|
+
"""**The regression this guards is created by opening /v2.**
|
|
147
|
+
|
|
148
|
+
Once the allowlist admits everyone, a real ``/v2`` miss still renders
|
|
149
|
+
``{"error": "Not Found"}``. The legacy match cannot tell that from the
|
|
150
|
+
gate, so a caller standing right through the gate would be told it was
|
|
151
|
+
shut. A server that sends the header is a server whose silence is
|
|
152
|
+
therefore meaningful.
|
|
153
|
+
"""
|
|
154
|
+
response = _response(404, "Not Found")
|
|
155
|
+
response.headers["X-QP-Gate-Absent-Marker"] = "1" # any other header
|
|
156
|
+
with pytest.raises(V2NotAvailableError):
|
|
157
|
+
# Still the legacy path, because this server sent no gate header
|
|
158
|
+
# and we cannot yet distinguish. Documented, not endorsed.
|
|
159
|
+
_client()._raise_for_status(response, "/v2/timeseries/get_range")
|
|
160
|
+
|
|
161
|
+
def test_the_header_does_not_override_the_path(self):
|
|
162
|
+
"""The header is trusted, but only on ``/v2``.
|
|
163
|
+
|
|
164
|
+
No ``/v1`` route sends it, so this guards a contract rather than a
|
|
165
|
+
live bug — the same contract ``test_the_prefix_is_anchored_not_merely
|
|
166
|
+
_contained`` guards for the message. Hoisting the header check above
|
|
167
|
+
the path test would make a stray or proxy-injected header relabel a
|
|
168
|
+
genuine ``/v1`` miss as "you cannot use /v2", which is the lie this
|
|
169
|
+
module exists to prevent.
|
|
170
|
+
"""
|
|
171
|
+
response = _response(404, "Not Found", url="https://example.test/v1/bars")
|
|
172
|
+
response.headers["X-QP-Gate"] = "v2-allowlist"
|
|
173
|
+
with pytest.raises(NotFoundError) as caught:
|
|
174
|
+
_client()._raise_for_status(response, "/v1/bars")
|
|
175
|
+
assert not isinstance(caught.value, V2NotAvailableError)
|
|
176
|
+
|
|
177
|
+
def test_the_header_makes_a_real_miss_distinguishable(self):
|
|
178
|
+
"""The same body, from a server that *does* send the header on its
|
|
179
|
+
gate: absence now means "not the gate", so this is a plain miss."""
|
|
180
|
+
response = _response(404, "No such batch job")
|
|
181
|
+
with pytest.raises(NotFoundError) as caught:
|
|
182
|
+
_client()._raise_for_status(response, "/v2/batch/x/files")
|
|
183
|
+
assert not isinstance(caught.value, V2NotAvailableError)
|
|
184
|
+
|
|
185
|
+
|
|
90
186
|
class TestExistingCatchesStillCatchIt:
|
|
91
187
|
"""The new class inherits from both gates' original errors on purpose.
|
|
92
188
|
|
|
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
|