quantpad-data 0.5.0__tar.gz → 0.5.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.
Files changed (23) hide show
  1. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/PKG-INFO +1 -1
  2. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/pyproject.toml +1 -1
  3. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/client.py +1 -1
  4. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/memory.py +59 -3
  5. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_v2.py +101 -0
  6. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/.gitignore +0 -0
  7. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/README.md +0 -0
  8. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/__init__.py +0 -0
  9. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/cache.py +0 -0
  10. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/errors.py +0 -0
  11. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/lean.py +0 -0
  12. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/py.typed +0 -0
  13. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/rth.py +0 -0
  14. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/v2.py +0 -0
  15. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/conftest.py +0 -0
  16. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_client.py +0 -0
  17. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_lean.py +0 -0
  18. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_message_encoding.py +0 -0
  19. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_packaging.py +0 -0
  20. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_rth.py +0 -0
  21. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_v2_cache.py +0 -0
  22. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_v2_gate.py +0 -0
  23. {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.5.0
3
+ Version: 0.5.1
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.5.0"
7
+ version = "0.5.1"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -84,7 +84,7 @@ V2_NOT_AVAILABLE = (
84
84
  "QuantPad support about /v2 access."
85
85
  )
86
86
 
87
- VERSION = "0.5.0"
87
+ VERSION = "0.5.1"
88
88
  USER_AGENT = f"quantpad-data-python/{VERSION}"
89
89
  TICK_SCHEMAS = (
90
90
  "trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
@@ -59,6 +59,34 @@ def _read_int(path: str) -> int | None:
59
59
  return None
60
60
 
61
61
 
62
+ def _stat_map(path: str) -> dict[str, int]:
63
+ """A cgroup ``memory.stat`` file as a mapping, empty if unreadable."""
64
+ out: dict[str, int] = {}
65
+ try:
66
+ with open(path) as handle:
67
+ for line in handle:
68
+ key, _, value = line.partition(" ")
69
+ try:
70
+ out[key] = int(value)
71
+ except ValueError:
72
+ continue
73
+ except OSError:
74
+ pass
75
+ return out
76
+
77
+
78
+ def _unreclaimable(stat: dict[str, int], keys: tuple[str, ...]) -> int | None:
79
+ """What this cgroup holds that the kernel cannot take back.
80
+
81
+ Everything charged to a cgroup is *not* equally spent. Page cache is
82
+ charged, and is handed back the moment anything needs the memory, so
83
+ subtracting it from the limit describes a process that is nowhere near
84
+ its ceiling as though it were at it.
85
+ """
86
+ present = [stat[key] for key in keys if key in stat]
87
+ return sum(present) if present else None
88
+
89
+
62
90
  def _cgroup_free() -> int | None:
63
91
  """Headroom under this container's memory limit, if it has one.
64
92
 
@@ -66,12 +94,34 @@ def _cgroup_free() -> int | None:
66
94
  reports the HOST's memory: in a sandbox on a large machine that is wrong
67
95
  by an order of magnitude and wrong in the generous direction, which is
68
96
  the direction that kills the process.
97
+
98
+ HEADROOM IS THE LIMIT LESS WHAT CANNOT BE RECLAIMED, NOT LESS WHAT IS
99
+ CHARGED, and the difference is the whole reason this function is not two
100
+ lines. ``memory.current`` includes page cache, so *writing a file* looks
101
+ identical to *allocating memory*: measured on a 4 GiB Daytona sandbox,
102
+ 2 GiB of files on disk took this from 3.91 GiB to 1.93 GiB while the
103
+ process's own ``anon`` never left 0.09 GiB, and deleting the files put it
104
+ straight back. Under ``mode="auto"`` that halved the declared budget and
105
+ refused windows that fitted twice over, with a message naming memory the
106
+ caller had. The disk cache makes it worse by design: every partition it
107
+ writes, and every one it reads back, is more page cache.
108
+
109
+ Note this is also what makes the two sources answer the SAME question --
110
+ ``MemAvailable`` already counts reclaimable cache as available, so the old
111
+ subtraction had them disagreeing and ``min()`` let the wrong one win.
69
112
  """
70
- for limit_path, usage_path in (
71
- ("/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory.current"),
113
+ for limit_path, usage_path, stat_path, keys in (
114
+ (
115
+ "/sys/fs/cgroup/memory.max",
116
+ "/sys/fs/cgroup/memory.current",
117
+ "/sys/fs/cgroup/memory.stat",
118
+ ("anon", "unevictable", "slab_unreclaimable"),
119
+ ),
72
120
  (
73
121
  "/sys/fs/cgroup/memory/memory.limit_in_bytes",
74
122
  "/sys/fs/cgroup/memory/memory.usage_in_bytes",
123
+ "/sys/fs/cgroup/memory/memory.stat",
124
+ ("total_rss", "total_unevictable"),
75
125
  ),
76
126
  ):
77
127
  limit = _read_int(limit_path)
@@ -79,7 +129,13 @@ def _cgroup_free() -> int | None:
79
129
  # None; v1 writes a sentinel. Both mean unlimited.
80
130
  if limit is None or limit >= _NO_LIMIT:
81
131
  continue
82
- return max(limit - (_read_int(usage_path) or 0), 0)
132
+ spent = _unreclaimable(_stat_map(stat_path), keys)
133
+ if spent is None:
134
+ # No usable stat file. Fall back to the charged figure, which is
135
+ # pessimistic rather than dangerous: it can refuse a request that
136
+ # would have fitted, and never admits one that will not.
137
+ spent = _read_int(usage_path) or 0
138
+ return max(limit - spent, 0)
83
139
  return None
84
140
 
85
141
 
@@ -1059,6 +1059,107 @@ class TestWhatThisProcessCanHold:
1059
1059
  assert memory.available_bytes() is None
1060
1060
  assert memory.declared_budget("auto") is None
1061
1061
 
1062
+ @staticmethod
1063
+ def _fake_cgroup(monkeypatch, *, v2=True, limit=4 * 2**30, stat=None,
1064
+ charged=None):
1065
+ """Stand in for the cgroup files, since their paths are absolute."""
1066
+ limit_name = "memory.max" if v2 else "memory.limit_in_bytes"
1067
+ usage_name = "memory.current" if v2 else "memory.usage_in_bytes"
1068
+
1069
+ def read_int(path):
1070
+ if limit_name in path:
1071
+ return limit
1072
+ if usage_name in path:
1073
+ return charged
1074
+ return None
1075
+
1076
+ monkeypatch.setattr(memory, "_read_int", read_int)
1077
+ monkeypatch.setattr(memory, "_stat_map", lambda path: dict(stat or {}))
1078
+
1079
+ def test_page_cache_is_not_charged_to_the_caller(self, monkeypatch):
1080
+ """The defect this arithmetic exists to stop, measured not imagined.
1081
+
1082
+ On a 4 GiB Daytona sandbox, writing 2 GiB of files took the detected
1083
+ figure from 3.91 GiB to 1.93 GiB while the process's own ``anon``
1084
+ stayed at 0.09 GiB, and deleting the files restored it exactly. Under
1085
+ ``mode="auto"`` that halved the declared budget and refused windows
1086
+ that fitted twice over, blaming memory the caller had. The disk cache
1087
+ feeds it: every partition written and every one read back is page
1088
+ cache.
1089
+ """
1090
+ limit = 4 * 2**30
1091
+ self._fake_cgroup(
1092
+ monkeypatch,
1093
+ limit=limit,
1094
+ # 2 GiB of page cache on top of a 100 MiB process.
1095
+ charged=100 * 2**20 + 2 * 2**30,
1096
+ stat={
1097
+ "anon": 100 * 2**20,
1098
+ "file": 2 * 2**30,
1099
+ "unevictable": 0,
1100
+ "slab_unreclaimable": 8 * 2**20,
1101
+ },
1102
+ )
1103
+
1104
+ free = memory._cgroup_free()
1105
+
1106
+ assert free == limit - (100 * 2**20 + 8 * 2**20)
1107
+ # Emphatically not the charged figure, which is what it used to be.
1108
+ assert free > 3 * 2**30
1109
+
1110
+ def test_a_cgroup_v1_container_reads_its_resident_set(self, monkeypatch):
1111
+ """v1 spells the same quantity ``total_rss`` rather than ``anon``."""
1112
+ limit = 2 * 2**30
1113
+ self._fake_cgroup(
1114
+ monkeypatch,
1115
+ v2=False,
1116
+ limit=limit,
1117
+ charged=limit, # wholly charged, almost all of it cache
1118
+ stat={
1119
+ "total_rss": 300 * 2**20,
1120
+ "total_cache": limit - 300 * 2**20,
1121
+ "total_unevictable": 0,
1122
+ },
1123
+ )
1124
+
1125
+ assert memory._cgroup_free() == limit - 300 * 2**20
1126
+
1127
+ def test_a_process_that_really_is_near_its_limit_still_reads_as_full(
1128
+ self, monkeypatch
1129
+ ):
1130
+ """The guard must still guard.
1131
+
1132
+ Discounting page cache is only correct because it is reclaimable;
1133
+ anonymous memory is not, so a process that has genuinely filled its
1134
+ cgroup must still report almost nothing free.
1135
+ """
1136
+ limit = 4 * 2**30
1137
+ self._fake_cgroup(
1138
+ monkeypatch,
1139
+ limit=limit,
1140
+ charged=limit,
1141
+ stat={"anon": limit - 64 * 2**20, "file": 32 * 2**20},
1142
+ )
1143
+
1144
+ assert memory._cgroup_free() == 64 * 2**20
1145
+ assert memory.declared_budget("auto") < 16 * 2**20
1146
+
1147
+ def test_an_unreadable_stat_file_falls_back_to_the_charged_figure(
1148
+ self, monkeypatch
1149
+ ):
1150
+ """Pessimistic rather than dangerous.
1151
+
1152
+ Without ``memory.stat`` there is no way to separate cache from
1153
+ anonymous memory, and the old subtraction is the safe direction: it
1154
+ can refuse something that would have fitted, but never admits
1155
+ something that will not.
1156
+ """
1157
+ limit = 4 * 2**30
1158
+ self._fake_cgroup(monkeypatch, limit=limit, stat={},
1159
+ charged=3 * 2**30)
1160
+
1161
+ assert memory._cgroup_free() == limit - 3 * 2**30
1162
+
1062
1163
  def test_an_unlimited_cgroup_is_not_read_as_a_limit(self, monkeypatch):
1063
1164
  """v2 writes the literal "max" and v1 writes a huge sentinel.
1064
1165
 
File without changes
File without changes