fizzbuzz-kata 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.
- fizzbuzz_kata-0.1.0/.github/workflows/publish.yml +27 -0
- fizzbuzz_kata-0.1.0/.github/workflows/tests.yml +31 -0
- fizzbuzz_kata-0.1.0/.gitignore +12 -0
- fizzbuzz_kata-0.1.0/.python-version +1 -0
- fizzbuzz_kata-0.1.0/PKG-INFO +7 -0
- fizzbuzz_kata-0.1.0/README.md +0 -0
- fizzbuzz_kata-0.1.0/notebooks/tdd_fizzbuzz_marimo_en.py +289 -0
- fizzbuzz_kata-0.1.0/pyproject.toml +30 -0
- fizzbuzz_kata-0.1.0/src/fizzbuzz_kata/__init__.py +7 -0
- fizzbuzz_kata-0.1.0/src/fizzbuzz_kata/core.py +20 -0
- fizzbuzz_kata-0.1.0/tests/test_core.py +27 -0
- fizzbuzz_kata-0.1.0/uv.lock +1034 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Publish package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write # required for PyPI Trusted Publishing
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
uses: ./.github/workflows/tests.yml # re-run the full test workflow
|
|
13
|
+
|
|
14
|
+
build-and-publish:
|
|
15
|
+
needs: test # only runs if tests succeeded
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v5
|
|
22
|
+
|
|
23
|
+
- name: Build package
|
|
24
|
+
run: uv build
|
|
25
|
+
|
|
26
|
+
- name: Publish to PyPI
|
|
27
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_call:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: uv sync --all-groups
|
|
26
|
+
|
|
27
|
+
- name: Lint
|
|
28
|
+
run: uv run ruff check .
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: uv run pytest --cov=src --cov-report=term-missing
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
File without changes
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.12"
|
|
3
|
+
# dependencies = [
|
|
4
|
+
# "marimo>=0.24.2",
|
|
5
|
+
# "pytest==9.1.1",
|
|
6
|
+
# ]
|
|
7
|
+
# ///
|
|
8
|
+
|
|
9
|
+
import marimo
|
|
10
|
+
|
|
11
|
+
__generated_with = "0.24.2"
|
|
12
|
+
app = marimo.App(width="medium")
|
|
13
|
+
|
|
14
|
+
with app.setup:
|
|
15
|
+
import marimo as mo
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.cell
|
|
20
|
+
def _():
|
|
21
|
+
mo.md(r"""
|
|
22
|
+
# TDD Demo: FizzBuzz with marimo
|
|
23
|
+
|
|
24
|
+
This notebook illustrates the **Red → Green → Refactor** cycle of
|
|
25
|
+
Test-Driven Development. Each section below corresponds to a
|
|
26
|
+
development step where we **grow the contract** of the
|
|
27
|
+
`fizzbuzz` function one test at a time.
|
|
28
|
+
|
|
29
|
+
You can run the tests in two ways:
|
|
30
|
+
|
|
31
|
+
1. **Inside marimo**: cells whose name starts with `test_`
|
|
32
|
+
are automatically detected and run by pytest.
|
|
33
|
+
2. **From the command line**:
|
|
34
|
+
```bash
|
|
35
|
+
uv run pytest tdd_fizzbuzz_marimo_en.py
|
|
36
|
+
```
|
|
37
|
+
""")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.cell
|
|
42
|
+
def _():
|
|
43
|
+
mo.md(r"""
|
|
44
|
+
## Step 1 — Red: first test, minimal contract
|
|
45
|
+
|
|
46
|
+
Before writing a single line of `fizzbuzz`, we write the simplest
|
|
47
|
+
possible test: for input `1`, we expect the output `"1"`.
|
|
48
|
+
This test would fail if the function didn't exist yet
|
|
49
|
+
(`NameError`) — that's the **Red** phase.
|
|
50
|
+
|
|
51
|
+
We then write the **minimal** code that makes this test pass:
|
|
52
|
+
simply return `str(n)`.
|
|
53
|
+
""")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.function
|
|
58
|
+
def fizzbuzz_v1(n):
|
|
59
|
+
return str(n)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.cell
|
|
63
|
+
def test_v1_returns_number_as_string():
|
|
64
|
+
assert fizzbuzz_v1(1) == "1"
|
|
65
|
+
assert fizzbuzz_v1(2) == "2"
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.cell
|
|
70
|
+
def _():
|
|
71
|
+
mo.md(r"""
|
|
72
|
+
## Step 2 — Grow the contract: multiples of 3
|
|
73
|
+
|
|
74
|
+
We add a rule to the contract: *"if `n` is a multiple of 3,
|
|
75
|
+
return `'Fizz'`"*. We first write the test (Red), then
|
|
76
|
+
change the implementation to make it pass (Green), **without
|
|
77
|
+
breaking the previous test** (regression).
|
|
78
|
+
""")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.function
|
|
83
|
+
def fizzbuzz_v2(n):
|
|
84
|
+
if n % 3 == 0:
|
|
85
|
+
return "Fizz"
|
|
86
|
+
return str(n)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.cell
|
|
90
|
+
def test_v2_multiples_of_three_return_fizz():
|
|
91
|
+
assert fizzbuzz_v2(3) == "Fizz"
|
|
92
|
+
assert fizzbuzz_v2(6) == "Fizz"
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.cell
|
|
97
|
+
def test_v2_non_multiples_still_return_number():
|
|
98
|
+
# Regression test inherited from step 1
|
|
99
|
+
assert fizzbuzz_v2(1) == "1"
|
|
100
|
+
assert fizzbuzz_v2(2) == "2"
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.cell
|
|
105
|
+
def _():
|
|
106
|
+
mo.md(r"""
|
|
107
|
+
## Step 3 — New rule: multiples of 5
|
|
108
|
+
|
|
109
|
+
Same approach: a new test describes the expected behavior for
|
|
110
|
+
multiples of 5, then we adapt the implementation.
|
|
111
|
+
""")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.function
|
|
116
|
+
def fizzbuzz_v3(n):
|
|
117
|
+
if n % 3 == 0:
|
|
118
|
+
return "Fizz"
|
|
119
|
+
if n % 5 == 0:
|
|
120
|
+
return "Buzz"
|
|
121
|
+
return str(n)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.cell
|
|
125
|
+
def test_v3_multiples_of_five_return_buzz():
|
|
126
|
+
assert fizzbuzz_v3(5) == "Buzz"
|
|
127
|
+
assert fizzbuzz_v3(10) == "Buzz"
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.cell
|
|
132
|
+
def test_v3_multiples_of_three_still_return_fizz():
|
|
133
|
+
assert fizzbuzz_v3(3) == "Fizz"
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.cell
|
|
138
|
+
def _():
|
|
139
|
+
mo.md(r"""
|
|
140
|
+
## Step 4 — The trap case: multiples of 15
|
|
141
|
+
|
|
142
|
+
This is the most pedagogically interesting test: it reveals a
|
|
143
|
+
**bug** in the current implementation. With `fizzbuzz_v3`, the
|
|
144
|
+
input `15` would return `"Fizz"` (the first matching `if`), while
|
|
145
|
+
the expected contract is `"FizzBuzz"`.
|
|
146
|
+
|
|
147
|
+
This test therefore fails first (**Red**), which forces us to
|
|
148
|
+
revisit the order of conditions or the logic (**Green**).
|
|
149
|
+
""")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.function
|
|
154
|
+
def fizzbuzz_v4(n):
|
|
155
|
+
if n % 15 == 0:
|
|
156
|
+
return "FizzBuzz"
|
|
157
|
+
if n % 3 == 0:
|
|
158
|
+
return "Fizz"
|
|
159
|
+
if n % 5 == 0:
|
|
160
|
+
return "Buzz"
|
|
161
|
+
return str(n)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.cell
|
|
165
|
+
def test_v4_multiples_of_fifteen_return_fizzbuzz():
|
|
166
|
+
assert fizzbuzz_v4(15) == "FizzBuzz"
|
|
167
|
+
assert fizzbuzz_v4(30) == "FizzBuzz"
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.cell
|
|
172
|
+
def test_v4_all_previous_rules_still_hold():
|
|
173
|
+
assert fizzbuzz_v4(1) == "1"
|
|
174
|
+
assert fizzbuzz_v4(3) == "Fizz"
|
|
175
|
+
assert fizzbuzz_v4(5) == "Buzz"
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.cell
|
|
180
|
+
def _():
|
|
181
|
+
mo.md(r"""
|
|
182
|
+
## Step 5 — Refactor: keep the behavior, improve the code
|
|
183
|
+
|
|
184
|
+
The tests from the previous steps now form a **safety net**.
|
|
185
|
+
We can rewrite the implementation (for example by building the
|
|
186
|
+
string through concatenation rather than a cascade of `if`
|
|
187
|
+
statements) without fear of regression, since the whole test
|
|
188
|
+
suite must keep passing.
|
|
189
|
+
|
|
190
|
+
This is the version we'd consider the final contract of the
|
|
191
|
+
function in production code.
|
|
192
|
+
""")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.function
|
|
197
|
+
def fizzbuzz(n: int) -> str:
|
|
198
|
+
"""Return the FizzBuzz representation of the integer n.
|
|
199
|
+
|
|
200
|
+
Contract:
|
|
201
|
+
- if n is a multiple of 3 AND 5: "FizzBuzz"
|
|
202
|
+
- if n is a multiple of 3 only: "Fizz"
|
|
203
|
+
- if n is a multiple of 5 only: "Buzz"
|
|
204
|
+
- otherwise: str(n)
|
|
205
|
+
|
|
206
|
+
Raises ValueError if n is not a strictly positive integer.
|
|
207
|
+
"""
|
|
208
|
+
if not isinstance(n, int) or n <= 0:
|
|
209
|
+
raise ValueError("fizzbuzz expects a strictly positive integer")
|
|
210
|
+
|
|
211
|
+
result = ""
|
|
212
|
+
if n % 3 == 0:
|
|
213
|
+
result += "Fizz"
|
|
214
|
+
if n % 5 == 0:
|
|
215
|
+
result += "Buzz"
|
|
216
|
+
return result or str(n)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.cell
|
|
220
|
+
def _():
|
|
221
|
+
mo.md(r"""
|
|
222
|
+
## Final test suite (parametrized)
|
|
223
|
+
|
|
224
|
+
Once the contract is stable, we can group all the cases into a
|
|
225
|
+
single parametrized test — more readable and easier to extend
|
|
226
|
+
than separate tests.
|
|
227
|
+
""")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.function
|
|
232
|
+
@pytest.mark.parametrize(
|
|
233
|
+
("n", "expected"),
|
|
234
|
+
[
|
|
235
|
+
(1, "1"),
|
|
236
|
+
(2, "2"),
|
|
237
|
+
(3, "Fizz"),
|
|
238
|
+
(5, "Buzz"),
|
|
239
|
+
(6, "Fizz"),
|
|
240
|
+
(10, "Buzz"),
|
|
241
|
+
(15, "FizzBuzz"),
|
|
242
|
+
(30, "FizzBuzz"),
|
|
243
|
+
(100, "Buzz"),
|
|
244
|
+
],
|
|
245
|
+
)
|
|
246
|
+
def test_cases(n, expected):
|
|
247
|
+
assert fizzbuzz(n) == expected
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.cell
|
|
251
|
+
def test_fizzbuzz_rejects_invalid_input():
|
|
252
|
+
with pytest.raises(ValueError):
|
|
253
|
+
fizzbuzz(0)
|
|
254
|
+
with pytest.raises(ValueError):
|
|
255
|
+
fizzbuzz(-5)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.cell
|
|
260
|
+
def _():
|
|
261
|
+
mo.md(r"""
|
|
262
|
+
## Explore interactively
|
|
263
|
+
|
|
264
|
+
Use the field below to call `fizzbuzz` on an integer of your
|
|
265
|
+
choice and see the result live — handy for a classroom demo.
|
|
266
|
+
""")
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.cell
|
|
271
|
+
def _():
|
|
272
|
+
n_input = mo.ui.number(start=1, stop=1000, step=1, value=15, label="n")
|
|
273
|
+
n_input
|
|
274
|
+
return (n_input,)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.cell
|
|
278
|
+
def _(n_input):
|
|
279
|
+
try:
|
|
280
|
+
result = fizzbuzz(n_input.value)
|
|
281
|
+
output = mo.md(f"`fizzbuzz({n_input.value})` → **{result}**")
|
|
282
|
+
except ValueError as e:
|
|
283
|
+
output = mo.md(f"⚠️ Error: {e}")
|
|
284
|
+
output
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
app.run()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fizzbuzz-kata"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A TDD kata: FizzBuzz, from notebook exploration to a tested, published package"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Your Name", email = "you@example.com" }]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/fizzbuzz_kata"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0",
|
|
20
|
+
"pytest-cov>=5.0",
|
|
21
|
+
"ruff>=0.6",
|
|
22
|
+
"marimo>=0.10",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
pythonpath = ["src"]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
exclude = ["notebooks"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
def fizzbuzz(n: int) -> str:
|
|
2
|
+
"""Return the FizzBuzz representation of the integer n.
|
|
3
|
+
|
|
4
|
+
Contract:
|
|
5
|
+
- if n is a multiple of 3 AND 5: "FizzBuzz"
|
|
6
|
+
- if n is a multiple of 3 only: "Fizz"
|
|
7
|
+
- if n is a multiple of 5 only: "Buzz"
|
|
8
|
+
- otherwise: str(n)
|
|
9
|
+
|
|
10
|
+
Raises ValueError if n is not a strictly positive integer.
|
|
11
|
+
"""
|
|
12
|
+
if not isinstance(n, int) or n <= 0:
|
|
13
|
+
raise ValueError("fizzbuzz expects a strictly positive integer")
|
|
14
|
+
|
|
15
|
+
result = ""
|
|
16
|
+
if n % 3 == 0:
|
|
17
|
+
result += "Fizz"
|
|
18
|
+
if n % 5 == 0:
|
|
19
|
+
result += "Buzz"
|
|
20
|
+
return result or str(n)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from fizzbuzz_kata.core import fizzbuzz
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
("n", "expected"),
|
|
8
|
+
[
|
|
9
|
+
(1, "1"),
|
|
10
|
+
(2, "2"),
|
|
11
|
+
(3, "Fizz"),
|
|
12
|
+
(5, "Buzz"),
|
|
13
|
+
(6, "Fizz"),
|
|
14
|
+
(10, "Buzz"),
|
|
15
|
+
(15, "FizzBuzz"),
|
|
16
|
+
(30, "FizzBuzz"),
|
|
17
|
+
(100, "Buzz"),
|
|
18
|
+
],
|
|
19
|
+
)
|
|
20
|
+
def test_fizzbuzz(n, expected):
|
|
21
|
+
assert fizzbuzz(n) == expected
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize("n", [0, -5])
|
|
25
|
+
def test_fizzbuzz_rejects_invalid_input(n):
|
|
26
|
+
with pytest.raises(ValueError):
|
|
27
|
+
fizzbuzz(n)
|