aggregate_api 1.0.0__py3-none-any.whl

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 (63) hide show
  1. aggregate_api/__init__.py +41 -0
  2. aggregate_api/__main__.py +154 -0
  3. aggregate_api/app.py +206 -0
  4. aggregate_api/audit.py +395 -0
  5. aggregate_api/bounds.py +331 -0
  6. aggregate_api/cache.py +319 -0
  7. aggregate_api/capability.py +823 -0
  8. aggregate_api/completion.py +219 -0
  9. aggregate_api/config.py +363 -0
  10. aggregate_api/cors.py +61 -0
  11. aggregate_api/examples.py +620 -0
  12. aggregate_api/layer_pricing.py +840 -0
  13. aggregate_api/library.py +94 -0
  14. aggregate_api/library_notes.py +96 -0
  15. aggregate_api/models.py +1407 -0
  16. aggregate_api/net.py +281 -0
  17. aggregate_api/pnl.py +101 -0
  18. aggregate_api/pricing.py +778 -0
  19. aggregate_api/resources.py +257 -0
  20. aggregate_api/routes/__init__.py +8 -0
  21. aggregate_api/routes/decl.py +327 -0
  22. aggregate_api/routes/examples.py +82 -0
  23. aggregate_api/routes/meta.py +282 -0
  24. aggregate_api/routes/objects.py +4119 -0
  25. aggregate_api/routes/status.py +466 -0
  26. aggregate_api/serializers.py +565 -0
  27. aggregate_api/sessions.py +353 -0
  28. aggregate_api/static/aggregate-api-logo-512.png +0 -0
  29. aggregate_api/static/aggregate-api-logo.png +0 -0
  30. aggregate_api/static/aggregate-api-trim.png +0 -0
  31. aggregate_api/static/android-chrome-192x192.png +0 -0
  32. aggregate_api/static/android-chrome-512x512.png +0 -0
  33. aggregate_api/static/apple-touch-icon.png +0 -0
  34. aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
  35. aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
  36. aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
  37. aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
  38. aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
  39. aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
  40. aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
  41. aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
  42. aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
  43. aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
  44. aggregate_api/static/assets/main-CmoEiPit.js +9 -0
  45. aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
  46. aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
  47. aggregate_api/static/favicon-16x16.png +0 -0
  48. aggregate_api/static/favicon-32x32.png +0 -0
  49. aggregate_api/static/favicon.ico +0 -0
  50. aggregate_api/static/index.html +912 -0
  51. aggregate_api/static/lite.html +83 -0
  52. aggregate_api/static/logo.png +0 -0
  53. aggregate_api/static/site.webmanifest +14 -0
  54. aggregate_api/static/sw.js +78 -0
  55. aggregate_api/status.py +536 -0
  56. aggregate_api/status_page.html +546 -0
  57. aggregate_api/tables.py +316 -0
  58. aggregate_api-1.0.0.dist-info/METADATA +187 -0
  59. aggregate_api-1.0.0.dist-info/RECORD +63 -0
  60. aggregate_api-1.0.0.dist-info/WHEEL +5 -0
  61. aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
  62. aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
  63. aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
