Refactoring & Adding Functionality
Thomas J. Kennedy
1 Where is Next?
We would like to continue the example from the previous lecture, i.e., Module-08/Painting-4-Style. We will doing two things during this lecture:
-
Tweaking the existing code
-
Adding user input
-
Adding new functionality
2 Starting with “main”
Let us start in main
within the estimate_paint
module.
Example 1: Before Changes - maindef main(): """ This is the driver logic for the program. The length, width, and price per gallon are currently hardcoded. TODO: add user input """ # Dimensions in feet length = 50 width = 40 # Price in dollars price_per_gallon = 49.95 area = compute_paint.wall_surface_area(length, width) min_required, max_required = compute_paint.gallons_required(area) summary = get_report(min_required, max_required, price_per_gallon) print(summary)
I would like to actually have the user enter some values. Let us add a gather_input
function that checks sys.argv
for command line arguments and prompts the user in the case where no such arguments were supplied.
Example 2: New Function - gather_inputdef gather_input(args: list[str]) -> tuple[float, float, float]: """ Check the supplied `args` for three (3) user supplied arguments (for a total length of 4). If three arguments were not supplied then prompt the used for length (in feet), width (in feet), and price per gallon. Args: args: command line arguments to process Returns: a three-tuple in the form (length, width, price_per_gallon) """ # Command Line Arguments were supplied if len(args) == 4: length = float(args[1]) width = float(args[2]) price_per_gallon = float(args[3]) else: length = float(input("Enter the room length: ")) width = float(input("Enter the room width: ")) price_per_gallon = float(input("Enter the cost per gallon of paint: ")) return length, width, price_per_gallon
main
can now retrieve the values by calling gather_input
and unpacking the values from the returned three-tuple.
length, width, price_per_gallon = gather_input(sys.argv)
Take note of how sys.argv
was passed into gather_inputs
as an argument. This decision was based on two criteria:
-
Testing is easier.
-
Accessing global variables (along with global variables themselves) should be minimized.
3 Writing New Tests
Let us add a couple tests for this new gather_inputs
function:
-
One for input from
sys.argv
.def test_gather_input_cli_arg(): length, width, unit_cost = gather_input([None, "40", "50", "49.95"]) assert_that(length, close_to(40, 1e-3)) assert_that(width, close_to(50, 1e-3)) assert_that(unit_cost, close_to(49.95, 1e-3))
-
We start by creating a list with four (4) entries. The
None
represents where the program name would be located. -
We unpack
length
,width
, andunit_cost
from the returned three-tuple. -
We check that
length
,width
, andunit_cost
are within0.001
of their expected values/
-
-
One for input from
sys.stdin
(i.e., frominput
).def test_gather_input_input(monkeypatch): faked_input = StringIO("40\n50\n49.95\n") monkeypatch.setattr("sys.stdin", faked_input) length, width, unit_cost = gather_input([]) assert_that(length, close_to(40, 1e-3)) assert_that(width, close_to(50, 1e-3)) assert_that(unit_cost, close_to(49.95, 1e-3))
-
We start by creating a
StringIO
object that contains the length, width, and price inputs, separated by newlines (\n
) -
We use
monkeypatch
to replacesys.stdin
(i.e., intercept calls toinput
) -
We unpack
length
,width
, andunit_cost
from the returned three-tuple. -
We check that
length
,width
, andunit_cost
are within0.001
of their expected values/
-
We should technically handle parsing errors. However, that will be discussed in a separate lecture and separate example.
4 Painting More Coats
I imagine that you have been wondering how someone who used to work as a painter wrote a program that assumes a room can be painted in one coat. Two (2) coats is always the minimum (unless it is a rental property).
Let us update the program to give us estimates for two (2), three (3), and, if we are unlucky, four (4) coats. Let us start with what we would like to see for…
python3.11 estimate_paint.py 10 12 49.95
Example 3: Desired OutputFor 2 coats... You will need to buy 3 gallons of paint. You will spend $ 149.85. For 3 coats... You will need to buy 4 to 5 gallons of paint. You will spend $ 199.80 to $ 249.75. For 4 coats... You will need to buy 5 to 6 gallons of paint. You will spend $ 249.75 to $ 299.70.
We can actually generate this report with a few changes (that are all within estimate_paint.py
). Let us think about coverage…
-
A 10 foot by 12 foot room has 480 square feet of wall to cover
-
A gallon of paint covers 350 to 400 square feet
-
One coat requires 2 gallons of paint (since paint must be bought in whole gallons)
-
Two (2) coats requires 480 square feet to be covered twice… which is equivalent to 960 square feet
-
Three (3) coats requires 480 square feet to be covered thrice… which is equivalent to 1,440 square feet
-
Four (4) coats requires 480 square feet to be covered four times… which is equivalent to 1,920 square feet
We can just call the compute_paint.gallons_required
function for each of these values and then pass the result to estimate_paint.get_report
!
4.1 Updating “main”
Let us start by introducing two global constants:
MIN_COATS: int = 2 # Minimum number of paint coats
MAX_COATS: int = 4 # Maximum number of paint coats
We can then add a for
loop to main
…
length, width, price_per_gallon = gather_input(sys.argv)
area = compute_paint.wall_surface_area(length, width)
for num_coats in range(MIN_COATS, MAX_COATS + 1):
total_area_painted = area * num_coats
print(f"For {num_coats} coats...")
min_required, max_required = compute_paint.gallons_required(total_area_painted)
summary = get_report(min_required, max_required, price_per_gallon, indent=2)
print(summary)
print()
Take note of how we
-
only call
wall_surface_area
one time -
compute
total_area_painted
by taking the number of coats (num_coats
) and multiplying thearea
for a single coat -
Output a quick header with
print(f"For {num_coats} coats...")
-
Pass an
indent
named argument toget_report
4.2 Updating “get_report”
We would like to indent each line of the report. The `indent argument will default to zero (0). Afer a quick update to the function signature and documentation…
def get_report(min_gallons: int, max_gallons: int, price_per_gallon: float, indent: int = 0) -> str:
"""
Generate a summary of the amount of paint required to paint a room and the
project cost of that paint.
Args:
min_gallons: estimate of the minimum amount of paint required
max_gallons: estimate of the maximum amount of paint required
price_per_gallon: cost for a single gallon of paint (e.g., for a
single gallon or as part of a five gallon bucket)
indent: number of spaces by which to indent each line of the report
Returns:
Summary of the estimated gallons of paint required if the min and max
estimates are the same. Otherwise a report listing the min and max
values is provided.
"""
We can move on to building the actual indent string…
indent = " " * indent
and adding it to each f-string…
if min_gallons == max_gallons:
gallons = max_gallons
cost = gallons * price_per_gallon
return "\n".join(
(
f"{indent}You will need to buy {gallons} gallons of paint.",
f"{indent}You will spend $ {cost:.2f}.",
)
)
min_cost = min_gallons * price_per_gallon
max_cost = max_gallons * price_per_gallon
return "\n".join(
(
f"{indent}You will need to buy {min_gallons} to {max_gallons} gallons of paint.",
f"{indent}You will spend $ {min_cost:.2f} to $ {max_cost:.2f}.",
)
)
5 That is “The End”
The only thing left to do is to run isort
and black
one last time. The final code can be found Module-08/Painting-5-Adding.