Skip to content

Commit 10cc2cc

Browse files
authored
Coding Challenge: Wordcount (#599)
* Coding Challenge: Wordcount * Silence the linter * Update the title and URL slugs * Make some URL slugs shorter * Fix the headline * Update the challenge URL in the README file * Bump dependency versions * Add configuration for GitHub Codespaces * Bump dependency and container image versions * Fix a broken link in the README file * Update link labels * Fix linter check * Add VS Code settings for Code Spaces * Tweak Dev Container and VS Code settings
1 parent db8489d commit 10cc2cc

29 files changed

+2153
-0
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "wordcount",
3+
"image": "mcr.microsoft.com/devcontainers/python:3.13-bookworm",
4+
"workspaceFolder": "/workspaces/materials/wordcount",
5+
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/materials,type=bind",
6+
"postCreateCommand": {
7+
"project": "python -m pip install -r requirements.txt -e . && rm -rf src/*.egg-info/",
8+
"help": "echo 'echo -e \"💡 Run \\e[1mpytest --task\\e[0m to display instructions for the current task.\n💡 Run \\e[1mpytest\\e[0m to evaluate your solution and track your progress.\"' >> ~/.bashrc"
9+
},
10+
"customizations": {
11+
"codespaces": {
12+
"openFiles": [
13+
"src/wordcount.py"
14+
]
15+
},
16+
"vscode": {
17+
"extensions": [
18+
"ms-python.python"
19+
]
20+
}
21+
}
22+
}

wordcount/.vscode/settings.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"breadcrumbs.enabled": false,
3+
"editor.dragAndDrop": false,
4+
"editor.fontSize": 20,
5+
"editor.minimap.enabled": false,
6+
"editor.mouseWheelZoom": true,
7+
"editor.renderWhitespace": "all",
8+
"files.exclude": {
9+
"**/.*": true,
10+
"**/__pycache__": true
11+
},
12+
"git.detectSubmodules": false,
13+
"git.openRepositoryInParentFolders": "never",
14+
"markdown.preview.fontSize": 20,
15+
"python.testing.pytestArgs": [
16+
"tests"
17+
],
18+
"python.testing.pytestEnabled": true,
19+
"terminal.integrated.fontSize": 20,
20+
"terminal.integrated.mouseWheelZoom": true,
21+
"window.autoDetectColorScheme": true,
22+
"window.commandCenter": false,
23+
"workbench.editorAssociations": {
24+
"*.md": "vscode.markdown.preview.editor"
25+
},
26+
"workbench.layoutControl.enabled": false,
27+
"workbench.preferredDarkColorTheme": "GitHub Dark",
28+
"workbench.preferredLightColorTheme": "GitHub Light"
29+
}

