Skip to content

Commit 0c8dadd

Browse files
committed
ch10
1 parent 8704175 commit 0c8dadd

File tree

7 files changed

+354
-0
lines changed

7 files changed

+354
-0
lines changed

ch10/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# __init__.py
2+
# This is here to enable you to run tests within the `test` folder.

ch10/api.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# api.py
2+
import os
3+
import csv
4+
from copy import deepcopy
5+
6+
from marshmallow import Schema, fields, pre_load
7+
from marshmallow.validate import Length, Range
8+
9+
10+
class UserSchema(Schema):
11+
"""Represent a *valid* user. """
12+
13+
email = fields.Email(required=True)
14+
name = fields.Str(required=True, validate=Length(min=1))
15+
age = fields.Int(
16+
required=True, validate=Range(min=18, max=65)
17+
)
18+
role = fields.Str()
19+
20+
@pre_load()
21+
def strip_name(self, data, **kwargs):
22+
data_copy = deepcopy(data)
23+
24+
try:
25+
data_copy['name'] = data_copy['name'].strip()
26+
except (AttributeError, KeyError, TypeError):
27+
pass
28+
29+
return data_copy
30+
31+
32+
schema = UserSchema()
33+
34+
35+
def export(filename, users, overwrite=True):
36+
"""Export a CSV file.
37+
38+
Create a CSV file and fill with valid users. If `overwrite`
39+
is False and file already exists, raise IOError.
40+
"""
41+
if not overwrite and os.path.isfile(filename):
42+
raise IOError(f"'{filename}' already exists.")
43+
44+
valid_users = get_valid_users(users)
45+
write_csv(filename, valid_users)
46+
47+
48+
def get_valid_users(users):
49+
"""Yield one valid user at a time from users. """
50+
yield from filter(is_valid, users)
51+
52+
53+
def is_valid(user):
54+
"""Return whether or not the user is valid. """
55+
return not schema.validate(user)
56+
57+
58+
def write_csv(filename, users):
59+
"""Write a CSV given a filename and a list of users.
60+
61+
The users are assumed to be valid for the given CSV structure.
62+
"""
63+
fieldnames = ['email', 'name', 'age', 'role']
64+
65+
with open(filename, 'w', newline='') as csvfile:
66+
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
67+
writer.writeheader()
68+
69+
for user in users:
70+
writer.writerow(user)

ch10/data.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# data.py
2+
def get_clean_data(source):
3+
4+
data = load_data(source)
5+
cleaned_data = clean_data(data)
6+
7+
return cleaned_data

ch10/requirements/requirements.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
marshmallow
2+
pytest

ch10/requirements/requirements.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#
2+
# This file is autogenerated by pip-compile
3+
# To update, run:
4+
#
5+
# pip-compile requirements.in
6+
#
7+
attrs==21.2.0
8+
# via pytest
9+
iniconfig==1.1.1
10+
# via pytest
11+
marshmallow==3.12.1
12+
# via -r requirements.in
13+
packaging==20.9
14+
# via pytest
15+
pluggy==0.13.1
16+
# via pytest
17+
py==1.10.0
18+
# via pytest
19+
pyparsing==2.4.7
20+
# via packaging
21+
pytest==6.2.4
22+
# via -r requirements.in
23+
toml==0.10.2
24+
# via pytest

ch10/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# tests/__init__.py

