ratecap 0.1.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.
ratecap-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ratecap
3
+ Version: 0.1.0
4
+ Summary: Thin Python client for the RateCap sidecar
5
+ Project-URL: Homepage, https://github.com/sairam0424/RateCap
6
+ Project-URL: Repository, https://github.com/sairam0424/RateCap
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.10
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "ratecap"
3
+ version = "0.1.0"
4
+ description = "Thin Python client for the RateCap sidecar"
5
+ requires-python = ">=3.10"
6
+ dependencies = []
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3",
9
+ "License :: OSI Approved :: MIT License",
10
+ ]
11
+
12
+ [project.urls]
13
+ Homepage = "https://github.com/sairam0424/RateCap"
14
+ Repository = "https://github.com/sairam0424/RateCap"
15
+
16
+ [build-system]
17
+ requires = ["setuptools>=68"]
18
+ build-backend = "setuptools.build_meta"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from ratecap.client import AllowResult, Client, Ticket
2
+ from ratecap.cost import estimate_llm_cost
3
+
4
+ __all__ = ["AllowResult", "Client", "Ticket", "estimate_llm_cost"]
@@ -0,0 +1,153 @@
1
+ import ssl
2
+ import time
3
+ import urllib.parse
4
+ import urllib.request
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class AllowResult:
10
+ allowed: bool
11
+ retry_after_ms: int = 0
12
+
13
+
14
+ @dataclass
15
+ class _Reservation:
16
+ key: str
17
+ token: str
18
+
19
+
20
+ class Ticket:
21
+ def __init__(self, client, key, allowed, retry_after_ms=0, reservations=None):
22
+ self.allowed = allowed
23
+ self.retry_after_ms = retry_after_ms
24
+ self._client = client
25
+ self._key = key
26
+ self._reservations = reservations or []
27
+
28
+ def release(self):
29
+ errors = []
30
+ for reservation in self._reservations:
31
+ try:
32
+ self._client._release_one(reservation)
33
+ except Exception as exc:
34
+ errors.append(f"{reservation.key}: {exc}")
35
+ if errors:
36
+ raise RuntimeError("failed to release reservation(s): " + "; ".join(errors))
37
+
38
+ def refund(self, refund_amount):
39
+ url = f"{self._client._sidecar_addr}/release"
40
+ req = urllib.request.Request(
41
+ url,
42
+ method="POST",
43
+ headers={
44
+ "X-RateCap-Refund-Key": self._key,
45
+ "X-RateCap-Refund-Amount": str(refund_amount),
46
+ },
47
+ )
48
+ try:
49
+ with self._client._urlopen(req) as resp:
50
+ if resp.status != 200:
51
+ raise RuntimeError(f"refund failed with status {resp.status}")
52
+ except urllib.error.HTTPError as err:
53
+ raise RuntimeError(f"refund failed with status {err.code}") from err
54
+
55
+ def __enter__(self):
56
+ return self
57
+
58
+ def __exit__(self, exc_type, exc_val, exc_tb):
59
+ self.release()
60
+ return False
61
+
62
+
63
+ class Client:
64
+ def __init__(
65
+ self, sidecar_addr, timeout=5.0, max_retries=0, backoff_base=0.1, ca_file=None
66
+ ):
67
+ self._sidecar_addr = sidecar_addr.rstrip("/")
68
+ self._timeout = timeout
69
+ self._max_retries = max_retries
70
+ self._backoff_base = backoff_base
71
+ self._ssl_context = None
72
+ if ca_file is not None:
73
+ self._ssl_context = ssl.create_default_context(cafile=ca_file)
74
+
75
+ def _urlopen(self, req):
76
+ attempt = 0
77
+ while True:
78
+ try:
79
+ kwargs = {"timeout": self._timeout}
80
+ if self._ssl_context is not None:
81
+ kwargs["context"] = self._ssl_context
82
+ return urllib.request.urlopen(req, **kwargs)
83
+ except urllib.error.HTTPError:
84
+ raise
85
+ except Exception:
86
+ if attempt >= self._max_retries:
87
+ raise
88
+ time.sleep(self._backoff_base * (2**attempt))
89
+ attempt += 1
90
+
91
+ def allow(self, key, cost=None, priority=None):
92
+ params = {"key": key, "skip_reservations": "true"}
93
+ if cost is not None:
94
+ params["cost"] = str(cost)
95
+ query = urllib.parse.urlencode(params)
96
+ url = f"{self._sidecar_addr}/check?{query}"
97
+ headers = {"x-ratecap-priority": priority} if priority else {}
98
+ req = urllib.request.Request(url, method="GET", headers=headers)
99
+ try:
100
+ with self._urlopen(req) as resp:
101
+ return AllowResult(allowed=True)
102
+ except urllib.error.HTTPError as err:
103
+ retry_after_ms = int(err.headers.get("Retry-After-Ms", 0) or 0)
104
+ return AllowResult(allowed=False, retry_after_ms=retry_after_ms)
105
+
106
+ def acquire(self, key, cost=None, priority=None):
107
+ params = {"key": key}
108
+ if cost is not None:
109
+ params["cost"] = str(cost)
110
+ query = urllib.parse.urlencode(params)
111
+ url = f"{self._sidecar_addr}/check?{query}"
112
+ headers = {"x-ratecap-priority": priority} if priority else {}
113
+ req = urllib.request.Request(url, method="GET", headers=headers)
114
+ try:
115
+ with self._urlopen(req) as resp:
116
+ reservations = self._parse_reservations(resp.headers)
117
+ return Ticket(self, key, allowed=True, reservations=reservations)
118
+ except urllib.error.HTTPError as err:
119
+ reservations = self._parse_reservations(err.headers)
120
+ retry_after_ms = int(err.headers.get("Retry-After-Ms", 0) or 0)
121
+ return Ticket(
122
+ self,
123
+ key,
124
+ allowed=False,
125
+ retry_after_ms=retry_after_ms,
126
+ reservations=reservations,
127
+ )
128
+
129
+ def _parse_reservations(self, headers):
130
+ reservations = []
131
+ i = 0
132
+ while True:
133
+ token = headers.get(f"Concurrency-Token-{i}")
134
+ if not token:
135
+ break
136
+ key = headers.get(f"Concurrency-Key-{i}", "")
137
+ reservations.append(_Reservation(key=key, token=token))
138
+ i += 1
139
+ return reservations
140
+
141
+ def _release_one(self, reservation):
142
+ url = f"{self._sidecar_addr}/release"
143
+ req = urllib.request.Request(
144
+ url,
145
+ method="POST",
146
+ headers={
147
+ "X-RateCap-Concurrency-Key": reservation.key,
148
+ "X-RateCap-Concurrency-Token": reservation.token,
149
+ },
150
+ )
151
+ with self._urlopen(req) as resp:
152
+ if resp.status != 200:
153
+ raise RuntimeError(f"release failed with status {resp.status}")
@@ -0,0 +1,3 @@
1
+ def estimate_llm_cost(input_tokens, max_tokens):
2
+ cost = input_tokens + max_tokens
3
+ return max(cost, 0)
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ratecap
3
+ Version: 0.1.0
4
+ Summary: Thin Python client for the RateCap sidecar
5
+ Project-URL: Homepage, https://github.com/sairam0424/RateCap
6
+ Project-URL: Repository, https://github.com/sairam0424/RateCap
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.10
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ src/ratecap/__init__.py
3
+ src/ratecap/client.py
4
+ src/ratecap/cost.py
5
+ src/ratecap.egg-info/PKG-INFO
6
+ src/ratecap.egg-info/SOURCES.txt
7
+ src/ratecap.egg-info/dependency_links.txt
8
+ src/ratecap.egg-info/top_level.txt
9
+ tests/test_client.py
10
+ tests/test_cost.py
@@ -0,0 +1 @@
1
+ ratecap
@@ -0,0 +1,386 @@
1
+ import os
2
+ import sys
3
+ import unittest
4
+
5
+ # unittest discover with -s <tests-dir> (no -t) treats tests/ as the top-level
6
+ # dir, so tests/__init__.py is never imported as a package init and can't put
7
+ # src/ on sys.path for us — this module is the only thing that runs before
8
+ # `from ratecap import Client` below, so the bootstrap has to live here.
9
+ _SRC_DIR = os.path.join(
10
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
11
+ )
12
+ if _SRC_DIR not in sys.path:
13
+ sys.path.insert(0, _SRC_DIR)
14
+
15
+ from ratecap import Client
16
+
17
+ # `tests.fake_sidecar` only resolves when cwd is packages/sdks/python (cwd is
18
+ # implicitly on sys.path as ''), which breaks the repo-root invocation
19
+ # `-s packages/sdks/python/tests` where no `tests` package exists at cwd.
20
+ # unittest discover with no -t always puts start_dir itself on sys.path, so a
21
+ # bare import resolves identically from both invocations.
22
+ from fake_sidecar import FakeSidecar
23
+
24
+
25
+ class TestAllow(unittest.TestCase):
26
+ def test_returns_true_on_200(self):
27
+ with FakeSidecar(lambda method, path, query, headers: (200, {})) as sidecar:
28
+ client = Client(sidecar.url)
29
+ result = client.allow("user-1")
30
+ self.assertTrue(result.allowed)
31
+
32
+ def test_returns_false_with_retry_after_on_429(self):
33
+ def handler(method, path, query, headers):
34
+ return 429, {"Retry-After-Ms": "750"}
35
+
36
+ with FakeSidecar(handler) as sidecar:
37
+ client = Client(sidecar.url)
38
+ result = client.allow("user-1")
39
+ self.assertFalse(result.allowed)
40
+ self.assertEqual(result.retry_after_ms, 750)
41
+
42
+ def test_requests_skip_reservations(self):
43
+ captured = {}
44
+
45
+ def handler(method, path, query, headers):
46
+ captured.update(query)
47
+ return 200, {}
48
+
49
+ with FakeSidecar(handler) as sidecar:
50
+ client = Client(sidecar.url)
51
+ client.allow("user-1")
52
+ self.assertEqual(captured.get("skip_reservations"), "true")
53
+
54
+
55
+ class TestAcquire(unittest.TestCase):
56
+ def test_acquire_returns_allowed_true_on_200(self):
57
+ def handler(method, path, query, headers):
58
+ if path == "/check":
59
+ return 200, {
60
+ "Concurrency-Token-0": "tok-abc",
61
+ "Concurrency-Key-0": "user-1",
62
+ }
63
+ return 200, {}
64
+
65
+ with FakeSidecar(handler) as sidecar:
66
+ client = Client(sidecar.url)
67
+ ticket = client.acquire("user-1")
68
+ self.assertTrue(ticket.allowed)
69
+
70
+ def test_acquire_does_not_send_skip_reservations(self):
71
+ captured = {}
72
+
73
+ def handler(method, path, query, headers):
74
+ if path == "/check":
75
+ captured.update(query)
76
+ return 200, {}
77
+ return 200, {}
78
+
79
+ with FakeSidecar(handler) as sidecar:
80
+ client = Client(sidecar.url)
81
+ client.acquire("user-1")
82
+ self.assertNotIn("skip_reservations", captured)
83
+
84
+ def test_release_releases_every_reservation(self):
85
+ release_calls = []
86
+
87
+ def handler(method, path, query, headers):
88
+ if path == "/check":
89
+ return 200, {
90
+ "Concurrency-Token-0": "tok-abc",
91
+ "Concurrency-Key-0": "user-1",
92
+ "Concurrency-Token-1": "tok-xyz",
93
+ "Concurrency-Key-1": "fleet",
94
+ }
95
+ if path == "/release":
96
+ release_calls.append(
97
+ {
98
+ "key": headers.get("X-Ratecap-Concurrency-Key"),
99
+ "token": headers.get("X-Ratecap-Concurrency-Token"),
100
+ }
101
+ )
102
+ return 200, {}
103
+ return 404, {}
104
+
105
+ with FakeSidecar(handler) as sidecar:
106
+ client = Client(sidecar.url)
107
+ ticket = client.acquire("user-1")
108
+ ticket.release()
109
+
110
+ self.assertEqual(len(release_calls), 2)
111
+ by_key = {c["key"]: c["token"] for c in release_calls}
112
+ self.assertEqual(by_key["user-1"], "tok-abc")
113
+ self.assertEqual(by_key["fleet"], "tok-xyz")
114
+
115
+ def test_release_reads_from_header_not_query(self):
116
+ release_calls = []
117
+
118
+ def handler(method, path, query, headers):
119
+ if path == "/check":
120
+ return 200, {
121
+ "Concurrency-Token-0": "tok-abc",
122
+ "Concurrency-Key-0": "user-1",
123
+ }
124
+ if path == "/release":
125
+ release_calls.append(
126
+ {
127
+ "query": dict(query),
128
+ "header_key": headers.get("X-Ratecap-Concurrency-Key"),
129
+ "header_token": headers.get("X-Ratecap-Concurrency-Token"),
130
+ }
131
+ )
132
+ return 200, {}
133
+ return 404, {}
134
+
135
+ with FakeSidecar(handler) as sidecar:
136
+ client = Client(sidecar.url)
137
+ ticket = client.acquire("user-1")
138
+ ticket.release()
139
+
140
+ self.assertEqual(len(release_calls), 1)
141
+ self.assertEqual(
142
+ release_calls[0]["query"],
143
+ {},
144
+ "expected /release to send nothing via the query string",
145
+ )
146
+ self.assertEqual(release_calls[0]["header_key"], "user-1")
147
+ self.assertEqual(release_calls[0]["header_token"], "tok-abc")
148
+
149
+ def test_release_is_noop_when_no_token_was_issued(self):
150
+ release_called = []
151
+
152
+ def handler(method, path, query, headers):
153
+ if path == "/release":
154
+ release_called.append(True)
155
+ return 200, {}
156
+ return 429, {}
157
+
158
+ with FakeSidecar(handler) as sidecar:
159
+ client = Client(sidecar.url)
160
+ ticket = client.acquire("user-1")
161
+ ticket.release()
162
+
163
+ self.assertEqual(release_called, [])
164
+
165
+ def test_release_raises_when_a_reservation_fails_to_release(self):
166
+ def handler(method, path, query, headers):
167
+ if path == "/check":
168
+ return 200, {
169
+ "Concurrency-Token-0": "tok-abc",
170
+ "Concurrency-Key-0": "user-1",
171
+ }
172
+ if path == "/release":
173
+ return 500, {}
174
+ return 404, {}
175
+
176
+ with FakeSidecar(handler) as sidecar:
177
+ client = Client(sidecar.url)
178
+ ticket = client.acquire("user-1")
179
+ with self.assertRaises(RuntimeError):
180
+ ticket.release()
181
+
182
+ def test_context_manager_auto_releases(self):
183
+ release_calls = []
184
+
185
+ def handler(method, path, query, headers):
186
+ if path == "/check":
187
+ return 200, {
188
+ "Concurrency-Token-0": "tok-abc",
189
+ "Concurrency-Key-0": "user-1",
190
+ }
191
+ if path == "/release":
192
+ release_calls.append(dict(query))
193
+ return 200, {}
194
+ return 404, {}
195
+
196
+ with FakeSidecar(handler) as sidecar:
197
+ client = Client(sidecar.url)
198
+ with client.acquire("user-1") as ticket:
199
+ self.assertTrue(ticket.allowed)
200
+
201
+ self.assertEqual(len(release_calls), 1)
202
+
203
+
204
+ class TestCostAndPriority(unittest.TestCase):
205
+ def test_allow_sends_cost_query_param_when_given(self):
206
+ captured = {}
207
+
208
+ def handler(method, path, query, headers):
209
+ captured.update(query)
210
+ return 200, {}
211
+
212
+ with FakeSidecar(handler) as sidecar:
213
+ client = Client(sidecar.url)
214
+ client.allow("user-1", cost=5)
215
+ self.assertEqual(captured.get("cost"), "5")
216
+
217
+ def test_allow_omits_cost_query_param_by_default(self):
218
+ captured = {}
219
+
220
+ def handler(method, path, query, headers):
221
+ captured.update(query)
222
+ return 200, {}
223
+
224
+ with FakeSidecar(handler) as sidecar:
225
+ client = Client(sidecar.url)
226
+ client.allow("user-1")
227
+ self.assertNotIn("cost", captured)
228
+
229
+ def test_allow_sends_priority_header_when_given(self):
230
+ captured = {}
231
+
232
+ def handler(method, path, query, headers):
233
+ captured.update(headers)
234
+ return 200, {}
235
+
236
+ with FakeSidecar(handler) as sidecar:
237
+ client = Client(sidecar.url)
238
+ client.allow("user-1", priority="critical")
239
+ # Python's http.server.HTTPServer normalizes header names via
240
+ # urllib's AbstractHTTPHandler.do_open (str.title() at every
241
+ # hyphen boundary) regardless of the casing passed to Request(),
242
+ # matching this file's own established convention of asserting
243
+ # "X-Ratecap-Concurrency-Key" rather than "X-RateCap-...".
244
+ self.assertEqual(captured.get("X-Ratecap-Priority"), "critical")
245
+
246
+ def test_acquire_sends_cost_and_priority(self):
247
+ captured_query = {}
248
+ captured_headers = {}
249
+
250
+ def handler(method, path, query, headers):
251
+ if path == "/check":
252
+ captured_query.update(query)
253
+ captured_headers.update(headers)
254
+ return 200, {}
255
+
256
+ with FakeSidecar(handler) as sidecar:
257
+ client = Client(sidecar.url)
258
+ client.acquire("user-1", cost=1500, priority="critical")
259
+ self.assertEqual(captured_query.get("cost"), "1500")
260
+ self.assertEqual(captured_headers.get("X-Ratecap-Priority"), "critical")
261
+
262
+
263
+ class TestRefund(unittest.TestCase):
264
+ def test_refund_sends_refund_headers(self):
265
+ refund_calls = []
266
+
267
+ def handler(method, path, query, headers):
268
+ if path == "/check":
269
+ return 200, {}
270
+ if path == "/release":
271
+ refund_calls.append(dict(headers))
272
+ return 200, {}
273
+ return 404, {}
274
+
275
+ with FakeSidecar(handler) as sidecar:
276
+ client = Client(sidecar.url)
277
+ ticket = client.acquire("user-1", cost=1500)
278
+ ticket.refund(1200)
279
+
280
+ self.assertEqual(len(refund_calls), 1)
281
+ self.assertEqual(refund_calls[0].get("X-Ratecap-Refund-Key"), "user-1")
282
+ self.assertEqual(refund_calls[0].get("X-Ratecap-Refund-Amount"), "1200")
283
+
284
+ def test_refund_raises_on_non_200(self):
285
+ def handler(method, path, query, headers):
286
+ if path == "/check":
287
+ return 200, {}
288
+ if path == "/release":
289
+ return 500, {}
290
+ return 404, {}
291
+
292
+ with FakeSidecar(handler) as sidecar:
293
+ client = Client(sidecar.url)
294
+ ticket = client.acquire("user-1", cost=1500)
295
+ with self.assertRaises(RuntimeError):
296
+ ticket.refund(1200)
297
+
298
+
299
+ class TestTimeout(unittest.TestCase):
300
+ def test_default_timeout_is_applied(self):
301
+ def handler(method, path, query, headers):
302
+ return 200, {}
303
+
304
+ with FakeSidecar(handler) as sidecar:
305
+ client = Client(sidecar.url)
306
+ # No direct way to observe urllib's internal timeout without a
307
+ # slow server; assert the attribute is set to a sane positive
308
+ # default instead, and that a custom value overrides it.
309
+ self.assertGreater(client._timeout, 0)
310
+
311
+ def test_custom_timeout_is_stored(self):
312
+ client = Client("http://localhost:8080", timeout=2.5)
313
+ self.assertEqual(client._timeout, 2.5)
314
+
315
+
316
+ class TestRetry(unittest.TestCase):
317
+ def test_retries_on_connection_error_up_to_max_retries(self):
318
+ attempts = []
319
+
320
+ def handler(method, path, query, headers):
321
+ attempts.append(1)
322
+ if len(attempts) < 3:
323
+ raise ConnectionResetError("simulated transient failure")
324
+ return 200, {}
325
+
326
+ with FakeSidecar(handler) as sidecar:
327
+ client = Client(sidecar.url, max_retries=3, backoff_base=0.01)
328
+ result = client.allow("user-1")
329
+ self.assertTrue(result.allowed)
330
+ self.assertEqual(len(attempts), 3)
331
+
332
+ def test_no_retry_by_default(self):
333
+ attempts = []
334
+
335
+ def handler(method, path, query, headers):
336
+ attempts.append(1)
337
+ raise ConnectionResetError("simulated failure")
338
+
339
+ with FakeSidecar(handler) as sidecar:
340
+ client = Client(sidecar.url)
341
+ with self.assertRaises(Exception):
342
+ client.allow("user-1")
343
+ self.assertEqual(len(attempts), 1)
344
+
345
+ def test_gives_up_after_max_retries_exhausted(self):
346
+ attempts = []
347
+
348
+ def handler(method, path, query, headers):
349
+ attempts.append(1)
350
+ raise ConnectionResetError("simulated permanent failure")
351
+
352
+ with FakeSidecar(handler) as sidecar:
353
+ client = Client(sidecar.url, max_retries=2, backoff_base=0.01)
354
+ with self.assertRaises(Exception):
355
+ client.allow("user-1")
356
+ self.assertEqual(len(attempts), 3) # 1 initial + 2 retries
357
+
358
+
359
+ class TestTLS(unittest.TestCase):
360
+ def test_ca_file_none_uses_default_context(self):
361
+ client = Client("https://localhost:8443")
362
+ self.assertIsNone(client._ssl_context)
363
+
364
+ def test_ca_file_builds_custom_ssl_context(self):
365
+ import ssl
366
+ import tempfile
367
+
368
+ with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f:
369
+ # A syntactically-plausible-but-fake CA file is enough to prove
370
+ # the client attempts to build a context from it; a real TLS
371
+ # handshake against it is exercised by the sidecar/core's own
372
+ # existing mTLS integration tests, not this SDK's unit suite.
373
+ f.write(b"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n")
374
+ ca_path = f.name
375
+
376
+ try:
377
+ with self.assertRaises(ssl.SSLError):
378
+ Client("https://localhost:8443", ca_file=ca_path)
379
+ finally:
380
+ import os
381
+
382
+ os.unlink(ca_path)
383
+
384
+
385
+ if __name__ == "__main__":
386
+ unittest.main()
@@ -0,0 +1,26 @@
1
+ import os
2
+ import sys
3
+ import unittest
4
+
5
+ _SRC_DIR = os.path.join(
6
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
7
+ )
8
+ if _SRC_DIR not in sys.path:
9
+ sys.path.insert(0, _SRC_DIR)
10
+
11
+ from ratecap import estimate_llm_cost
12
+
13
+
14
+ class TestEstimateLLMCost(unittest.TestCase):
15
+ def test_sums_input_and_max_tokens(self):
16
+ self.assertEqual(estimate_llm_cost(500, 1000), 1500)
17
+
18
+ def test_zero_input_tokens_still_counts_max_tokens(self):
19
+ self.assertEqual(estimate_llm_cost(0, 1000), 1000)
20
+
21
+ def test_negative_inputs_clamp_to_zero(self):
22
+ self.assertEqual(estimate_llm_cost(-10, -5), 0)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ unittest.main()