wordcount/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Python Project: Build a Word Count Command-Line App
2+
3+
This folder contains supporting materials for the [wordcount coding challenge](https://realpython.com/courses/word-count-app-project/) on Real Python.
4+
5+
## How to Get Started?
6+
7+
### Cloud Environment
8+
9+
If you'd like to solve this challenge with a minimal setup required, then click the button below to launch a pre-configured environment in the cloud:
10+
11+
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/realpython/materials?quickstart=1&devcontainer_path=.devcontainer%2Fwordcount%2Fdevcontainer.json)
12+
13+
Alternatively, follow the steps below to set up the environment on your local machine.
14+
15+
### Local Computer
16+
17+
Use the [downloader tool](https://realpython.github.io/gh-download/?url=https%3A%2F%2Fgithub.com%2Frealpython%2Fmaterials%2Ftree%2Fmaster%2Fwordcount) to get the project files or clone the entire [`realpython/materials`](https://github.com/realpython/materials) repository from GitHub and change your directory to `materials/wordcount/`:
18+
19+
```sh
20+
$ git clone https://github.com/realpython/materials.git
21+
$ cd materials/wordcount/
22+
```
23+
24+
Create and activate a [virtual environment](https://realpython.com/python-virtual-environments-a-primer/), and then install the project in [editable mode](https://setuptools.pypa.io/en/latest/userguide/development_mode.html):
25+
26+
```sh
27+
$ python -m venv venv/
28+
$ source venv/bin/activate
29+
(venv) $ python -m pip install -r requirements.txt -e .
30+
```
31+
32+
Make sure to include the period at the end of the command!
33+
34+
## How to Get Feedback?
35+
36+
To display instructions for your current task:
37+
38+
```sh
39+
(venv) $ pytest --task
40+
```
41+
42+
To track your progress and reveal the acceptance criteria:
43+
44+
```sh
45+
(venv) $ pytest
46+
```

wordcount/pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "wordcount"
7+
version = "1.0.0"
8+
readme = "README.md"
9+
dependencies = [
10+
"pytest",
11+
"pytest-timeout",
12+
"rich",
13+
]
14+
15+
[project.scripts]
16+
wordcount = "wordcount:main"
17+
18+
[tool.black]
19+
line-length = 79

wordcount/requirements.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
iniconfig==2.1.0
2+
markdown-it-py==3.0.0
3+
mdurl==0.1.2
4+
packaging==25.0
5+
pluggy==1.6.0
6+
Pygments==2.19.1
7+
pytest==8.3.5
8+
pytest-timeout==2.4.0
9+
rich==14.0.0

wordcount/src/wordcount.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Uncomment the main() function below to solve your first task:
2+
# def main():
3+
# pass

wordcount/tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from fixtures import * # noqa
2+
3+
pytest_plugins = ["realpython"]

wordcount/tests/fixtures.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import random
2+
import string
3+
from dataclasses import dataclass
4+
from functools import cached_property
5+
from pathlib import Path
6+
from string import ascii_lowercase
7+
from subprocess import run
8+
from tempfile import TemporaryDirectory, gettempdir
9+
from typing import Callable
10+
11+
import pytest
12+
13+
14+
@dataclass
15+
class FakeFile:
16+
content: bytes
17+
counts: tuple[int, ...]
18+
19+
@cached_property
20+
def path(self) -> Path:
21+
return Path("-")
22+
23+
def format_line(self, max_digits=None, selected=None):
24+
if selected is None:
25+
selected = 8 + 4 + 1
26+
numbers = [
27+
self.counts[i] for i in range(4) if selected & (2 ** (3 - i))
28+
]
29+
if max_digits is None:
30+
max_digits = len(str(max(numbers)))
31+
counts = " ".join(
32+
filter(None, [f"{number:{max_digits}}" for number in numbers])
33+
)
34+
if self.path.name == "-":
35+
return f"{counts}\n".encode("utf-8")
36+
else:
37+
return f"{counts} {self.path}\n".encode("utf-8")
38+
39+
40+
@dataclass
41+
class TempFile(FakeFile):
42+
@cached_property
43+
def path(self) -> Path:
44+
name = "".join(random.choices(ascii_lowercase, k=10))
45+
return Path(gettempdir()) / name
46+
47+
def __post_init__(self):
48+
self.path.write_bytes(self.content)
49+
50+
def delete(self):
51+
if self.path.is_dir():
52+
self.path.rmdir()
53+
elif self.path.is_file():
54+
self.path.unlink(missing_ok=True)
55+
56+
57+
@dataclass(frozen=True)
58+
class Files:
59+
files: list[FakeFile]
60+
61+
def __iter__(self):
62+
return iter(self.files)
63+
64+
def __len__(self):
65+
return len(self.files)
66+
67+
@cached_property
68+
def paths(self):
69+
return [str(file.path) for file in self.files]
70+
71+
@cached_property
72+
def expected(self):
73+
if len(self.files) > 1:
74+
return self.file_lines + self.total_line
75+
else:
76+
return self.file_lines
77+
78+
@cached_property
79+
def file_lines(self):
80+
return b"".join(file.format_line() for file in self.files)
81+
82+
@cached_property
83+
def total_line(self):
84+
totals = [sum(file.counts[i] for file in self.files) for i in range(4)]
85+
md = len(str(max(totals)))
86+
return f"{totals[0]:{md}} {totals[1]:{md}} {totals[3]:{md}} total\n".encode(
87+
"utf-8"
88+
)
89+
90+
91+
@pytest.fixture(scope="session")
92+
def small_file():
93+
temp_file = TempFile(content=b"caffe\n", counts=(1, 1, 6, 6))
94+
try:
95+
yield temp_file
96+
finally:
97+
temp_file.delete()
98+
99+
100+
@pytest.fixture(scope="session")
101+
def big_file():
102+
temp_file = TempFile(
103+
content=(
104+
b"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\n"
105+
b"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\n"
106+
b"quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\n"
107+
b"consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\n"
108+
b"cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\n"
109+
b"proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
110+
),
111+
counts=(6, 69, 447, 447),
112+
)
113+
try:
114+
yield temp_file
115+
finally:
116+
temp_file.delete()
117+
118+
119+
@pytest.fixture(scope="session")
120+
def file1():
121+
temp_file = TempFile(content=b"caffe latte\n", counts=(1, 2, 12, 12))
122+
try:
123+
yield temp_file
124+
finally:
125+
temp_file.delete()
126+
127+
128+
@pytest.fixture(scope="session")
129+
def file2():
130+
temp_file = TempFile(
131+
content=b"Lorem ipsum dolor sit amet\n", counts=(1, 5, 27, 27)
132+
)
133+
try:
134+
yield temp_file
135+
finally:
136+
temp_file.delete()
137+
138+
139+
@pytest.fixture(scope="session")
140+
def unicode_file():
141+
temp_file = TempFile(
142+
content="Zażółć gęślą jaźń\n".encode("utf-8"), counts=(1, 3, 18, 27)
143+
)
144+
try:
145+
yield temp_file
146+
finally:
147+
temp_file.delete()
148+
149+
150+
@pytest.fixture(scope="session")
151+
def small_files():
152+
temp_files = [
153+
TempFile(content=b"Mocha", counts=(0, 1, 5, 5)),
154+
TempFile(content=b"Espresso\n", counts=(1, 1, 9, 9)),
155+
TempFile(content=b"Cappuccino\n", counts=(1, 1, 11, 11)),
156+
TempFile(content=b"Frappuccino", counts=(0, 1, 11, 11)),
157+
TempFile(content=b"Flat White\n", counts=(1, 2, 11, 11)),
158+
TempFile(content=b"Turkish Coffee", counts=(0, 2, 14, 14)),
159+
TempFile(content=b"Irish Coffee Drink\n", counts=(1, 3, 19, 19)),
160+
TempFile(content=b"Espresso con Panna", counts=(0, 3, 18, 18)),
161+
]
162+
try:
163+
yield Files(temp_files)
164+
finally:
165+
for file in temp_files:
166+
file.delete()
167+
168+
169+
@pytest.fixture(scope="session")
170+
def medium_files(file1, file2, unicode_file):
171+
return Files([file1, file2, unicode_file])
172+
173+
174+
@pytest.fixture(scope="session")
175+
def wc():
176+
def function(*args, stdin: bytes | None = None) -> bytes:
177+
process = run(["wordcount", *args], capture_output=True, input=stdin)
178+
return process.stdout
179+
180+
return function
181+
182+
183+
@pytest.fixture(scope="session")
184+
def fake_dir():
185+
with TemporaryDirectory(delete=False) as directory:
186+
path = Path(directory)
187+
try:
188+
yield path
189+
finally:
190+
path.rmdir()
191+
192+
193+
@pytest.fixture(scope="function")
194+
def random_name():
195+
return make_random_name()
196+
197+
198+
def make_random_name(length=10):
199+
return "".join(random.choices(string.ascii_lowercase, k=length))
200+
201+
202+
@pytest.fixture(scope="session")
203+
def runner(wc, small_file, unicode_file, big_file, fake_dir):
204+
return Runner(
205+
wc, small_file, unicode_file, big_file, fake_dir, make_random_name()
206+
)
207+
208+
209+
@dataclass
210+
class Runner:
211+
wc: Callable
212+
file1: FakeFile
213+
file2: FakeFile
214+
file3: FakeFile
215+
fake_dir: Path
216+
random_name: str
217+
218+
def __call__(self, *flags):
219+
return self.wc(
220+
*flags,
221+
str(self.file1.path),
222+
"-",
223+
str(self.file2.path),
224+
self.fake_dir,
225+
"-",
226+
str(self.file3.path),
227+
self.random_name,
228+
stdin=b"flat white",
229+
)

0 commit comments

Comments
 (0)