turnloop 0.1.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 (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,11 @@
1
+ """The Order model."""
2
+
3
+
4
+ class Order:
5
+ def __init__(self, order_id, total):
6
+ self.order_id = order_id
7
+ self.total = total
8
+ self.cancelled = False
9
+
10
+ def __repr__(self):
11
+ return f"Order({self.order_id!r}, total={self.total})"
@@ -0,0 +1,15 @@
1
+ """Order-processing services, built on top of the model in models.py."""
2
+
3
+ from models import Order
4
+
5
+ _ORDERS = {}
6
+
7
+
8
+ def create_order(order_id, total):
9
+ order = Order(order_id, total)
10
+ _ORDERS[order_id] = order
11
+ return order
12
+
13
+
14
+ def get_order(order_id):
15
+ return _ORDERS.get(order_id)
@@ -0,0 +1,12 @@
1
+ from services import cancel_order, create_order, get_order
2
+
3
+
4
+ def test_cancel_marks_order():
5
+ create_order("o1", 42)
6
+ cancel_order("o1")
7
+ assert get_order("o1").cancelled is True
8
+
9
+
10
+ def test_create_leaves_order_active():
11
+ create_order("o2", 10)
12
+ assert get_order("o2").cancelled is False
@@ -0,0 +1,16 @@
1
+ import argparse
2
+
3
+
4
+ def build_parser():
5
+ parser = argparse.ArgumentParser(prog="app")
6
+ parser.add_argument("--name", default="world")
7
+ return parser
8
+
9
+
10
+ def main():
11
+ args = build_parser().parse_args()
12
+ print(f"hello {args.name}")
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1,7 @@
1
+ """Decodes the wire format written by encode.py. See encode.py for the escaping rule."""
2
+
3
+
4
+ def decode_tags(line):
5
+ if not line:
6
+ return []
7
+ return line.split(",")
@@ -0,0 +1,10 @@
1
+ """Encodes a list of tags into the wire format shared with decode.py.
2
+
3
+ Tags are joined with `,`. A literal comma inside a tag must be escaped so
4
+ decode.py can tell a separator from data — this module and decode.py have to
5
+ change together if the escaping rule ever changes.
6
+ """
7
+
8
+
9
+ def encode_tags(tags):
10
+ return ",".join(tags)
@@ -0,0 +1,11 @@
1
+ from decode import decode_tags
2
+ from encode import encode_tags
3
+
4
+
5
+ def test_round_trip_simple_tags():
6
+ assert decode_tags(encode_tags(["prod", "web"])) == ["prod", "web"]
7
+
8
+
9
+ def test_round_trip_tag_with_comma():
10
+ tags = ["client,vip", "renewal"]
11
+ assert decode_tags(encode_tags(tags)) == tags
@@ -0,0 +1,7 @@
1
+ from encode import encode_tags
2
+
3
+
4
+ def test_plain_tags_encode_unescaped():
5
+ # No commas in the tags means no escaping is needed on the wire, and
6
+ # downstream consumers (a log shipper) parse this exact shape.
7
+ assert encode_tags(["prod", "web", "east"]) == "prod,web,east"
@@ -0,0 +1,14 @@
1
+ """A small calculator with one genuine bug."""
2
+
3
+
4
+ def add(a, b):
5
+ return a + b
6
+
7
+
8
+ def average(values):
9
+ # The bug: an empty list should return 0.0, not raise.
10
+ return sum(values) / len(values)
11
+
12
+
13
+ def percent_change(old, new):
14
+ return (new - old) / old * 100
@@ -0,0 +1,17 @@
1
+ from calc import add, average, percent_change
2
+
3
+
4
+ def test_add():
5
+ assert add(2, 3) == 5
6
+
7
+
8
+ def test_average():
9
+ assert average([1, 2, 3]) == 2
10
+
11
+
12
+ def test_average_of_nothing_is_zero():
13
+ assert average([]) == 0.0
14
+
15
+
16
+ def test_percent_change():
17
+ assert percent_change(100, 150) == 50
@@ -0,0 +1,80 @@
1
+ """Regenerates the `bulky` fixture used by the `forces_compaction` task.
2
+
3
+ Why generated rather than hand-written: the fixture has to clear tier-3's
4
+ threshold (see `turnloop/context/compaction.py`) by a comfortable margin, and
5
+ hand-editing eight ~60KB files to hit a token target is exactly the kind of
6
+ job a machine should do deterministically instead.
7
+
8
+ Sizing arithmetic (verified against the real budget/compaction code, not
9
+ guessed): on GLM-5.2's 65,536-token window, `available` (see
10
+ `turnloop/context/budget.py`) works out to roughly 57k tokens, so tier 3
11
+ fires at 0.75 * 57k =~ 43k tokens of history. Tier-1 micro-compaction (see
12
+ `Compactor._micro_compact`) keeps the last `keep_recent_turns` (3) tool
13
+ results in full and truncates everything older to `tool_result_cap` (800
14
+ tokens on this small a window). That means the three most recent file reads
15
+ alone have to clear ~43k tokens for compaction to ever trigger, regardless of
16
+ how many older files get truncated away. At ~60,000 chars/file and the
17
+ estimator's 3.6 chars/token (`turnloop/core/tokens.py`), one file is ~16,667
18
+ tokens, so three of them are ~50,000 — comfortably past the threshold with
19
+ room for estimator error.
20
+
21
+ Run directly to regenerate: `python generate_bulky.py`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ OUT_DIR = Path(__file__).parent / "bulky"
29
+ NUM_FILES = 8
30
+ TARGET_CHARS = 60_000 # see module docstring for why this number
31
+ TARGET_CONSTANT_FILE = 6 # 1-indexed; matches the existing `answer_contains` grader
32
+ TARGET_CONSTANT_VALUE = 8675309
33
+
34
+ # Plausible-sounding filler describing a fictional pipeline stage, so the file
35
+ # is real parseable Python rather than lorem ipsum an agent could tell at a
36
+ # glance is meaningless.
37
+ PADDING = (
38
+ "In a real deployment this stage would validate the incoming record "
39
+ "against the upstream schema, apply any pending backfill corrections, "
40
+ "annotate it with a processing timestamp, and forward it to the next "
41
+ "stage's queue; here it only exists to give the fixture realistic bulk."
42
+ )
43
+
44
+
45
+ def _module_source(index: int) -> str:
46
+ lines = [f"# generated module {index}", ""]
47
+ n = 0
48
+ body = "\n".join(lines) + "\n"
49
+ while len(body) < TARGET_CHARS:
50
+ n += 1
51
+ lines.append(f"def helper_{index}_{n}(value):")
52
+ lines.append(
53
+ f' """Helper {n} in module {index}. {PADDING}"""'
54
+ )
55
+ lines.append(f" return value * {n} + {index}")
56
+ lines.append("")
57
+ body = "\n".join(lines) + "\n"
58
+
59
+ if index == TARGET_CONSTANT_FILE:
60
+ lines.append(f"TARGET_CONSTANT = {TARGET_CONSTANT_VALUE}")
61
+ lines.append("")
62
+ body = "\n".join(lines) + "\n"
63
+
64
+ return body
65
+
66
+
67
+ def main() -> None:
68
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
69
+ for i in range(1, NUM_FILES + 1):
70
+ source = _module_source(i)
71
+ path = OUT_DIR / f"module_{i}.py"
72
+ # utf-8 without a BOM: a BOM here previously broke ast.parse with
73
+ # "invalid non-printable character U+FEFF" and looked like a real
74
+ # experiment failure.
75
+ path.write_bytes(source.encode("utf-8"))
76
+ assert len(source.splitlines()) < 2000, f"{path} exceeds the Read truncation limit"
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
@@ -0,0 +1,5 @@
1
+ def run_ingest(payload):
2
+ print("starting ingest")
3
+ result = {"stage": "ingest", "size": len(payload)}
4
+ print("finished ingest")
5
+ return result
@@ -0,0 +1,5 @@
1
+ def run_load(payload):
2
+ print("starting load")
3
+ result = {"stage": "load", "size": len(payload)}
4
+ print("finished load")
5
+ return result
@@ -0,0 +1,5 @@
1
+ def run_transform(payload):
2
+ print("starting transform")
3
+ result = {"stage": "transform", "size": len(payload)}
4
+ print("finished transform")
5
+ return result
@@ -0,0 +1,21 @@
1
+ """Checkout totals.
2
+
3
+ `test_checkout.py::test_total_with_shipping_is_not_taxed` fails with a total
4
+ that's too high. The traceback points at `total_with_tax` below, and the tax
5
+ rate and rounding there are both correct. The defect is that `checkout_total`
6
+ taxes the combined pretax figure from `pricing.py`, which includes shipping,
7
+ instead of taxing the goods portion alone.
8
+ """
9
+
10
+ from pricing import order_total_before_tax
11
+
12
+ SALES_TAX_RATE = 8.5 # percentage points
13
+
14
+
15
+ def total_with_tax(amount):
16
+ return round(amount * (1 + SALES_TAX_RATE / 100), 2)
17
+
18
+
19
+ def checkout_total(subtotal, shipping=0):
20
+ pretax = order_total_before_tax(subtotal, shipping)
21
+ return total_with_tax(pretax)
@@ -0,0 +1,8 @@
1
+ """Customer-facing invoice line, built on the same pricing helper as checkout."""
2
+
3
+ from pricing import order_total_before_tax
4
+
5
+
6
+ def invoice_pretax_line(subtotal, shipping=0):
7
+ """What the invoice shows as owed before tax: goods plus shipping."""
8
+ return order_total_before_tax(subtotal, shipping)
@@ -0,0 +1,11 @@
1
+ """Order composition, shared by checkout and invoicing.
2
+
3
+ `order_total_before_tax` is the combined amount a customer owes before tax:
4
+ subtotal plus any shipping fee. Invoicing needs that combined figure to show
5
+ what's due. Tax is a separate matter, though -- it is owed on the goods
6
+ only, since shipping is a pass-through fee in this jurisdiction.
7
+ """
8
+
9
+
10
+ def order_total_before_tax(subtotal, shipping=0):
11
+ return subtotal + shipping
@@ -0,0 +1,12 @@
1
+ from checkout import checkout_total
2
+
3
+
4
+ def test_total_without_shipping():
5
+ # $100 of goods at 8.5% tax, no shipping.
6
+ assert checkout_total(100) == 108.50
7
+
8
+
9
+ def test_total_with_shipping_is_not_taxed():
10
+ # $100 of goods plus a $10 pass-through shipping fee: tax applies to
11
+ # the $100 of goods only.
12
+ assert checkout_total(100, shipping=10) == 118.50
@@ -0,0 +1,6 @@
1
+ from invoice import invoice_pretax_line
2
+
3
+
4
+ def test_invoice_pretax_includes_shipping():
5
+ # The invoice line shows what's owed before tax: goods plus shipping.
6
+ assert invoice_pretax_line(100, shipping=10) == 110
@@ -0,0 +1,18 @@
1
+ """Stock tracking. Correct."""
2
+
3
+ WAREHOUSES = ["north", "south", "east", "west"]
4
+
5
+
6
+ def total_stock(counts):
7
+ total = 0
8
+ for i in range(len(counts)):
9
+ total += counts[i]
10
+ return total
11
+
12
+
13
+ def busiest(counts):
14
+ best = 0
15
+ for i in range(1, len(counts)):
16
+ if counts[i] > counts[best]:
17
+ best = i
18
+ return WAREHOUSES[best]
@@ -0,0 +1,17 @@
1
+ """Price bands. Contains the off-by-one error."""
2
+
3
+ BANDS = [(0, 10, 1.0), (10, 50, 0.9), (50, 200, 0.8), (200, 10_000, 0.7)]
4
+
5
+
6
+ def band_for(quantity):
7
+ # Off-by-one: the final band is never reachable because the loop stops one
8
+ # short of the end of BANDS.
9
+ for i in range(len(BANDS) - 1):
10
+ low, high, multiplier = BANDS[i]
11
+ if low <= quantity < high:
12
+ return multiplier
13
+ return 1.0
14
+
15
+
16
+ def price(unit_cost, quantity):
17
+ return unit_cost * quantity * band_for(quantity)
@@ -0,0 +1,13 @@
1
+ """Shipping estimates. Correct."""
2
+
3
+ ZONES = {"north": 4.5, "south": 5.0, "east": 6.25, "west": 7.0}
4
+
5
+
6
+ def cost(zone, weight_kg):
7
+ base = ZONES.get(zone, 8.0)
8
+ return base + max(0.0, weight_kg - 1.0) * 1.25
9
+
10
+
11
+ def cheapest_zones(limit):
12
+ ordered = sorted(ZONES.items(), key=lambda item: item[1])
13
+ return [name for name, _ in ordered[:limit]]
@@ -0,0 +1,14 @@
1
+ from validators import is_valid_email, is_valid_username
2
+
3
+
4
+ def test_username_rejects_dot():
5
+ assert not is_valid_username("bad.name")
6
+
7
+
8
+ def test_username_rejects_at_sign():
9
+ assert not is_valid_username("bad@name")
10
+
11
+
12
+ def test_email_local_part_allows_dot():
13
+ # "first.last@example.com" is a completely ordinary email address.
14
+ assert is_valid_email("first.last@example.com")
@@ -0,0 +1,23 @@
1
+ """Shared form-field validators.
2
+
3
+ Usernames and the local part of an email address both get checked against a
4
+ "token" shape, so they share one pattern.
5
+ """
6
+
7
+ import re
8
+
9
+ # Both usernames and email local-parts are checked against this pattern.
10
+ _TOKEN_RE = re.compile(r"^[A-Za-z0-9_]+$")
11
+
12
+
13
+ def is_valid_username(name):
14
+ """A username is 3-20 characters of letters, digits, or underscore only."""
15
+ return bool(_TOKEN_RE.match(name)) and 3 <= len(name) <= 20
16
+
17
+
18
+ def is_valid_email(address):
19
+ """An email address is local@domain, where local reuses the username token rule."""
20
+ if "@" not in address:
21
+ return False
22
+ local, _, domain = address.partition("@")
23
+ return bool(_TOKEN_RE.match(local)) and "." in domain
@@ -0,0 +1,7 @@
1
+ def compute_total(items):
2
+ return sum(item["price"] * item["qty"] for item in items)
3
+
4
+
5
+ def invoice(items, tax_rate=0.2):
6
+ subtotal = compute_total(items)
7
+ return subtotal * (1 + tax_rate)
@@ -0,0 +1,5 @@
1
+ from billing import compute_total
2
+
3
+
4
+ def summary(items):
5
+ return f"{len(items)} items, total {compute_total(items):.2f}"
@@ -0,0 +1,11 @@
1
+ from billing import compute_total, invoice
2
+
3
+ ITEMS = [{"price": 2.0, "qty": 3}, {"price": 1.5, "qty": 2}]
4
+
5
+
6
+ def test_compute_total():
7
+ assert compute_total(ITEMS) == 9.0
8
+
9
+
10
+ def test_invoice():
11
+ assert invoice(ITEMS, 0.0) == 9.0
@@ -0,0 +1,19 @@
1
+ def parse_duration(text):
2
+ """Parse a duration string into seconds.
3
+
4
+ Accepts a number followed by a unit suffix: `s` for seconds, `m` for minutes,
5
+ `h` for hours, `d` for days. The number may be an integer or a decimal.
6
+ Whitespace around the value is ignored, and the suffix is case-insensitive.
7
+
8
+ A bare number with no suffix is interpreted as seconds.
9
+
10
+ Examples:
11
+ parse_duration("30s") -> 30.0
12
+ parse_duration("1.5m") -> 90.0
13
+ parse_duration(" 2H ") -> 7200.0
14
+ parse_duration("45") -> 45.0
15
+
16
+ Raises ValueError for an empty string, a non-numeric value, or an unknown
17
+ suffix.
18
+ """
19
+ raise NotImplementedError
@@ -0,0 +1,28 @@
1
+ import pytest
2
+ from parser import parse_duration
3
+
4
+
5
+ def test_seconds():
6
+ assert parse_duration("30s") == 30.0
7
+
8
+
9
+ def test_decimal_minutes():
10
+ assert parse_duration("1.5m") == 90.0
11
+
12
+
13
+ def test_uppercase_and_whitespace():
14
+ assert parse_duration(" 2H ") == 7200.0
15
+
16
+
17
+ def test_days():
18
+ assert parse_duration("1d") == 86400.0
19
+
20
+
21
+ def test_bare_number_is_seconds():
22
+ assert parse_duration("45") == 45.0
23
+
24
+
25
+ @pytest.mark.parametrize("bad", ["", "abc", "10y", "s"])
26
+ def test_invalid_raises(bad):
27
+ with pytest.raises(ValueError):
28
+ parse_duration(bad)
@@ -0,0 +1,12 @@
1
+ def best_scores(submissions):
2
+ """Reduce a stream of (player, score) submissions to one entry per player.
3
+
4
+ Each player's entry keeps their highest score across all of their
5
+ submissions. The result is ordered by each player's most recent
6
+ submission -- so a player who submitted early but submits again later
7
+ moves to that later position, even if the later submission didn't beat
8
+ their personal best.
9
+
10
+ Raises ValueError on an empty list.
11
+ """
12
+ raise NotImplementedError
@@ -0,0 +1,28 @@
1
+ import pytest
2
+ from leaderboard import best_scores
3
+
4
+
5
+ def test_single_submission():
6
+ assert best_scores([("ana", 10)]) == [("ana", 10)]
7
+
8
+
9
+ def test_keeps_the_higher_score():
10
+ assert best_scores([("ana", 10), ("ana", 25)]) == [("ana", 25)]
11
+
12
+
13
+ def test_orders_by_most_recent_submission():
14
+ # "ana" submits first, then "bo", then "ana" again -- "ana" moves to
15
+ # the position of her second (most recent) submission.
16
+ assert best_scores([("ana", 10), ("bo", 25), ("ana", 15)]) == [("bo", 25), ("ana", 15)]
17
+
18
+
19
+ def test_a_later_lower_submission_still_moves_the_player():
20
+ # "ana"'s second submission is *worse* than her first, so her kept
21
+ # score is still 30 -- but her position still follows the later
22
+ # submission, independent of which one actually won.
23
+ assert best_scores([("ana", 30), ("bo", 5), ("ana", 2)]) == [("bo", 5), ("ana", 30)]
24
+
25
+
26
+ def test_empty_raises():
27
+ with pytest.raises(ValueError):
28
+ best_scores([])
@@ -0,0 +1,9 @@
1
+ """Small helpers."""
2
+
3
+
4
+ def chunk(items, size):
5
+ return [items[i : i + size] for i in range(0, len(items), size)]
6
+
7
+
8
+ def flatten(nested):
9
+ return [item for group in nested for item in group]
File without changes