aggregate_api/net.py ADDED
@@ -0,0 +1,281 @@
1
+ """Where a request came from, behind exactly one trusted proxy.
2
+
3
+ The api runs on ``127.0.0.1:8001`` behind two Caddy front doors, one public
4
+ (``agg.mynl.com``) and one on the VPN (``10.8.0.1:19456``), and both of them
5
+ ``reverse_proxy`` to that same address. So ``request.client.host`` is
6
+ ``127.0.0.1`` for a visitor from the public internet exactly as much as for one
7
+ on the VPN, and any code reading the peer address to decide who is asking gets
8
+ the same answer for both. Two things follow, and this module is both of them.
9
+
10
+ **The audit log has been recording a constant.** ``_client_ip`` in
11
+ ``routes/objects.py`` read ``request.client.host`` and nothing else, so every
12
+ production row carries ``ip = '127.0.0.1'``, the ``builds_ip`` index indexes one
13
+ value, and :meth:`aggregate_api.audit.AuditLog.by_ip` cannot answer the question
14
+ it exists for. Rows written before this module are not retroactively meaningful.
15
+
16
+ **And a private-origin gate cannot be written on the peer.** "Allow if the peer
17
+ is loopback" would test green on a laptop, test green over the VPN, and publish
18
+ the page it guards to the entire internet. The gate reads the forwarded chain
19
+ instead, and it reads it from the correct end.
20
+
21
+ The correct end
22
+ ---------------
23
+
24
+ Caddy **appends** the address it observed to whatever ``X-Forwarded-For``
25
+ arrived, so the **last** element is the one Caddy saw and everything before it
26
+ is whatever the client chose to send. A public visitor who sends
27
+ ``X-Forwarded-For: 10.8.0.2`` produces ``10.8.0.2, <their real address>``, and
28
+ reading the first element hands them the page. Every function here reads the
29
+ last, and :func:`forwarded_chain` flattens repeated header lines in arrival
30
+ order first, because ``Headers.get`` returns only the first occurrence and a
31
+ client can send its own line ahead of Caddy's.
32
+
33
+ **Reading the last element is correct for exactly one trusted proxy**, which is
34
+ the deployment ``human-hints.md`` describes. Put a second proxy in front and the
35
+ last element becomes that proxy's view of the first, the index is off by one,
36
+ and the gate opens. Nothing here can detect that, so it is a deployment
37
+ invariant rather than a check: one hop, and a change to the topology is a change
38
+ to this module.
39
+
40
+ Fail closed
41
+ -----------
42
+
43
+ Anything unparseable is neither private nor a client address. A present but
44
+ empty header, a garbled element, an unexpected format: each answers "no" rather
45
+ than falling back to the peer, because the peer is loopback and loopback is what
46
+ the private list allows.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import ipaddress
52
+ from typing import Iterable
53
+
54
+ #: The forwarded-for header, spelled once. Compared case-insensitively by
55
+ #: Starlette's ``Headers``, so the casing here is presentation only.
56
+ FORWARDED_FOR = "X-Forwarded-For"
57
+
58
+ #: What ``AGGAPI_PRIVATE_CIDRS`` defaults to: loopback, the IPv6 loopback, and
59
+ #: the VPN subnet from ``human-hints.md``. A setting rather than a constant
60
+ #: because the VPN subnet is a deployment fact.
61
+ DEFAULT_PRIVATE_CIDRS = "127.0.0.0/8, ::1, 10.8.0.0/24"
62
+
63
+ #: Returned when no client address can be established. Matches what
64
+ #: ``_client_ip`` has always written for a request with no peer.
65
+ UNKNOWN = "-"
66
+
67
+ Network = ipaddress.IPv4Network | ipaddress.IPv6Network
68
+
69
+
70
+ def parse_cidrs(raw: str) -> tuple[Network, ...]:
71
+ """Parse a comma-separated CIDR list into networks.
72
+
73
+ Parameters
74
+ ----------
75
+ raw : str
76
+ Comma-separated networks or bare addresses, for example
77
+ ``"127.0.0.0/8, ::1, 10.8.0.0/24"``. A bare address becomes a single
78
+ host network (``::1`` is ``::1/128``).
79
+
80
+ Returns
81
+ -------
82
+ tuple
83
+ The parsed networks, in the order given.
84
+
85
+ Raises
86
+ ------
87
+ ValueError
88
+ If any element fails to parse. Deliberately loud: a typo in this
89
+ setting is a gate that silently allows less, or nothing, and a server
90
+ that refuses to start reports that better than a page which has quietly
91
+ stopped answering.
92
+
93
+ Notes
94
+ -----
95
+ ``strict=False`` so an entry written with host bits set (``10.8.0.1/24``,
96
+ which is how an operator naturally writes the VPN address they can see) is
97
+ read as the network containing it rather than rejected.
98
+ """
99
+ networks: list[Network] = []
100
+ for element in raw.split(","):
101
+ text = element.strip()
102
+ if not text:
103
+ continue
104
+ networks.append(ipaddress.ip_network(text, strict=False))
105
+ return tuple(networks)
106
+
107
+
108
+ def as_ip(text: str | None):
109
+ """Parse one forwarded element into an address, or ``None``.
110
+
111
+ Parameters
112
+ ----------
113
+ text : str or None
114
+ One element of a forwarded chain, or a peer address.
115
+
116
+ Returns
117
+ -------
118
+ ipaddress.IPv4Address or ipaddress.IPv6Address or None
119
+ ``None`` for anything that does not parse, which every caller reads as
120
+ "not private" and "not a client address".
121
+
122
+ Notes
123
+ -----
124
+ Three decorations are unwrapped, and only three. ``[::1]:443`` is the
125
+ bracketed IPv6-with-port form; ``1.2.3.4:5678`` is the IPv4 one, told apart
126
+ by having exactly one colon, since a bare IPv6 address always has at least
127
+ two; and an IPv4-mapped IPv6 address (``::ffff:10.8.0.2``, which a
128
+ dual-stack listener can report) is reduced to the IPv4 address it carries,
129
+ without which a genuine VPN peer would be compared against an IPv4 network
130
+ as a v6 address and refused.
131
+
132
+ Anything else fails closed rather than being guessed at. That direction is
133
+ the whole point: an unrecognized format costs a legitimate request a 404, an
134
+ over-clever parse costs the page.
135
+ """
136
+ if not text:
137
+ return None
138
+ candidate = text.strip()
139
+ if not candidate:
140
+ return None
141
+ if candidate.startswith("["):
142
+ end = candidate.find("]")
143
+ if end < 0:
144
+ return None
145
+ candidate = candidate[1:end]
146
+ elif candidate.count(":") == 1:
147
+ candidate = candidate.split(":", 1)[0]
148
+ try:
149
+ address = ipaddress.ip_address(candidate)
150
+ except ValueError:
151
+ return None
152
+ if address.version == 6 and address.ipv4_mapped is not None:
153
+ return address.ipv4_mapped
154
+ return address
155
+
156
+
157
+ def is_private(text: str | None, networks: Iterable[Network]) -> bool:
158
+ """True when ``text`` parses to an address inside one of ``networks``.
159
+
160
+ Parameters
161
+ ----------
162
+ text : str or None
163
+ A candidate address.
164
+ networks : iterable
165
+ Networks from :func:`parse_cidrs`.
166
+
167
+ Returns
168
+ -------
169
+ bool
170
+
171
+ Notes
172
+ -----
173
+ The version is checked before the membership test because
174
+ ``IPv4Address in IPv6Network`` raises :class:`TypeError` rather than
175
+ answering False, and a gate that raises on a mixed-family list is a gate
176
+ that 500s instead of refusing.
177
+ """
178
+ address = as_ip(text)
179
+ if address is None:
180
+ return False
181
+ return any(address.version == net.version and address in net
182
+ for net in networks)
183
+
184
+
185
+ def forwarded_chain(request) -> list[str]:
186
+ """Every ``X-Forwarded-For`` element, in arrival order.
187
+
188
+ Parameters
189
+ ----------
190
+ request : starlette.requests.Request
191
+
192
+ Returns
193
+ -------
194
+ list of str
195
+ Stripped elements, earliest hop first. Empty **only** when the header is
196
+ absent, so an empty list means "nothing proxied this request" and never
197
+ "a proxy said nothing".
198
+
199
+ Notes
200
+ -----
201
+ ``getlist`` rather than ``get``. Repeated header lines are equivalent to one
202
+ comma-joined line, and ``Headers.get`` returns only the first occurrence, so
203
+ a client sending its own ``X-Forwarded-For`` line ahead of the proxy's would
204
+ have its own line read as the whole chain. Flattening every occurrence in
205
+ order puts the proxy's observation last, where the rest of this module
206
+ expects it.
207
+
208
+ **Empty elements are kept, deliberately.** ``"10.8.0.2,"`` yields
209
+ ``['10.8.0.2', '']`` and the last element is the empty string, which parses
210
+ to nothing and is therefore refused. Dropping empties would make the last
211
+ element ``10.8.0.2``, a value the client chose, and a proxy that had stopped
212
+ appending would then admit whatever a visitor cared to send. The degenerate
213
+ case has to fail closed, and keeping the empty element is what makes it.
214
+ """
215
+ elements: list[str] = []
216
+ for value in request.headers.getlist(FORWARDED_FOR):
217
+ elements.extend(part.strip() for part in value.split(","))
218
+ return elements
219
+
220
+
221
+ def client_address(request) -> str:
222
+ """The address this request came from, honoring one trusted proxy.
223
+
224
+ Parameters
225
+ ----------
226
+ request : starlette.requests.Request
227
+
228
+ Returns
229
+ -------
230
+ str
231
+ The client address, or :data:`UNKNOWN` when none can be established.
232
+
233
+ Notes
234
+ -----
235
+ Two cases, and the header's **presence** decides which applies, because the
236
+ peer is loopback either way. With no ``X-Forwarded-For`` nothing proxied the
237
+ request, so the peer *is* the client, which is the local-development and
238
+ direct-curl case. With the header present the request came through Caddy and
239
+ the last element is what Caddy observed: the module docstring says why it is
240
+ the last and not the first.
241
+
242
+ A present but unusable header does **not** fall back to the peer, which is
243
+ why the branch turns on presence rather than on whether an address was
244
+ recovered. Falling back would report ``127.0.0.1`` for a public visitor,
245
+ which is the bug this function exists to fix, and in
246
+ :func:`aggregate_api.routes.status.require_private` it would be the
247
+ difference between refusing and admitting.
248
+ """
249
+ chain = forwarded_chain(request)
250
+ if chain:
251
+ address = as_ip(chain[-1])
252
+ return str(address) if address is not None else UNKNOWN
253
+ if request.client and request.client.host:
254
+ address = as_ip(request.client.host)
255
+ return str(address) if address is not None else request.client.host
256
+ return UNKNOWN
257
+
258
+
259
+ def is_private_request(request, networks: Iterable[Network]) -> tuple[bool, str]:
260
+ """Whether this request demonstrably came from a private origin.
261
+
262
+ Parameters
263
+ ----------
264
+ request : starlette.requests.Request
265
+ networks : iterable
266
+ Networks from :func:`parse_cidrs`.
267
+
268
+ Returns
269
+ -------
270
+ (bool, str)
271
+ The verdict, and the address it was judged on, for the log line and the
272
+ refusal buffer.
273
+
274
+ Notes
275
+ -----
276
+ Deny by default: False unless an address was established *and* it falls
277
+ inside the list. :data:`UNKNOWN` is in no network, so the two failure modes
278
+ (no address, wrong address) converge on one refusal with no special case.
279
+ """
280
+ address = client_address(request)
281
+ return is_private(address, networks), address
aggregate_api/pnl.py ADDED
@@ -0,0 +1,101 @@
1
+ """The PnL group's own runner: the ledger read as a pentagon.
2
+
3
+ One function, and it has a module to itself for a reason. The obvious home was
4
+ ``pricing.py``, beside the four runners that already complete pentagons, but
5
+ that file opens by saying there is no pandas in it and that this is the point of
6
+ it: everything there hands back library exhibit envelopes or scalars, and the
7
+ frames are materialized upstream. This runner serializes a DataFrame, so putting
8
+ it there would quietly retire a claim worth keeping.
9
+
10
+ The division of labor is the plan's governing principle (``dev/plan-a195-pnl-
11
+ pentagon.md``, ruled 2026-10-06): **the library owns the arithmetic and the
12
+ browser owns the geometry**. Every number the figure draws comes out of
13
+ ``PnL.pentagon_df``; nothing about the economics is computed in JavaScript, and
14
+ nothing about the picture is computed here. The SVG lives in ``web/src/charts/
15
+ pentagon.js`` because it is a bespoke figure rather than a chart anyone else
16
+ emits, and forcing it through the chart IR would be a large extension for one
17
+ picture.
18
+
19
+ Why this is a route at all, rather than the app making do with ``/v1`` as it
20
+ stands. The make-do route would be per-ledger-row quantiles at an arbitrary
21
+ level, which this api does not expose and should not: it would be one frame per
22
+ level per row, fetched so the browser could reassemble arithmetic the library
23
+ already does in one call.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+ from .capability import can_pnl_pentagon
31
+ from .serializers import frame_to_payload, reset_index_safe
32
+
33
+ #: The strip's own six solvency levels, in reading order, so the first is the
34
+ #: default the leaf opens on.
35
+ #:
36
+ #: Carried here rather than in the SPA so one list drives both: the response
37
+ #: states which levels it answered at and the strip renders exactly those, which
38
+ #: means a change here needs no matching edit in the browser. The six are the
39
+ #: ones the retired ``aggregate-asp`` experiment offered (``js/state.js``), which
40
+ #: are the standards a reader of a solvency filing expects to be shown.
41
+ PENTAGON_RETURN_PERIODS = (100.0, 200.0, 250.0, 500.0, 1000.0, 2000.0)
42
+
43
+
44
+ def run_pnl_pentagon(obj: Any, *, periods=None) -> dict:
45
+ """The ledger as a pentagon, at each of several solvency levels.
46
+
47
+ Parameters
48
+ ----------
49
+ obj : Any
50
+ The live object, which must be a ``PnL``.
51
+ periods : list of float, optional
52
+ Return periods to answer at, each above 1. Defaults to
53
+ :data:`PENTAGON_RETURN_PERIODS`.
54
+
55
+ Returns
56
+ -------
57
+ dict
58
+ Matches :class:`aggregate_api.models.PnLPentagonResponse`: one entry per
59
+ level, in the order asked for, each carrying the level and the frame
60
+ struck at it with ``Step`` as its first column.
61
+
62
+ Raises
63
+ ------
64
+ ValueError
65
+ If the object is not a P&L, or if the installed library does not serve
66
+ ``pentagon_df``, or, from the library itself, if a period does not
67
+ exceed 1. Every message here is written to be shown to a reader, and the
68
+ route turns one into a 400 whose ``detail`` is the message verbatim.
69
+
70
+ Notes
71
+ -----
72
+ **Every level in one answer**, which is what the control needs. The leaf's
73
+ control is a strip of mini pentagons, each labeled with the assets its level
74
+ implies, so the reader chooses from figures they can already see rather than
75
+ pressing six times to find out. Serving one level per request would leave
76
+ the strip unable to label itself.
77
+
78
+ The cost of the extra five is a handful of quantile reads per ledger row.
79
+ The amounts, the expenses and the margin do not move with the level at all,
80
+ being properties of the book rather than of the standard applied to it; only
81
+ capital, assets and the two ratios over capital do.
82
+
83
+ A reader should not assume the figures grow with the level. A bounded
84
+ program exhausted in the far tail releases *less* capital at a remoter
85
+ standard, so the cession's own capital can shrink as the level rises while
86
+ the gross and net both grow. That is the reading the strip exists to make
87
+ visible, and it is pinned upstream in
88
+ ``tests/test_pnl_pentagon.py::test_an_exhausted_program_releases_less_capital_further_out``.
89
+ """
90
+ if not can_pnl_pentagon(obj):
91
+ raise ValueError(
92
+ "the pentagon reads a P&L ledger, and this object is not a P&L. "
93
+ "Wrap it with the PnL button first, or explode it into a tower to "
94
+ "see a cession in the figure.")
95
+ wanted = (PENTAGON_RETURN_PERIODS if not periods
96
+ else tuple(float(t) for t in periods))
97
+ levels = []
98
+ for t in wanted:
99
+ frame = reset_index_safe(obj.pentagon_df(t=t))
100
+ levels.append({"t": t, "frame": frame_to_payload(frame)})
101
+ return {"levels": levels}