quantpad-data 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Official Python client for the QuantPad market-data API
5
5
  Project-URL: Documentation, https://api.quantpad.ai/external/docs
6
6
  Author: QuantPad
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,21 @@ from .errors import (
25
25
  ValidationError,
26
26
  )
27
27
 
28
- #: What the two ``/v2`` gates answer, verbatim.
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
- "The /v2 API is not open yet. Your credentials are fine - /v2 is "
61
- "limited to a small allowlist while it is finished, so qpd.v2 and "
62
- "qpd.Historical will not work for you regardless of what you pass "
63
- "them. The documented /v1 helpers (get_bars, get_ticks, get_universe, "
64
- "get_coverage, symbology_resolve) are unaffected and carry the same "
65
- "data. Contact QuantPad support if you need /v2 access."
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.1"
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 (status, message) in _V2_GATE_REPLIES and path.startswith("/v2/"):
156
- raise V2NotAvailableError(V2_NOT_AVAILABLE, status)
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:
@@ -61,7 +61,7 @@ class TestTheGateIsNamed:
61
61
  "/v2/timeseries/get_range",
62
62
  )
63
63
  assert caught.value.status_code == 403
64
- assert "not open yet" in str(caught.value)
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
- assert "not open yet" in V2_NOT_AVAILABLE
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