ch10/tests/test_api.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# tests/test_api.py
2+
import re
3+
from unittest.mock import patch, mock_open, call
4+
5+
import pytest
6+
7+
from ch10.api import is_valid, export, write_csv
8+
9+
10+
@pytest.fixture
11+
def min_user():
12+
"""Represent a valid user with minimal data. """
13+
return {
14+
'email': '[email protected]',
15+
'name': 'Primus Minimus',
16+
'age': 18,
17+
}
18+
19+
20+
@pytest.fixture
21+
def full_user():
22+
"""Represent valid user with full data. """
23+
return {
24+
'email': '[email protected]',
25+
'name': 'Maximus Plenus',
26+
'age': 65,
27+
'role': 'emperor',
28+
}
29+
30+
31+
@pytest.fixture
32+
def users(min_user, full_user):
33+
"""List of users, two valid and one invalid. """
34+
bad_user = {
35+
'email': '[email protected]',
36+
'name': 'Horribilis',
37+
}
38+
return [min_user, bad_user, full_user]
39+
40+
41+
class TestIsValid:
42+
"""Test how code verifies whether a user is valid or not. """
43+
44+
def test_minimal(self, min_user):
45+
assert is_valid(min_user)
46+
47+
def test_full(self, full_user):
48+
assert is_valid(full_user)
49+
50+
@pytest.mark.parametrize('age', range(18))
51+
def test_invalid_age_too_young(self, age, min_user):
52+
min_user['age'] = age
53+
54+
assert not is_valid(min_user)
55+
56+
@pytest.mark.parametrize('age', range(66, 100))
57+
def test_invalid_age_too_old(self, age, min_user):
58+
min_user['age'] = age
59+
60+
assert not is_valid(min_user)
61+
62+
@pytest.mark.parametrize('age', ['NaN', 3.1415, None])
63+
def test_invalid_age_wrong_type(self, age, min_user):
64+
min_user['age'] = age
65+
66+
assert not is_valid(min_user)
67+
68+
@pytest.mark.parametrize('age', range(18, 66))
69+
def test_valid_age(self, age, min_user):
70+
min_user['age'] = age
71+
72+
assert is_valid(min_user)
73+
74+
@pytest.mark.parametrize('field', ['email', 'name', 'age'])
75+
def test_mandatory_fields(self, field, min_user):
76+
del min_user[field]
77+
78+
assert not is_valid(min_user)
79+
80+
@pytest.mark.parametrize('field', ['email', 'name', 'age'])
81+
def test_mandatory_fields_empty(self, field, min_user):
82+
min_user[field] = ''
83+
84+
assert not is_valid(min_user)
85+
86+
def test_name_whitespace_only(self, min_user):
87+
min_user['name'] = ' \n\t'
88+
89+
assert not is_valid(min_user)
90+
91+
@pytest.mark.parametrize(
92+
'email, outcome',
93+
[
94+
('missing_at.com', False),
95+
('@missing_start.com', False),
96+
('missing_end@', False),
97+
('missing_dot@example', False),
98+
99+
('[email protected]', True),
100+
('δοκιμή@παράδειγμα.δοκιμή', True),
101+
('аджай@экзампл.рус', True),
102+
]
103+
)
104+
def test_email(self, email, outcome, min_user):
105+
min_user['email'] = email
106+
107+
assert is_valid(min_user) == outcome
108+
109+
@pytest.mark.parametrize(
110+
'field, value',
111+
[
112+
('email', None),
113+
('email', 3.1415),
114+
('email', {}),
115+
116+
('name', None),
117+
('name', 3.1415),
118+
('name', {}),
119+
120+
('role', None),
121+
('role', 3.1415),
122+
('role', {}),
123+
]
124+
)
125+
def test_invalid_types(self, field, value, min_user):
126+
min_user[field] = value
127+
128+
assert not is_valid(min_user)
129+
130+
131+
class TestExport:
132+
"""Test behavior of `export` function. """
133+
134+
@pytest.fixture
135+
def csv_file(self, tmp_path):
136+
"""Yield a filename in a temporary folder.
137+
138+
Due to how pytest `tmp_path` fixture works, the file does
139+
not exist yet.
140+
"""
141+
yield tmp_path / "out.csv"
142+
143+
@pytest.fixture
144+
def existing_file(self, tmp_path):
145+
"""Create a temporary file and put some content in it. """
146+
existing = tmp_path / 'existing.csv'
147+
existing.write_text('Please leave me alone...')
148+
yield existing
149+
150+
def test_export(self, users, csv_file):
151+
export(csv_file, users)
152+
153+
text = csv_file.read_text()
154+
155+
assert (
156+
'email,name,age,role\n'
157+
'[email protected],Primus Minimus,18,\n'
158+
'[email protected],Maximus Plenus,65,emperor\n'
159+
) == text
160+
161+
def test_export_quoting(self, min_user, csv_file):
162+
min_user['name'] = 'A name, with a comma'
163+
164+
export(csv_file, [min_user])
165+
166+
text = csv_file.read_text()
167+
168+
assert (
169+
'email,name,age,role\n'
170+
'[email protected],"A name, with a comma",18,\n'
171+
) == text
172+
173+
def test_does_not_overwrite(self, users, existing_file):
174+
with pytest.raises(IOError) as err:
175+
export(existing_file, users, overwrite=False)
176+
177+
err.match(
178+
r"'{}' already exists\.".format(
179+
re.escape(str(existing_file))
180+
)
181+
)
182+
183+
# let's also verify the file is still intact
184+
assert existing_file.read_text() == (
185+
'Please leave me alone...'
186+
)
187+
188+
189+
class TextExportMock:
190+
"""Example on how to test with mocks. """
191+
192+
@pytest.fixture
193+
def write_csv_mock(self):
194+
with patch('ch10.api.write_csv') as m:
195+
yield m
196+
197+
@pytest.fixture
198+
def get_valid_users_mock(self):
199+
with patch('ch10.api.get_valid_users') as m:
200+
yield m
201+
202+
def test_export(
203+
self, write_csv_mock, get_valid_users_mock, users
204+
):
205+
export('out.csv', users)
206+
207+
# verify mocked funcs have been called properly
208+
assert [call(users)] == get_valid_users_mock.call_args_list
209+
210+
valid_users = get_valid_users_mock.return_value
211+
assert [
212+
call('out.csv', valid_users)
213+
] == write_csv_mock.call_args_list
214+
215+
216+
class TestWriteCSV:
217+
"""Example on how to test with mocks. """
218+
219+
@pytest.fixture
220+
def open_mock(self):
221+
"""Mocks the `open` function. """
222+
with patch('builtins.open', new_callable=mock_open()) as m:
223+
yield m
224+
225+
@pytest.fixture
226+
def csv_mock(self):
227+
"""Mocks the `csv` module as imported in `api.py`. """
228+
with patch('ch10.api.csv') as m:
229+
yield m
230+
231+
def test_write_csv(self, open_mock, csv_mock, users):
232+
fieldnames = ['email', 'name', 'age', 'role']
233+
234+
write_csv('out.csv', users)
235+
236+
# verify both mocks are at work properly
237+
writer = csv_mock.DictWriter.return_value
238+
managed = open_mock().__enter__()
239+
240+
assert [
241+
call(managed, fieldnames=fieldnames)
242+
] == csv_mock.DictWriter.call_args_list
243+
244+
assert [call()] == writer.writeheader.call_args_list
245+
246+
assert [
247+
call(users[0]), call(users[1]), call(users[2])
248+
] == writer.writerow.call_args_list

0 commit comments

Comments
 (0)