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.
@@ -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,12 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ __marimo__/
12
+ .coverage
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: fizzbuzz-kata
3
+ Version: 0.1.0
4
+ Summary: A TDD kata: FizzBuzz, from notebook exploration to a tested, published package
5
+ Author-email: Your Name <you@example.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
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,7 @@
1
+ """FizzBuzz kata package."""
2
+
3
+ from .core import fizzbuzz
4
+
5
+ __all__ = ["fizzbuzz"]
6
+
7
+ __version__ = "0.1.0"
@@ -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)