Module 3: Strategies Describing The Input Space

4. `@st.composite`: assembling a coherent `Booking`

Description

Lesson 3 ended in a dead end, on purpose. You learned st.builds —the "independent-hopper assembler"— and saw it shine with Room and Member, whose fields don't talk to each other. But on trying to build a Booking with the same tool you hit its limit: start and end came out of separate hoppers, end fell before start half the time, and your property failed showing a Failing test case with an impossible booking. The conclusion was clear: when a field depends on another, independent hoppers don't serve. You need the "cook who follows steps": draw the start, look at it, and compute the end based on it.

This lesson gives you that tool, the central one of the whole module: @st.composite. It is a decorator that turns a normal Python function into a strategy. Inside that function you receive a special parameter, draw, which is your way of "pulling a value" from another strategy whenever you want. You draw a start. You draw a duration. You compute end = start + duration. You draw a price. And you return a Booking assembled with all of that —a coherent booking, which Reservo would accept without a fuss. The difference with st.builds is that here you control the order and can use what you already drew to decide what you draw next. It is the maximum expression of "describing the input space": you don't describe loose fields, you describe the procedure that assembles a valid object.

By the end you will have the module's star strategy, bookings(), and you will understand why @st.composite is the default tool as soon as an object has any internal coherence rule —which is almost always, in real software.

Connection with the module: this lesson is the peak. Everything before was preparation: bounding fields (lesson 2) gave you the pieces you draw here; st.builds (lesson 3) taught you to assemble objects and showed you exactly the gap @st.composite comes to fill. And everything that follows rests on this: .map() (lesson 5) is sometimes a shorter alternative to a trivial composite; .filter()/assume() (lesson 6) is what you will avoid using precisely because a good composite generates valid from the start; .flatmap() (lesson 7) is the "chained" version of the same dependency idea. And the mini-project (lesson 8) is, literally, polishing the bookings() you build here and testing a property over it. If you take a single tool from the module, let it be this one.

The recipe with steps: draw, look, decide

Go back to the kitchen of lesson 3. st.builds was the hopper machine: each field falls from its hopper without looking at the others. @st.composite is the cook who follows a recipe step by step, and the magic is that each step can look at what came out of the previous ones.

Think about how you would write the recipe for a booking to an assistant: "First, choose any start time within the next couple of years. Note it down. Second, choose how many hours the booking lasts, between 1 and 8. Third —and here is the trick— compute the end time by adding that duration to the start time you noted. Fourth, choose a price. With all that, assemble the booking." Note the third step: you don't choose an end time at random; you derive it from two things you already decided. That "deriving based on the previous" is precisely what independent hoppers couldn't do and what a composite does naturally.

draw is the gesture of "choose and note." Every time you write value = draw(strategy), you ask Hypothesis to pull a value from that strategy right now, and it stores it in a normal Python variable you can use in the following lines. The cook draws the start, holds it, and when the end step arrives, adds to it. There are no hoppers that don't talk: there is a sequence of decisions where each can use the previous ones. That is why @st.composite is the correct tool for any object with internal coherence rules —and almost all objects of a real domain have them.

Worked example: the bookings() strategy

Let's write the star strategy. The coherence rule it must respect: start < end always, and price_cents >= 0. The way to guarantee it is not to generate end on its own, but to derive it from the start plus a positive duration.

# strategies.py — the composite strategy that assembles coherent bookings
from datetime import datetime, timedelta
from hypothesis import strategies as st
from reservo import Booking


@st.composite
def bookings(draw):
    """Assembles a coherent and valid Booking: start < end, price_cents >= 0."""
    start = draw(st.datetimes(min_value=datetime(2020, 1, 1),
                              max_value=datetime(2030, 1, 1)))
    duration_hours = draw(st.integers(min_value=1, max_value=8))
    end = start + timedelta(hours=duration_hours)          # end derived from start
    price = draw(st.integers(min_value=0, max_value=1_000_000))
    return Booking(
        id="bk-1", room_id="r-focus", member_id="m-1",
        start=start, end=end, status="confirmed", price_cents=price,
    )

