Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Coding Challenge: Wordcount #599

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions wordcount/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Python Project: Build a Word Count Command-Line App

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
```
19 changes: 19 additions & 0 deletions wordcount/pyproject.toml
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
9 changes: 9 additions & 0 deletions wordcount/requirements.txt
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
3 changes: 3 additions & 0 deletions wordcount/src/wordcount.py
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
3 changes: 3 additions & 0 deletions wordcount/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from fixtures import * # noqa

pytest_plugins = ["realpython"]
229 changes: 229 additions & 0 deletions wordcount/tests/fixtures.py
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",
)
Loading
Loading