-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1b448e6
commit 49e27cc
Showing
27 changed files
with
2,097 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
# Learn by Doing: Build a Clone of the Unix `wc` Shell Command | ||
|
||
This folder contains supporting materials for the [wordcount coding challenge](https://realpython.com/courses/wordcount/) on Real Python. | ||
|
||
## How to Get Started? | ||
|
||
### Cloud Environment | ||
|
||
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: | ||
|
||
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/realpython/materials?quickstart=1&devcontainer_path=.devcontainer%2Fwordcount%2Fdevcontainer.json) | ||
|
||
Alternatively, follow the steps below to set up the environment on your local machine. | ||
|
||
### Local Computer | ||
|
||
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/`: | ||
|
||
```sh | ||
$ git clone https://github.com/realpython/materials.git | ||
$ cd materials/wordcount/ | ||
``` | ||
|
||
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): | ||
|
||
```sh | ||
$ python -m venv venv/ | ||
$ source venv/bin/activate | ||
(venv) $ python -m pip install -r requirements.txt -e . | ||
``` | ||
|
||
Make sure to include the period at the end of the command! | ||
|
||
## How to Get Feedback? | ||
|
||
To display instructions for your current task: | ||
|
||
```sh | ||
(venv) $ pytest --task | ||
``` | ||
|
||
To track your progress and reveal the acceptance criteria: | ||
|
||
```sh | ||
(venv) $ pytest | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
[build-system] | ||
requires = ["setuptools"] | ||
build-backend = "setuptools.build_meta" | ||
|
||
[project] | ||
name = "wordcount" | ||
version = "1.0.0" | ||
readme = "README.md" | ||
dependencies = [ | ||
"pytest", | ||
"pytest-timeout", | ||
"rich", | ||
] | ||
|
||
[project.scripts] | ||
wordcount = "wordcount:main" | ||
|
||
[tool.black] | ||
line-length = 79 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
iniconfig==2.0.0 | ||
markdown-it-py==3.0.0 | ||
mdurl==0.1.2 | ||
packaging==24.1 | ||
pluggy==1.5.0 | ||
Pygments==2.18.0 | ||
pytest==8.3.3 | ||
pytest-timeout==2.3.1 | ||
rich==13.9.2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Uncomment the main() function below to solve your first task: | ||
# def main(): | ||
# pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from fixtures import * | ||
|
||
pytest_plugins = ["realpython"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,229 @@ | ||
import random | ||
import string | ||
from dataclasses import dataclass | ||
from functools import cached_property | ||
from pathlib import Path | ||
from string import ascii_lowercase | ||
from subprocess import run | ||
from tempfile import TemporaryDirectory, gettempdir | ||
from typing import Callable | ||
|
||
import pytest | ||
|
||
|
||
@dataclass | ||
class FakeFile: | ||
content: bytes | ||
counts: tuple[int, ...] | ||
|
||
@cached_property | ||
def path(self) -> Path: | ||
return Path("-") | ||
|
||
def format_line(self, max_digits=None, selected=None): | ||
if selected is None: | ||
selected = 8 + 4 + 1 | ||
numbers = [ | ||
self.counts[i] for i in range(4) if selected & (2 ** (3 - i)) | ||
] | ||
if max_digits is None: | ||
max_digits = len(str(max(numbers))) | ||
counts = " ".join( | ||
filter(None, [f"{number:{max_digits}}" for number in numbers]) | ||
) | ||
if self.path.name == "-": | ||
return f"{counts}\n".encode("utf-8") | ||
else: | ||
return f"{counts} {self.path}\n".encode("utf-8") | ||
|
||
|
||
@dataclass | ||
class TempFile(FakeFile): | ||
@cached_property | ||
def path(self) -> Path: | ||
name = "".join(random.choices(ascii_lowercase, k=10)) | ||
return Path(gettempdir()) / name | ||
|
||
def __post_init__(self): | ||
self.path.write_bytes(self.content) | ||
|
||
def delete(self): | ||
if self.path.is_dir(): | ||
self.path.rmdir() | ||
elif self.path.is_file(): | ||
self.path.unlink(missing_ok=True) | ||
|
||
|
||
@dataclass(frozen=True) | ||
class Files: | ||
files: list[FakeFile] | ||
|
||
def __iter__(self): | ||
return iter(self.files) | ||
|
||
def __len__(self): | ||
return len(self.files) | ||
|
||
@cached_property | ||
def paths(self): | ||
return [str(file.path) for file in self.files] | ||
|
||
@cached_property | ||
def expected(self): | ||
if len(self.files) > 1: | ||
return self.file_lines + self.total_line | ||
else: | ||
return self.file_lines | ||
|
||
@cached_property | ||
def file_lines(self): | ||
return b"".join(file.format_line() for file in self.files) | ||
|
||
@cached_property | ||
def total_line(self): | ||
totals = [sum(file.counts[i] for file in self.files) for i in range(4)] | ||
md = len(str(max(totals))) | ||
return f"{totals[0]:{md}} {totals[1]:{md}} {totals[3]:{md}} total\n".encode( | ||
"utf-8" | ||
) | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def small_file(): | ||
temp_file = TempFile(content=b"caffe\n", counts=(1, 1, 6, 6)) | ||
try: | ||
yield temp_file | ||
finally: | ||
temp_file.delete() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def big_file(): | ||
temp_file = TempFile( | ||
content=( | ||
b"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\n" | ||
b"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\n" | ||
b"quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\n" | ||
b"consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\n" | ||
b"cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\n" | ||
b"proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n" | ||
), | ||
counts=(6, 69, 447, 447), | ||
) | ||
try: | ||
yield temp_file | ||
finally: | ||
temp_file.delete() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def file1(): | ||
temp_file = TempFile(content=b"caffe latte\n", counts=(1, 2, 12, 12)) | ||
try: | ||
yield temp_file | ||
finally: | ||
temp_file.delete() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def file2(): | ||
temp_file = TempFile( | ||
content=b"Lorem ipsum dolor sit amet\n", counts=(1, 5, 27, 27) | ||
) | ||
try: | ||
yield temp_file | ||
finally: | ||
temp_file.delete() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def unicode_file(): | ||
temp_file = TempFile( | ||
content="Zażółć gęślą jaźń\n".encode("utf-8"), counts=(1, 3, 18, 27) | ||
) | ||
try: | ||
yield temp_file | ||
finally: | ||
temp_file.delete() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def small_files(): | ||
temp_files = [ | ||
TempFile(content=b"Mocha", counts=(0, 1, 5, 5)), | ||
TempFile(content=b"Espresso\n", counts=(1, 1, 9, 9)), | ||
TempFile(content=b"Cappuccino\n", counts=(1, 1, 11, 11)), | ||
TempFile(content=b"Frappuccino", counts=(0, 1, 11, 11)), | ||
TempFile(content=b"Flat White\n", counts=(1, 2, 11, 11)), | ||
TempFile(content=b"Turkish Coffee", counts=(0, 2, 14, 14)), | ||
TempFile(content=b"Irish Coffee Drink\n", counts=(1, 3, 19, 19)), | ||
TempFile(content=b"Espresso con Panna", counts=(0, 3, 18, 18)), | ||
] | ||
try: | ||
yield Files(temp_files) | ||
finally: | ||
for file in temp_files: | ||
file.delete() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def medium_files(file1, file2, unicode_file): | ||
return Files([file1, file2, unicode_file]) | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def wc(): | ||
def function(*args, stdin: bytes | None = None) -> bytes: | ||
process = run(["wordcount", *args], capture_output=True, input=stdin) | ||
return process.stdout | ||
|
||
return function | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def fake_dir(): | ||
with TemporaryDirectory(delete=False) as directory: | ||
path = Path(directory) | ||
try: | ||
yield path | ||
finally: | ||
path.rmdir() | ||
|
||
|
||
@pytest.fixture(scope="function") | ||
def random_name(): | ||
return make_random_name() | ||
|
||
|
||
def make_random_name(length=10): | ||
return "".join(random.choices(string.ascii_lowercase, k=length)) | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def runner(wc, small_file, unicode_file, big_file, fake_dir): | ||
return Runner( | ||
wc, small_file, unicode_file, big_file, fake_dir, make_random_name() | ||
) | ||
|
||
|
||
@dataclass | ||
class Runner: | ||
wc: Callable | ||
file1: FakeFile | ||
file2: FakeFile | ||
file3: FakeFile | ||
fake_dir: Path | ||
random_name: str | ||
|
||
def __call__(self, *flags): | ||
return self.wc( | ||
*flags, | ||
str(self.file1.path), | ||
"-", | ||
str(self.file2.path), | ||
self.fake_dir, | ||
"-", | ||
str(self.file3.path), | ||
self.random_name, | ||
stdin=b"flat white", | ||
) |
Oops, something went wrong.