Let's take it apart line by line, because every detail matters:

  • @st.composite above the function is what turns it into a strategy. Without that decorator, bookings would be a normal function that returns a Booking; with it, bookings is a strategy factory.
  • def bookings(draw): — the first parameter, draw, you don't pass: the decorator injects it. It is your tool for pulling values from other strategies within the recipe.
  • start = draw(st.datetimes(...)) — draws a start date, bounded to a realistic range (2020–2030), and stores it in start. From here on start is a concrete date you can use.
  • duration_hours = draw(st.integers(min_value=1, max_value=8)) — draws a duration of 1 to 8 hours. The min_value=1 is key: it guarantees the duration is positive, so the end is going to end up strictly after the start.
  • end = start + timedelta(hours=duration_hours) — the line that changes everything. The end is not drawn: it is computed by adding the duration to the start. Since the duration is at least 1 hour, end > start is guaranteed by construction. It will never generate a zero-duration or negative-duration booking.
  • price = draw(st.integers(min_value=0, max_value=1_000_000)) — a non-negative and bounded price.
  • return Booking(...) — assembles and returns the object with all the drawn and derived values.

Note the contrast with the naive_bookings of lesson 3: there start and end were two independent draws; here only start is drawn, and end is derived. That is the whole difference between generating garbage and generating valid bookings.

An important usage detail: bookings is a factory, so it is called with parentheses to obtain the strategy. Inside a @given you write @given(booking=bookings()) —with the ()—, not @given(booking=bookings). The @st.composite decorator makes bookings a function that, when you call it, returns the strategy. Forgetting the parentheses is the most common error with composite, and we will see it in the mistakes section.

Let's peek at it with .example() to see that now it does produce coherent bookings:

# peek_bookings.py
from strategies import bookings
for _ in range(3):
    print(bookings().example())

What to expect. You run python3 peek_bookings.py and see three bookings, all with end later than start (real output of this guide; the values change between runs):

Booking(id='bk-1', room_id='r-focus', member_id='m-1', start=datetime.datetime(2030, 1, 1, 0, 0, fold=1), end=datetime.datetime(2030, 1, 1, 3, 0), status='confirmed', price_cents=1673)
Booking(id='bk-1', room_id='r-focus', member_id='m-1', start=datetime.datetime(2023, 1, 1, 0, 0), end=datetime.datetime(2023, 1, 1, 1, 0), status='confirmed', price_cents=0)
Booking(id='bk-1', room_id='r-focus', member_id='m-1', start=datetime.datetime(2030, 1, 1, 0, 0), end=datetime.datetime(2030, 1, 1, 8, 0), status='confirmed', price_cents=1186)

Compare each booking with the one from the counterexample of lesson 3. Here, in all three, the end goes after the start: the first lasts 3 hours (from 0:00 to 3:00), the second 1 hour, the third 8 hours. None is impossible. And note the edge cases Hypothesis put in on its own: a duration of 1 hour (the minimum), a price_cents of 0 (the minimum). The composite guarantees the coherence; Hypothesis keeps exploring the edges within that coherence. That is exactly what you want: variety, but always valid.

Now use it in a property. Two, actually —one that verifies the coherence itself, another a business property:

# test_bookings_property.py — properties over generated bookings
from datetime import timedelta
from hypothesis import given
from reservo import refund_cents
from strategies import bookings


@given(booking=bookings())
def test_booking_is_coherent(booking):
    # The strategy never produces an invalid booking.
    assert booking.start < booking.end
    assert booking.price_cents >= 0


@given(booking=bookings())
def test_refund_within_paid_for_generated_bookings(booking):
    now = booking.start - timedelta(hours=24)      # cancel the day before
    refund = refund_cents(booking, booking.price_cents, now)
    assert 0 <= refund <= booking.price_cents

What to expect. Both pass. The first confirms the strategy does its job (a hundred bookings, all with start < end); the second tests a real Reservo property over generated bookings:

$ python3 -m pytest test_bookings_property.py -v
test_bookings_property.py::test_booking_is_coherent PASSED                [ 50%]
test_bookings_property.py::test_refund_within_paid_for_generated_bookings PASSED [100%]

============================== 2 passed in 0.19s ===============================

test_booking_is_coherent passing is the proof that bookings() fulfills its contract: in a hundred attempts, Hypothesis couldn't produce a single booking with end <= start, because the construction makes it impossible. And test_refund_within_paid_for_generated_bookings is the module's achievement: the invariant refund ∈ [0, paid] tested over a hundred different and valid bookings, not over a constant. That is describing the input space for real.

