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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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,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,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([])
|
|
File without changes
|