Why draw goes inside and why the order matters

There are two subtle ideas worth underlining.

The first: draw only works inside a @st.composite function. You can't write draw(...) anywhere; it is a parameter the decorator hands you, and it lives only inside that function. Outside of there, to pull a value from a strategy in exploration mode, you have .example() (which we already used). But inside the recipe, draw is the only correct way to "pull a value and use it," and it is the one Hypothesis knows how to shrink when something fails.

The second: the order of the draws is the order of the recipe, and you can use what is drawn in any later step. You drew start first; that is why you can use it when computing end. If you needed it the other way around —fix the end first and derive the start by subtracting the duration— you would draw the end first. The composite gives you full control over the sequence, which is precisely what st.builds's hoppers didn't offer. That freedom is the reason almost any real domain object —with its rules of "this field depends on that one"— is generated with @st.composite.

Common mistakes

Forgetting the parentheses when using the strategy (bookings instead of bookings()). What happens: someone writes @given(booking=bookings) without the parentheses and gets a confusing error, or odd behavior, because they passed the factory function instead of the strategy. Why it happens: with st.builds the strategy was the value directly (rooms, without parentheses), but @st.composite produces a factory you have to call to obtain the strategy. How to detect it: if @given complains that it received something that is not a strategy, or if the test does unexpected things, check whether you are missing the (). How to fix it: inside @given, always bookings() with parentheses. Mnemonic rule: st.builds gives you a strategy (noun, no ()); @st.composite gives you a strategy factory (verb, with ()).

Generating the dependent field instead of deriving it (going back to hoppers inside the composite). What happens: someone uses @st.composite but, out of habit, draws end with another draw(st.datetimes(...)) instead of computing it from start. The composite doesn't save it: it goes back to generating incoherent bookings. Why it happens: the decorator gives the possibility of deriving, but doesn't force it; if you keep drawing independently, you have the same problem as lesson 3 with more steps. How to detect it: if inside your composite there are two draws for two fields that should be related, and you don't use one to compute the other, something is wrong. How to fix it: the dependent field is computed, not drawn. end = start + timedelta(hours=duration). The draw is only for the independent decisions (the start, the duration, the price); the derived ones come from those.

Putting assert or side effects inside the composite (confusing generating with testing). What happens: someone puts an assert start < end inside the @st.composite function, or tries to assert properties there. Why it happens: "manufacturing the input" gets mixed with "testing the property," which are two different jobs. How to detect it: if your composite has a business assert, or calls functions of the system under test, it left its role. How to fix it: the composite only builds and returns a valid object; the assertions go in the test body, under @given. A well-written composite doesn't need assert because it builds the object in a way that is valid by design —like end = start + duration, which can't give an earlier end.

Exercises

Exercise 1 — A composite for date ranges. Write a @st.composite strategy called time_ranges that generates a tuple (start, end) of two dates where end is strictly later than start, with a gap of between 1 minute and 3 hours. Explain which line guarantees the coherence.

View solution
from datetime import datetime, timedelta
from hypothesis import strategies as st


@st.composite
def time_ranges(draw):
    start = draw(st.datetimes(min_value=datetime(2024, 1, 1),
                              max_value=datetime(2026, 1, 1)))
    minutes = draw(st.integers(min_value=1, max_value=180))   # 1 min .. 3 h
    end = start + timedelta(minutes=minutes)
    return (start, end)

The line that guarantees the coherence is end = start + timedelta(minutes=minutes) combined with min_value=1 in the duration. Since minutes is at least 1, end is always strictly after start. end is never drawn independently, so it is impossible for it to fall before. Just like in bookings(), the dependent field is derived, not generated. And remember: to use it, @given(range=time_ranges()) with parentheses.

Exercise 2 — Convert builds into composite. This st.builds generates incoherent bookings (the problem of lesson 3). Rewrite it as a @st.composite that guarantees start < end, drawing a duration of 1 to 4 hours.

naive_bookings = st.builds(
    Booking,
    id=st.just("bk-1"), room_id=st.just("r-focus"), member_id=st.just("m-1"),
    start=st.datetimes(min_value=datetime(2024, 1, 1), max_value=datetime(2026, 1, 1)),
    end=st.datetimes(min_value=datetime(2024, 1, 1), max_value=datetime(2026, 1, 1)),
    status=st.just("confirmed"),
    price_cents=st.integers(min_value=0, max_value=500_000),
)
View solution
from datetime import datetime, timedelta
from hypothesis import strategies as st
from reservo import Booking


@st.composite
def coherent_bookings(draw):
    start = draw(st.datetimes(min_value=datetime(2024, 1, 1),
                              max_value=datetime(2026, 1, 1)))
    duration_hours = draw(st.integers(min_value=1, max_value=4))
    end = start + timedelta(hours=duration_hours)
    price = draw(st.integers(min_value=0, max_value=500_000))
    return Booking(
        id="bk-1", room_id="r-focus", member_id="m-1",
        start=start, end=end, status="confirmed", price_cents=price,
    )

The key change: in the st.builds, start and end were two independent strategies; in the composite, only start is drawn and end is derived with end = start + timedelta(hours=duration_hours). The other fields (id, room_id, etc.) stay fixed. Now coherent_bookings() never produces a booking with end <= start. If you ran test_booking_start_before_end from lesson 3 over this strategy, it would pass green instead of failing.

Exercise 3 — Diagnose the broken composite. A teammate writes this composite and their property fails with bookings where end is before start. Where is the error and how do you fix it?

@st.composite
def bookings(draw):
    start = draw(st.datetimes(min_value=datetime(2024, 1, 1), max_value=datetime(2026, 1, 1)))
    end = draw(st.datetimes(min_value=datetime(2024, 1, 1), max_value=datetime(2026, 1, 1)))
    price = draw(st.integers(min_value=0, max_value=500_000))
    return Booking(id="bk-1", room_id="r-focus", member_id="m-1",
                   start=start, end=end, status="confirmed", price_cents=price)
View solution

The error is in the second line: end = draw(st.datetimes(...)). Although the function is a @st.composite, here the end is being drawn independently of the start, exactly like in st.builds's hoppers. The decorator gives the possibility of deriving, but this code doesn't take advantage of it: it keeps generating two unrelated dates, so half the time end falls before start. Using @st.composite fixes nothing on its own; you have to derive the dependent field.

The fix is to replace the end's draw with a computation from the start:

@st.composite
def bookings(draw):
    start = draw(st.datetimes(min_value=datetime(2024, 1, 1), max_value=datetime(2026, 1, 1)))
    duration_hours = draw(st.integers(min_value=1, max_value=8))   # positive duration
    end = start + timedelta(hours=duration_hours)                  # end derived
    price = draw(st.integers(min_value=0, max_value=500_000))
    return Booking(id="bk-1", room_id="r-focus", member_id="m-1",
                   start=start, end=end, status="confirmed", price_cents=price)

The moral, which is the heart of the lesson: the composite doesn't guarantee coherence by the simple fact of existing; you guarantee the coherence by deriving the dependent fields instead of drawing them loose.

Summary and next step

In this lesson you conquered the module's central tool: @st.composite. It is the decorator that turns a function into a strategy, giving you a draw parameter with which you pull values from other strategies in order, using what you already drew to decide what you draw next. With it you wrote the star strategy, bookings(), which draws a start and a positive duration, derives end = start + duration, and assembles a coherent Booking —solving the dependent-fields problem that sank st.builds in lesson 3. You proved it works with two green properties: one that verifies the coherence itself (a hundred bookings, all with start < end) and another that tests refund ∈ [0, paid] over a hundred valid and different bookings. And you saw the details that make or break a composite: calling it with parentheses (bookings()), deriving the dependent fields instead of drawing them, and leaving the asserts out —the composite only builds.

Before moving on you should be able to: write a @st.composite that generates an object with an internal coherence rule; explain why end is derived and start is drawn; and recognize a broken composite (one that draws the dependent field instead of deriving it).

What follows are the three verbs that shape an already-built strategy. In lesson 5 you are going to learn .map(): applying a function to each value a strategy generates, to transform it. You will see that many things you would do with a trivial composite —convert a number of hours into a price, wrap a value in a tuple— come out shorter with a .map(), and you will learn when each tool is the clearest. It is the first of the fine tweaks you can give a strategy.

Resources