How to Flatten Lists and Merge Dictionaries With Python 3.15 Unpacking in Comprehensions
Learn how Python 3.15 lets you put a star inside a comprehension to flatten lists, union sets, merge dictionaries and stream values lazily, with tested code, exact error messages, measured speed and...
Python 3.15 lets you put a star inside a comprehension. Write [*part for part in parts] and Python spreads every inner list into one flat list. Write {**layer for layer in layers} and it merges many dictionaries into one. This tutorial teaches both forms from scratch: you will try each one, meet the errors it can raise, build a small module around it, test that module, measure the speed, and learn how to keep the new syntax out of projects that still run on older Pythons.
Table Of Content
- What you will build
- Prerequisites
- Step 1: Check your Python and see the error older versions give
- Step 2: Flatten a list of lists
- The idea: the star spreads each inner list
- Try it
- What each line shows
- Step 3: Union sets and merge dictionaries
- The idea: ** merges mappings, and the last one wins
- What each line shows
- Step 4: Stay lazy with a generator expression
- The idea: build values only when someone asks for them
- What the output shows
- Gotcha 1: a bare starred generator in a call is one argument
- Gotcha 2: the generator form does not pass send() through
- Step 5: Learn the errors before you meet them
- Strings do not fail; they quietly spread into letters
- Runtime errors: not iterable, not a mapping
- Syntax errors: one star, at the top, with no parentheses
- Step 6: Put it together in a small module
- Step 7: Test it, then test your tests
- The unit tests
- Mutation check: do the tests notice a bug?
- Step 8: See how it works and measure the speed
- What Python builds
- Is it faster?
- Reading the list results
- Reading the dictionary results
- Reading the scaling table
- Step 9: Adopt it safely in a project that still supports older Pythons
- One use breaks the whole file on older versions
- Let your tools catch it before your users do
- mypy also understands the new forms
- A guard that does not work: ast.parse with feature_version
- A simple rule for choosing
- Check the whole thing end to end
- Where to go next
A few words first, so nothing below is a mystery. An iterable is anything you can loop over: a list, a tuple, a set, a string, a generator. A comprehension is a short expression that builds a list, set or dictionary by looping, such as [n * 2 for n in numbers]. Unpacking means spreading the items of an iterable into a new container with *, or the key and value pairs of a dictionary with **, as in [*first, *second]. Flattening means turning a list of lists into one list.
Combining an arbitrary number of lists or dictionaries is an everyday job: pages of results from an API, settings from several layers of configuration, tags collected from many posts. Until now, unpacking could only combine a fixed number of them, because you had to type each one out. PEP 798 states the gap plainly: “if we want to similarly combine an arbitrary number of iterables, we cannot use unpacking in this same way.” The What’s New in Python 3.15 page describes the new syntax as “a direct alternative to nested comprehensions, itertools.chain(), and itertools.chain.from_iterable().”
What you will build
You will write a small module called scanreport.py that handles the kind of data a vulnerability scanner returns: pages of findings, the CVE identifiers inside them, and layered settings. All of the data in this tutorial is invented, and the CVE identifiers are made-up examples, not real vulnerabilities. Along the way you will learn the four forms of the new syntax, which are easiest to remember as a table:
| What you want | New in Python 3.15 | The older way |
|---|---|---|
| One flat list | [*part for part in parts] |
[x for part in parts for x in part] |
| One set (a union) | {*part for part in parts} |
{x for part in parts for x in part} |
| One dictionary (a merge) | {**d for d in dicts} |
{k: v for d in dicts for k, v in d.items()} |
| A lazy stream of values | (*part for part in parts) |
(x for part in parts for x in part) |
The first three older equivalents come from the What’s New page, which shows the list, set and dictionary versions side by side with the new ones; the generator row follows the same pattern, as in the PEP’s own examples. The rest of this tutorial shows why the new column is worth learning, and where it can bite you.
Prerequisites
Python 3.15 or newer. The language reference marks the feature “Added in version 3.15,” so it does not exist in 3.14 or earlier. Every result below was produced with Python 3.15.1, the Windows embeddable build from python.org, on Windows 11. The code is plain Python with no operating-system calls, so it should behave the same on macOS and Linux. Python knowledge: you should be able to read a basic list comprehension and know what a dictionary is. Nothing else is assumed.
Optional tools. Step 7 uses pytest, installed with python -m pip install pytest==9.1.1. Step 1 and Step 9 compare against an older Python (we used 3.13.14), and Step 9 uses Ruff 0.17.2 and mypy 2.4.0. If you only want to learn the syntax, you can skip the older Python and both tools. This is the exact set of versions we used:
python 3.15: Python 3.15.1
python 3.13: Python 3.13.14
pytest: pytest 9.1.1
ruff: ruff 0.17.2
mypy: mypy 2.4.0 (compiled: yes)
How to follow along. Make an empty folder. Each code block below starts with a line that names its file, so save it under that name in the folder. Then run the command shown after it. In the commands, python must be your Python 3.15 interpreter. If it is an older one, Step 1 tells you right away. The output blocks are real captures; only the timings will differ on your machine.
Step 1: Check your Python and see the error older versions give
Before using new syntax, confirm that your interpreter knows it. This script asks Python to compile a one-line expression and reports what happens. It uses compile() on a string on purpose: a syntax Python does not understand, written directly in a file, would stop the whole file from loading, and you would never see our message. Compiling a string lets the script catch the failure and print it.
"""step01_version.py: which Python is this, and does it accept unpacking in a comprehension?"""
import sys
SNIPPET = "[*part for part in [[1, 2], [3]]]"
print("Python", sys.version.split()[0])
try:
code = compile(SNIPPET, "<snippet>", "eval")
except SyntaxError as exc:
print("SyntaxError:", exc.msg)
else:
print(SNIPPET, "->", eval(code))
Run it:
python step01_version.py
On Python 3.15.1 the expression compiles and runs:
Python 3.15.1
[*part for part in [[1, 2], [3]]] -> [1, 2, 3]
On Python 3.13.14, the same script reports a syntax error instead:
Python 3.13.14
SyntaxError: iterable unpacking cannot be used in comprehension
Check that it worked. You want the first output: your version, followed by -> [1, 2, 3]. If you see the SyntaxError, you are running an older Python. Install 3.15, or call the right interpreter by its full path, and run the script again before going on. The error message is worth remembering: iterable unpacking cannot be used in comprehension is what every Python before 3.15 says to this syntax.
Step 2: Flatten a list of lists
The idea: the star spreads each inner list
A normal comprehension produces one result item for each item it loops over. When the result expression starts with a star, Python takes that expression as an iterable and spreads its items into the result, producing zero or more items for each loop pass. The language reference puts it this way: “If the expression of a list or set comprehension is starred, the result will be unpacked to produce zero or more elements.” It adds that this is “often used for ‘flattening’ lists” and gives examples. PEP 798 offers a handy way to picture it: where a normal comprehension would call append for each item, the starred one calls extend (“which uses extend instead of append”).
Try it
"""step02_flatten.py: flatten a list of lists, and see that the star spreads each inner list."""
import itertools
students = ["Petr", "Blaise", "Jarka"]
teachers = ["Salim", "Bartosz"]
print("docs example :", [*people for people in [students, teachers]])
lists = [[1, 2], [3, 4], [5]]
new = [*numbers for numbers in lists]
nested = [n for numbers in lists for n in numbers]
chained = list(itertools.chain.from_iterable(lists))
looped = []
for chunk in lists:
looped.extend(chunk)
print("star comprehension :", new)
print("nested for clauses :", nested)
print("chain.from_iterable:", chained)
print("loop with extend :", looped)
print("all four equal :", new == nested == chained == looped)
print("empty lists vanish :", [*x for x in [[], [7], [], [8, 9]]])
print("any iterable works :", [*range(n) for n in (1, 2, 3)])
print("calls work too :", [*text.split("-") for text in ["a-b", "c", "d-e-f"]])
print("with a condition :", [*row for row in [[1], [], [2, 3], [4]] if len(row) >= 2])
data = [[[1], [2]], [[3]]]
print("two levels deep :", [*[*cell for cell in row] for row in data])
# The double-loop form is easy to write backwards: the OUTER loop comes first.
try:
[n for n in numbers for numbers in lists]
except NameError as exc:
print("reversed clauses : NameError:", exc)
python step02_flatten.py
docs example : ['Petr', 'Blaise', 'Jarka', 'Salim', 'Bartosz']
star comprehension : [1, 2, 3, 4, 5]
nested for clauses : [1, 2, 3, 4, 5]
chain.from_iterable: [1, 2, 3, 4, 5]
loop with extend : [1, 2, 3, 4, 5]
all four equal : True
empty lists vanish : [7, 8, 9]
any iterable works : [0, 0, 1, 0, 1, 2]
calls work too : ['a', 'b', 'c', 'd', 'e', 'f']
with a condition : [2, 3]
two levels deep : [1, 2, 3]
reversed clauses : NameError: name 'numbers' is not defined
What each line shows
The first line repeats the example from the language reference and prints the same five names. The next five lines flatten a list of number lists four different ways and compare them: the new star form, the nested for clauses, itertools.chain.from_iterable, and a plain loop that calls extend. The final comparison prints True, so on this input all four agree. You will see the same thing proved on random data in Step 7.
The following lines show what the star form accepts. Empty inner lists simply vanish, because spreading nothing adds nothing. Any iterable works, not only lists: range(n) was spread three times with different n. The expression after the star can be a call, as in text.split("-"). A condition such as if len(row) >= 2 applies to each outer item before it is spread, so only the row [2, 3] survived. You can even nest the form to flatten two levels at once, although at that point a plain loop is often easier to read, so use the nesting sparingly.
The last line shows one reason the new form is easier to get right. In the older double loop, the outer loop comes first: [n for numbers in lists for n in numbers]. We wrote the clauses in reverse, and Python answered with NameError: name 'numbers' is not defined, because it tried to loop over numbers before the other clause had created it. PEP 798 notes that the double-loop version “is one that students often get wrong, the natural impulse for many students being to reverse the order of the for clauses.” With the star form there is only one loop to read.
Check that it worked. The line all four equal : True is your proof. If any value prints False, compare the four result lines to find the odd one out.
Step 3: Union sets and merge dictionaries
The idea: ** merges mappings, and the last one wins
A set comprehension with a star builds the union of many iterables. A dictionary comprehension with a double star merges many dictionaries. When two dictionaries have the same key, the later one wins. The language reference states the rule for dictionaries: “if the same key is specified multiple times, the associated value in the resulting dictionary will be the last one specified.” That makes this form a natural fit for layered settings, where defaults come first and overrides come last.
"""step03_sets_dicts.py: union sets and merge dictionaries with ** inside a comprehension."""
system_defaults = {"color": "blue", "count": 8}
user_defaults = {"color": "yellow"}
overrides = {"count": 5}
print("docs example :", {**d for d in [system_defaults, user_defaults, overrides]})
tag_sets = [{"web", "prod"}, {"prod", "eu"}, {"eu", "db"}]
print("set union :", sorted({*tags for tags in tag_sets}))
defaults = {"timeout": 30, "retries": 3, "region": "eu"}
staging = {"timeout": 10, "debug": True}
override = {"retries": 0}
layers = [defaults, staging, override]
merged = {**layer for layer in layers}
print("merged :", merged)
print("key order :", list(merged))
print("same as 3 stars :", merged == {**defaults, **staging, **override})
print("same as | | | :", merged == defaults | staging | override)
print("inputs untouched:", defaults)
try:
{**layer for layer in [defaults, None, override]}
except TypeError as exc:
print("a None layer : TypeError:", exc)
print("skip empty ones :", {**layer for layer in [defaults, None, {}, override] if layer})
print("any hashable key:", {**d for d in [{1: "a"}, {(2, 3): "b"}]})
try:
dict(**{1: "a"})
except TypeError as exc:
print("in a call : TypeError:", exc)
# The copy is shallow: nested values are shared, not duplicated.
base = {"limits": {"cpu": 1}}
copy = {**layer for layer in [base]}
copy["limits"]["cpu"] = 99
print("shared nested :", base["limits"]["cpu"], "(the original changed too)")
python step03_sets_dicts.py
docs example : {'color': 'yellow', 'count': 5}
set union : ['db', 'eu', 'prod', 'web']
merged : {'timeout': 10, 'retries': 0, 'region': 'eu', 'debug': True}
key order : ['timeout', 'retries', 'region', 'debug']
same as 3 stars : True
same as | | | : True
inputs untouched: {'timeout': 30, 'retries': 3, 'region': 'eu'}
a None layer : TypeError: 'NoneType' object is not a mapping
skip empty ones : {'timeout': 30, 'retries': 0, 'region': 'eu'}
any hashable key: {1: 'a', (2, 3): 'b'}
in a call : TypeError: keywords must be strings
shared nested : 99 (the original changed too)
What each line shows
The first line is the dictionary example from the language reference, and it prints the same {'color': 'yellow', 'count': 5}: the user default replaced the system default, then the override replaced the count. The set union keeps each tag once.
The settings example is closer to real life. Look at merged: timeout is 10 (staging beat the default of 30), retries is 0 (the override beat 3), and debug appears because only staging had it. Now look at the key order. timeout and retries are still first, in the positions where they first appeared, even though their values were replaced. A later value changes what a key holds, not where it sits. The merged result equals both the three-star literal and the | operator for dictionaries, and the original defaults dictionary was not touched.
Two traps appear in the lines below that. First, a missing configuration file often shows up as None, and **None is an error: TypeError: 'NoneType' object is not a mapping. Adding if layer at the end of the comprehension skips both None and empty layers, which is why the next line works. Second, the copy is shallow. We changed copy["limits"]["cpu"] and the original dictionary changed too, because both dictionaries hold the very same inner dictionary. If you need independent nested values, wrap the result in copy.deepcopy(...). One bonus: the merged keys can be any hashable value, such as the number 1 or a tuple, which is not true when you spread a dictionary into a function call: dict(**{1: "a"}) raised TypeError: keywords must be strings.
Check that it worked. The merged dictionary should have timeout 10, retries 0, region eu and debug True, in that key order, and the line that follows it should say True for both comparisons.
Step 4: Stay lazy with a generator expression
The idea: build values only when someone asks for them
A list comprehension builds the whole list immediately. A generator expression, which uses parentheses, builds nothing until you ask for the next value. That matters when each loop pass is expensive, such as a request to a paginated API, because you can stop early and skip the remaining requests. The star form works with generator expressions too. The script below fakes a paginated API that counts how many pages were requested.
"""step04_generators.py: generator expressions stay lazy, so you can stop reading early."""
import itertools
class FakeApi:
"""Pretends to be a paginated API and counts how many pages were requested."""
def __init__(self, pages):
self.pages = pages
self.requests = 0
def pages_iter(self):
for number, findings in enumerate(self.pages, start=1):
self.requests += 1
yield {"page": number, "findings": findings}
PAGES = [["a1", "a2", "a3"], ["b1", "b2", "b3"], ["c1", "c2", "c3"], ["d1"]]
api = FakeApi(PAGES)
everything = [*page["findings"] for page in api.pages_iter()]
print("list comprehension :", len(everything), "findings,", api.requests, "page requests")
api = FakeApi(PAGES)
stream = (*page["findings"] for page in api.pages_iter())
print("generator created :", api.requests, "page requests so far")
first_three = list(itertools.islice(stream, 3))
print("first three :", first_three, "->", api.requests, "page request")
fourth = next(stream)
print("fourth finding :", fourth, "->", api.requests, "page requests")
# A bare starred generator as the only argument of a call is ONE generator argument.
def count_args(*args):
return len(args), type(args[0]).__name__
rows = [[1, 2], [3]]
print("f(*row for ...) :", count_args(*row for row in rows))
print("f(*[*row for ...]) :", count_args(*[*row for row in rows]))
# The generator form re-yields values; it does not delegate send() like yield from does.
def inner():
received = yield "first"
print(" inner received:", received)
yield "second"
star = (*inner() for _ in range(1))
print("star form :", next(star))
print("star form :", star.send("hello"))
def delegate():
yield from inner()
plain = delegate()
print("yield from :", next(plain))
print("yield from :", plain.send("hello"))
python step04_generators.py
list comprehension : 10 findings, 4 page requests
generator created : 0 page requests so far
first three : ['a1', 'a2', 'a3'] -> 1 page request
fourth finding : b1 -> 2 page requests
f(*row for ...) : (1, 'generator')
f(*[*row for ...]) : (3, 'int')
star form : first
inner received: None
star form : second
yield from : first
inner received: hello
yield from : second
What the output shows
The list comprehension had to request all four pages to build its list of ten findings. The generator expression requested none when it was created. Taking the first three findings with itertools.islice needed only the first page, one request. Asking for a fourth finding made the generator request the second page. Reading only as much as you need is the point of this form.
Gotcha 1: a bare starred generator in a call is one argument
Look at the two f(...) lines. Passing *row for row in rows as the only argument to a function does not spread anything into separate arguments. The function received one argument, and its type was generator. PEP 798 says this on purpose, noting that “f(*x for x in it) is equivalent to f((*x for x in it))”. The reason is that a generator expression has always been allowed without extra parentheses when it is a function’s only argument, and the PEP keeps that rule. Before 3.15 this line was a SyntaxError, so code that used to fail now runs and does something different from what it looks like. To spread the elements into separate arguments, build the list first and star it, as the second line does: f(*[*row for row in rows]) passed three integers.
Gotcha 2: the generator form does not pass send() through
The last six lines compare the star generator with yield from using a small generator that prints what it receives. With yield from, the value sent in with send("hello") reaches the inner generator. With the star form it does not, and the inner generator received None. The PEP defines the star form as a loop that yields each value, and it lists .send(), .throw() and .close() as the behaviors that a switch to yield from would change. Unless you use generators as two-way coroutines, you can ignore this, and now you know where the difference is if you ever do.
Step 5: Learn the errors before you meet them
This script tries twelve snippets and prints exactly what Python says about each one. It compiles every snippet from a string, so a syntax error is reported as a message and does not stop the script. At the end it shows the two fixes you will use most.
"""step05_errors.py: what breaks, and the exact message you will see."""
def attempt(source):
try:
code = compile(source, "<snippet>", "eval")
except SyntaxError as exc:
return f"SyntaxError: {exc.msg}"
try:
return f"ok: {eval(code)!r}"
except Exception as exc:
return f"{type(exc).__name__}: {exc}"
CASES = [
'[*s for s in ["ab", "cd"]]',
"[*n for n in [1, 2]]",
"[*n for n in [None]]",
"{*n for n in [1]}",
"{**x for x in [[1]]}",
"{**x for x in [None]}",
"[*a, *b for a, b in [([1], [2])]]",
'{"k": 1, **d for d in [{}]}',
'{**d, "k": 1 for d in [{}]}',
"[**d for d in [{}]]",
"(**d for d in [{}])",
"[(*x) for x in [[1]]]",
]
for source in CASES:
print(f"{source}\n {attempt(source)}")
print("--- the fixes")
values = ["solo", ["a", "b"], ("c",)]
print([*([v] if isinstance(v, str) else v) for v in values])
pages = [{"items": [1, 2]}, {"items": None}, {}]
print([*(page.get("items") or []) for page in pages])
python step05_errors.py
[*s for s in ["ab", "cd"]]
ok: ['a', 'b', 'c', 'd']
[*n for n in [1, 2]]
TypeError: Value after * must be an iterable, not int
[*n for n in [None]]
TypeError: Value after * must be an iterable, not NoneType
{*n for n in [1]}
TypeError: 'int' object is not iterable
{**x for x in [[1]]}
TypeError: 'list' object is not a mapping
{**x for x in [None]}
TypeError: 'NoneType' object is not a mapping
[*a, *b for a, b in [([1], [2])]]
SyntaxError: did you forget parentheses around the comprehension target?
{"k": 1, **d for d in [{}]}
SyntaxError: invalid syntax
{**d, "k": 1 for d in [{}]}
SyntaxError: cannot use dict unpacking here
[**d for d in [{}]]
SyntaxError: cannot use dict unpacking in list comprehension
(**d for d in [{}])
SyntaxError: cannot use dict unpacking in generator expression
[(*x) for x in [[1]]]
SyntaxError: cannot use starred expression here
--- the fixes
['solo', 'a', 'b', 'c']
[1, 2]
Strings do not fail; they quietly spread into letters
The first snippet is the dangerous one, because it raises no error. A string is an iterable of characters, so [*s for s in ["ab", "cd"]] returned ['a', 'b', 'c', 'd']. If a field in your data can be either a single string or a list of strings, which happens often in JSON, spreading it will give you letters. The first fix in the output handles it: wrap lone strings in a list before they reach the star, as in [*([v] if isinstance(v, str) else v) for v in values].
Runtime errors: not iterable, not a mapping
Spreading something that is not an iterable raises TypeError. The message depends on the kind of comprehension: lists say Value after * must be an iterable, not int, while the set comprehension said 'int' object is not iterable. For dictionaries, anything that is not a mapping fails with 'list' object is not a mapping, and note that a list of key and value pairs is not a mapping. The most common real-world cause is None, such as a page whose items field is null. The second fix in the output shows the usual cure: page.get("items") or [] turns a missing key or a None into an empty list.
Syntax errors: one star, at the top, with no parentheses
The syntax errors follow from the shape the new grammar allows: the whole element of the comprehension is one starred expression, and nothing else. In the PEP’s words, “* and ** should only be allowed at the top-most level of the expression in the comprehension”. Two stars in one element, as in [*a, *b for ...], is not allowed (Python’s message asks whether you forgot parentheses around the comprehension target; if you want two spreads, a plain display [*a, *b] already does that). Mixing a key and value pair with ** is rejected in both orders. ** is only for dictionary comprehensions, so a list or generator expression says so. Finally, do not wrap the starred expression in parentheses: [(*x) for x in ...] says cannot use starred expression here.
Check that it worked. You should see 12 snippets with their results, followed by the two fix lines ['solo', 'a', 'b', 'c'] and [1, 2]. The exact wording of messages can change between Python releases, so treat the quoted messages as what 3.15.1 printed.
Step 6: Put it together in a small module
Now we use everything in one place. First, some invented scanner output: five pages where one page is empty, one has None where findings should be, and one has no findings key at all, plus three layers of settings. Messy input like this is exactly why the guards from Step 5 matter.
"""sample_data.py: made-up scanner output, shared by the demo scripts and the tests."""
PAGES = [
{
"page": 1,
"findings": [
{"id": "F-101", "host": "web-1", "severity": "high", "cves": ["CVE-2026-0001", "CVE-2026-0002"]},
{"id": "F-102", "host": "web-2", "severity": "low", "cves": []},
],
},
{"page": 2, "findings": []},
{"page": 3, "findings": None},
{"page": 4},
{
"page": 5,
"findings": [
{"id": "F-103", "host": "db-1", "severity": "high", "cves": ["CVE-2026-0002"]},
{"id": "F-104", "host": "db-1", "severity": "medium", "cves": ["CVE-2026-0003"]},
],
},
]
DEFAULTS = {"timeout": 30, "retries": 3, "region": "eu"}
STAGING = {"timeout": 10, "debug": True}
OVERRIDE = {"retries": 0}
Next comes the module itself. It has five small functions, one for each idea you have learned.
"""scanreport.py: combine scan results with Python 3.15 unpacking in comprehensions."""
from collections.abc import AsyncIterator, Iterable, Iterator, Mapping
def all_findings(pages: Iterable[Mapping]) -> list[dict]:
"""Flatten the findings of every page into one list, keeping page order."""
return [*(page.get("findings") or []) for page in pages]
def stream_findings(pages: Iterable[Mapping]) -> Iterator[dict]:
"""The same flattening, but lazy: a page is read only when the next finding is needed."""
return (*(page.get("findings") or []) for page in pages)
async def all_findings_async(pages: AsyncIterator[Mapping]) -> list[dict]:
"""The same flattening for an async iterator of pages."""
return [*(page.get("findings") or []) async for page in pages]
def cve_ids(findings: Iterable[Mapping]) -> set[str]:
"""Every CVE id mentioned by any finding, once."""
return {*finding.get("cves", ()) for finding in findings}
def merge_settings(*layers: Mapping | None) -> dict:
"""Merge settings layers. Later layers win; None and empty layers are skipped."""
return {**layer for layer in layers if layer}
all_findings flattens pages into one list, and the (page.get("findings") or []) guard from Step 5 takes care of the empty, None and missing pages. stream_findings is the lazy twin, built with a generator expression. all_findings_async shows that the form also works with async for over an asynchronous iterator of pages. cve_ids uses a set comprehension to collect each CVE identifier once. merge_settings merges layers, with later layers winning and None or empty layers skipped.
This script uses all five and prints the results:
"""step06_project.py: use scanreport on the sample data."""
import asyncio
import itertools
import scanreport
from sample_data import DEFAULTS, OVERRIDE, PAGES, STAGING
findings = scanreport.all_findings(PAGES)
print("findings :", [f["id"] for f in findings])
print("unique CVEs :", sorted(scanreport.cve_ids(findings)))
print("settings :", scanreport.merge_settings(DEFAULTS, None, STAGING, OVERRIDE))
def counted(pages, seen):
for page in pages:
seen.append(page["page"])
yield page
seen = []
first_two = list(itertools.islice(scanreport.stream_findings(counted(PAGES, seen)), 2))
print("first two :", [f["id"] for f in first_two], "| pages read:", seen)
async def async_pages():
for page in PAGES:
await asyncio.sleep(0)
yield page
async_result = asyncio.run(scanreport.all_findings_async(async_pages()))
print("async version:", [f["id"] for f in async_result])
python step06_project.py
findings : ['F-101', 'F-102', 'F-103', 'F-104']
unique CVEs : ['CVE-2026-0001', 'CVE-2026-0002', 'CVE-2026-0003']
settings : {'timeout': 10, 'retries': 0, 'region': 'eu', 'debug': True}
first two : ['F-101', 'F-102'] | pages read: [1]
async version: ['F-101', 'F-102', 'F-103', 'F-104']
Four findings came out of five pages, in page order, with the empty, None and missing pages skipped. The unique CVE list has three entries, although four CVE mentions appear in the data, because CVE-2026-0002 is mentioned by two findings. The merged settings match what you saw in Step 3. Asking for only two findings through the lazy version read just page 1. The async version produced the same four findings as the list version.
Step 7: Test it, then test your tests
The unit tests
Tests turn a one-time look at the printed output into a check that keeps working. This file has nine tests. Most check one rule from the module. The last one is a random comparison test: for 500 different random seeds it builds random lists and dictionaries, then asserts that the star forms give exactly the same results as the older idioms, including the key order of merged dictionaries.
"""test_scanreport.py: unit tests plus a random comparison with the older idioms."""
import asyncio
import functools
import itertools
import operator
import random
import scanreport
from sample_data import PAGES
def test_all_findings_keep_order_and_skip_empty_or_missing_pages():
assert [f["id"] for f in scanreport.all_findings(PAGES)] == ["F-101", "F-102", "F-103", "F-104"]
def test_stream_matches_the_list_version():
assert list(scanreport.stream_findings(PAGES)) == scanreport.all_findings(PAGES)
def test_stream_is_lazy():
pulled = []
def pages():
for page in PAGES:
pulled.append(page["page"])
yield page
stream = scanreport.stream_findings(pages())
assert pulled == []
next(stream)
assert pulled == [1]
def test_async_version_matches():
async def pages():
for page in PAGES:
yield page
assert asyncio.run(scanreport.all_findings_async(pages())) == scanreport.all_findings(PAGES)
def test_cve_ids_are_unique():
found = scanreport.cve_ids(scanreport.all_findings(PAGES))
assert found == {"CVE-2026-0001", "CVE-2026-0002", "CVE-2026-0003"}
def test_cve_ids_keep_every_id_of_one_finding():
assert scanreport.cve_ids([{"cves": ["CVE-A", "CVE-B"]}, {"cves": []}, {}]) == {"CVE-A", "CVE-B"}
def test_merge_later_layer_wins_and_none_is_skipped():
merged = scanreport.merge_settings({"a": 1, "b": 2}, None, {}, {"b": 3})
assert merged == {"a": 1, "b": 3}
assert list(merged) == ["a", "b"]
def test_merge_does_not_change_its_inputs():
first = {"a": 1}
scanreport.merge_settings(first, {"a": 2})
assert first == {"a": 1}
def test_star_forms_match_the_older_idioms_on_random_data():
for seed in range(500):
rng = random.Random(seed)
lists = [[rng.randrange(100) for _ in range(rng.randrange(5))] for _ in range(rng.randrange(6))]
assert [*p for p in lists] == [x for p in lists for x in p] == list(itertools.chain.from_iterable(lists)), seed
assert {*p for p in lists} == set(itertools.chain.from_iterable(lists)), seed
dicts = [{rng.randrange(8): rng.randrange(100) for _ in range(rng.randrange(5))} for _ in range(rng.randrange(6))]
expected = {}
for d in dicts:
expected.update(d)
merged = {**d for d in dicts}
assert merged == expected == functools.reduce(operator.ior, dicts, {}), seed
assert list(merged) == list(expected), seed
python -m pytest -q test_scanreport.py
......... [100%]
9 passed in 0.02s
Nine passed. The random comparison is what turns Step 2’s all four equal line into evidence: across 500 random inputs, with empty lists and repeated dictionary keys in them, the new syntax and the older idioms never disagreed.
Mutation check: do the tests notice a bug?
Passing tests only mean something if they would fail when the code is wrong. A mutation check tests that: it breaks the code on purpose, one small change at a time, runs the tests, and reports whether they noticed. This script applies six deliberate bugs to scanreport.py, restores the file afterward, and counts the failing runs.
"""step07_mutations.py: break scanreport.py on purpose and check that the tests notice."""
import os
import subprocess
import sys
from pathlib import Path
SOURCE = Path("scanreport.py")
original = SOURCE.read_text(encoding="utf-8")
MUTANTS = {
"missing findings key not handled": (
'return [*(page.get("findings") or []) for page in pages]',
'return [*page["findings"] for page in pages]',
),
"None findings not handled": (
'return [*(page.get("findings") or []) for page in pages]',
'return [*page.get("findings", []) for page in pages]',
),
"settings: first layer wins": (
"return {**layer for layer in layers if layer}",
"return {**layer for layer in reversed(layers) if layer}",
),
"settings: None layers kept": (
"return {**layer for layer in layers if layer}",
"return {**layer for layer in layers}",
),
"stream built eagerly": (
'return (*(page.get("findings") or []) for page in pages)',
'return iter([*(page.get("findings") or []) for page in pages])',
),
"only the first CVE kept": (
'{*finding.get("cves", ()) for finding in findings}',
'{*finding.get("cves", ())[:1] for finding in findings}',
),
}
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
failed = 0
try:
for name, (old, new) in MUTANTS.items():
assert original.count(old) == 1, name
SOURCE.write_text(original.replace(old, new), encoding="utf-8")
run = subprocess.run(
[sys.executable, "-B", "-m", "pytest", "-q", "-p", "no:cacheprovider", "test_scanreport.py"],
capture_output=True, text=True, env=env,
)
last = run.stdout.strip().splitlines()[-1]
caught = run.returncode != 0
failed += caught
print(f"{'CAUGHT' if caught else 'MISSED':<7}{name:<36}{last}")
finally:
SOURCE.write_text(original, encoding="utf-8")
print(f"{failed} of {len(MUTANTS)} mutants caught")
python step07_mutations.py
The first time I ran it, I had only eight tests, and it reported this:
CAUGHT missing findings key not handled 4 failed, 4 passed in 0.06s
CAUGHT None findings not handled 4 failed, 4 passed in 0.06s
CAUGHT settings: first layer wins 1 failed, 7 passed in 0.06s
CAUGHT settings: None layers kept 1 failed, 7 passed in 0.06s
CAUGHT stream built eagerly 1 failed, 7 passed in 0.06s
MISSED only the first CVE kept 8 passed in 0.02s
5 of 6 mutants caught
The last mutant, only the first CVE kept, survived. The reason was my own sample data: CVE-2026-0002 belongs to both F-101 and F-103, so keeping just the first CVE of each finding still produced the same set. The bug hid behind lucky data. The fix is the test named test_cve_ids_keep_every_id_of_one_finding, which uses a finding whose two identifiers appear nowhere else. With that test in place, which is the version of the file shown above, the run looks like this:
CAUGHT missing findings key not handled 4 failed, 5 passed in 0.06s
CAUGHT None findings not handled 4 failed, 5 passed in 0.06s
CAUGHT settings: first layer wins 1 failed, 8 passed in 0.06s
CAUGHT settings: None layers kept 1 failed, 8 passed in 0.06s
CAUGHT stream built eagerly 1 failed, 8 passed in 0.06s
CAUGHT only the first CVE kept 1 failed, 8 passed in 0.06s
6 of 6 mutants caught
Check that it worked. You want 9 passed from pytest and 6 of 6 mutants caught from the mutation script. If a mutant is missed on your machine, the tests have a blind spot worth fixing, just as we did.
Step 8: See how it works and measure the speed
What Python builds
Python turns a comprehension into bytecode, and the standard dis module can show it. This script lists which list, set and dictionary building instructions each form uses, and prints the syntax tree for two forms.
"""step08_internals.py: what Python builds for a starred comprehension."""
import ast
import dis
BUILDERS = {"LIST_APPEND", "LIST_EXTEND", "SET_ADD", "SET_UPDATE", "MAP_ADD", "DICT_UPDATE"}
SOURCES = [
"[p for p in pages]",
"[*p for p in pages]",
"[x for p in pages for x in p]",
"{*p for p in pages}",
"{**d for d in dicts}",
]
for source in SOURCES:
ops = [i.opname for i in dis.get_instructions(compile(source, "<s>", "eval")) if i.opname in BUILDERS]
print(f"{source:<32}{ops}")
print("--- syntax tree")
print(ast.dump(ast.parse("[*p for p in pages]", mode="eval").body, indent=1))
dict_node = ast.parse("{**d for d in dicts}", mode="eval").body
print(ast.dump(dict_node, indent=1))
print("DictComp.value is None:", dict_node.value is None)
python step08_internals.py
[p for p in pages] ['LIST_APPEND']
[*p for p in pages] ['LIST_EXTEND']
[x for p in pages for x in p] ['LIST_APPEND']
{*p for p in pages} ['SET_UPDATE']
{**d for d in dicts} ['DICT_UPDATE']
--- syntax tree
ListComp(
elt=Starred(
value=Name(id='p')),
generators=[
comprehension(
target=Name(id='p', ctx=Store()),
iter=Name(id='pages'),
is_async=0)])
DictComp(
key=Name(id='d'),
generators=[
comprehension(
target=Name(id='d', ctx=Store()),
iter=Name(id='dicts'),
is_async=0)])
DictComp.value is None: True
A normal comprehension appends one item per pass (LIST_APPEND). The star form uses LIST_EXTEND instead, and the set and dictionary forms use SET_UPDATE and DICT_UPDATE. The dis documentation says LIST_EXTEND implements list.extend(STACK[-i], seq) and carries the note “Used to build lists.” That is the same call you would write by hand in a loop. The syntax tree explains the rest: the list form is an ordinary list comprehension whose element is a Starred node, and the dictionary form keeps the unpacked expression in key while value is None.
Is it faster?
Instead of guessing, we can measure. This script times every approach on lists and on dictionaries, in three shapes each: a few long inputs, a medium number of medium ones, and many short ones. Each method runs in three fresh processes; every process keeps the best of five timings, and the table shows the median of the three. The script also checks that every method returned exactly the same result. At the end, it compares two older idioms that are known to scale badly.
"""step09_bench.py: is the star form faster or slower than the older ways? One method per fresh process."""
import functools
import itertools
import operator
import statistics
import subprocess
import sys
import timeit
PROCESSES = 3
def extend_loop(lists):
out = []
for part in lists:
out.extend(part)
return out
def update_loop(dicts):
out = {}
for d in dicts:
out.update(d)
return out
def make_lists(count, size):
return [list(range(size)) for _ in range(count)]
def make_dicts(count, size):
half = size // 2
return [{j: i for j in range(i * half, i * half + size)} for i in range(count)]
def list_methods(lists):
return {
"star comprehension": lambda: [*part for part in lists],
"nested for clauses": lambda: [x for part in lists for x in part],
"chain.from_iterable": lambda: list(itertools.chain.from_iterable(lists)),
"extend loop": lambda: extend_loop(lists),
"reduce(iconcat)": lambda: functools.reduce(operator.iconcat, lists, []),
}
def dict_methods(dicts):
return {
"star comprehension": lambda: {**d for d in dicts},
"update loop": lambda: update_loop(dicts),
"items comprehension": lambda: {k: v for d in dicts for k, v in d.items()},
"reduce(ior), in place": lambda: functools.reduce(operator.ior, dicts, {}),
"reduce(or_), copies": lambda: functools.reduce(operator.or_, dicts, {}),
}
KINDS = {
"lists": (make_lists, list_methods, [(10, 100_000), (1_000, 1_000), (100_000, 10)]),
"dicts": (make_dicts, dict_methods, [(10, 20_000), (100, 2_000), (1_000, 200)]),
}
def child(kind, count, size, name):
make, methods, _ = KINDS[kind]
fn = methods(make(count, size))[name]
result = fn()
fingerprint = hash(tuple(result.items() if kind == "dicts" else result))
number = 1 if "or_" in name else 3
ms = min(timeit.repeat(fn, number=number, repeat=5)) / number * 1000
print(ms, fingerprint)
def parent():
print("Python", sys.version.split()[0], f"| median of {PROCESSES} fresh processes, each the best of 5 timings")
for kind, (make, methods, shapes) in KINDS.items():
for count, size in shapes:
medians, fingerprints = {}, set()
for name in methods(make(1, 1)):
runs = []
for _ in range(PROCESSES):
cmd = [sys.executable, __file__, "child", kind, str(count), str(size), name]
ms, fingerprint = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.split()
runs.append(float(ms))
fingerprints.add(fingerprint)
medians[name] = statistics.median(runs)
assert len(fingerprints) == 1, "methods disagree"
base = medians["star comprehension"]
print(f"{kind.upper()}: {count:,} x {size:,}")
for name, ms in medians.items():
print(f" {name:<24}{ms:9.2f} ms {ms / base:6.2f}x of star")
print("SCALING: sum(lists, []) and reduce(or_) against the star form (10 items or keys per part)")
print(f" {'parts':>6}{'sum ms':>10}{'star ms':>10}{'or_ ms':>10}{'star dict ms':>14}")
for count in (500, 1_000, 2_000, 4_000, 8_000):
lists = [list(range(10)) for _ in range(count)]
dicts = [{i * 10 + j: j for j in range(10)} for i in range(count)]
timings = [
min(timeit.repeat(fn, number=1, repeat=3)) * 1000
for fn in (
lambda: sum(lists, []),
lambda: [*part for part in lists],
lambda: functools.reduce(operator.or_, dicts, {}),
lambda: {**d for d in dicts},
)
]
print(f" {count:>6}" + "".join(f"{t:>{w}.2f}" for t, w in zip(timings, (10, 10, 10, 14))))
if len(sys.argv) > 1 and sys.argv[1] == "child":
child(sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), sys.argv[5])
else:
parent()
python step09_bench.py
Python 3.15.1 | median of 3 fresh processes, each the best of 5 timings
LISTS: 10 x 100,000
star comprehension 14.02 ms 1.00x of star
nested for clauses 25.45 ms 1.82x of star
chain.from_iterable 23.92 ms 1.71x of star
extend loop 14.06 ms 1.00x of star
reduce(iconcat) 14.08 ms 1.00x of star
LISTS: 1,000 x 1,000
star comprehension 19.97 ms 1.00x of star
nested for clauses 22.56 ms 1.13x of star
chain.from_iterable 21.12 ms 1.06x of star
extend loop 20.06 ms 1.00x of star
reduce(iconcat) 19.44 ms 0.97x of star
LISTS: 100,000 x 10
star comprehension 20.12 ms 1.00x of star
nested for clauses 24.24 ms 1.20x of star
chain.from_iterable 22.79 ms 1.13x of star
extend loop 20.71 ms 1.03x of star
reduce(iconcat) 20.82 ms 1.03x of star
DICTS: 10 x 20,000
star comprehension 4.27 ms 1.00x of star
update loop 4.14 ms 0.97x of star
items comprehension 6.03 ms 1.41x of star
reduce(ior), in place 4.20 ms 0.98x of star
reduce(or_), copies 12.78 ms 3.00x of star
DICTS: 100 x 2,000
star comprehension 4.69 ms 1.00x of star
update loop 4.28 ms 0.91x of star
items comprehension 6.42 ms 1.37x of star
reduce(ior), in place 4.62 ms 0.99x of star
reduce(or_), copies 80.06 ms 17.08x of star
DICTS: 1,000 x 200
star comprehension 4.75 ms 1.00x of star
update loop 4.56 ms 0.96x of star
items comprehension 6.54 ms 1.38x of star
reduce(ior), in place 4.77 ms 1.00x of star
reduce(or_), copies 517.16 ms 108.84x of star
SCALING: sum(lists, []) and reduce(or_) against the star form (10 items or keys per part)
parts sum ms star ms or_ ms star dict ms
500 1.25 0.01 3.19 0.08
1000 4.79 0.02 12.59 0.36
2000 18.84 0.04 64.59 0.62
4000 74.71 0.07 643.77 1.15
8000 332.63 0.14 3000.46 2.11
Reading the list results
The star form was as fast as the two older in-place idioms, the extend loop and functools.reduce with operator.iconcat, within about 3 percent in every shape. The nested double loop was slower in all three shapes, from 1.13 times the star time up to 1.82 times when the inner lists were long. chain.from_iterable sat in between, between 1.06 and 1.71 times. The difference is largest with a few very long lists, where the double loop appends element by element while the star form extends the result by a whole inner list per pass.
Reading the dictionary results
The star form was within about 10 percent of the best older idiom, the update loop, which was between 3 and 9 percent faster. The comprehension with .items() was about 1.4 times slower. The surprise is functools.reduce(operator.or_, dicts, {}): each | builds a brand new dictionary, copying everything gathered so far, so the cost grows with the square of the input. It was 3 times slower with 10 dictionaries and 109 times slower with 1,000.
Reading the scaling table
The last table doubles the number of parts, each holding ten items or keys. sum(lists, []) has the same quadratic problem: it went from 1.25 ms at 500 lists to 332.63 ms at 8,000, while the star form went from 0.01 ms to 0.14 ms. The reduce(or_) merge reached 3,000.46 ms at 8,000 dictionaries against 2.11 ms for the star form. If you ever find sum(list_of_lists, []) in existing code, this syntax is a safe, readable replacement.
An honest note about measuring. My first version of this script timed all five list methods one after another inside a single process. On one shape it reported the star form almost twice as slow as reduce, and a re-run moved the gap onto a different method. I did not chase the exact cause, but it was clearly the measuring setup and not the code being timed: giving each measurement its own fresh process made the numbers repeat to within a few percent, which is why the final script works that way. The absolute times are from one Windows machine running Python 3.15.1; yours will differ, so trust the ordering and the scaling, not the milliseconds. For a 14 ms operation, readability matters more than a 1.8 times difference.
Step 9: Adopt it safely in a project that still supports older Pythons
One use breaks the whole file on older versions
Syntax is checked when a file is compiled, before any of its code runs. So a single starred comprehension in a module makes the entire module unimportable on a Python that does not know the syntax, including functions that never use it. Here is a tiny module that uses both forms:
"""modern.py: code that only runs on Python 3.15 and newer."""
def flatten(pages):
return [*p for p in pages]
def merge(layers):
return {**d for d in layers}
Importing it on Python 3.13.14 fails at compile time. The traceback begins with the import modern line and the path of the file, and ends like this:
return [*p for p in pages]
^^
SyntaxError: iterable unpacking cannot be used in comprehension
On Python 3.15.1, the same import works and modern.flatten([[1], [2, 3]]) returned [1, 2, 3].
Let your tools catch it before your users do
Declare the oldest Python you support in pyproject.toml. Here is the one we used, which says the project supports Python 3.14 and newer:
# pyproject.toml
[project]
name = "legacy-tool"
version = "0.1.0"
requires-python = ">=3.14"
Ruff reads that setting. Its settings page recommends project.requires-python “as it’s based on Python packaging standards, and will be respected by other tools.” With that file next to modern.py, we ran:
ruff check --select E9,F modern.py
invalid-syntax: Cannot use iterable unpacking in a list comprehension on Python 3.14 (syntax was added in Python 3.15)
--> modern.py:5:13
|
4 | def flatten(pages):
5 | return [*p for p in pages]
| ^^
invalid-syntax: Cannot use dictionary unpacking in a dict comprehension on Python 3.14 (syntax was added in Python 3.15)
--> modern.py:9:13
|
8 | def merge(layers):
9 | return {**d for d in layers}
| ^^^
Found 2 errors.
Ruff 0.17.2 flagged both comprehensions as invalid syntax for Python 3.14 and even said which version added it. The type checker mypy does the same when you tell it the target version. Its configuration docs describe python_version as the version “used to parse and check the target program.” We ran mypy 2.4.0 on Python 3.13:
python -m mypy --python-version 3.14 modern.py
modern.py:5: error: Unpacking in comprehensions: requires Python 3.15 or newer (current target: Python 3.14) [syntax]
modern.py:9: error: Unpacking in comprehensions: requires Python 3.15 or newer (current target: Python 3.14) [syntax]
Found 2 errors in 1 file (checked 1 source file)
mypy also understands the new forms
With the target set to 3.15, mypy infers the right types for each form and rejects a spread of something that is not iterable. This file asks mypy to reveal the types:
"""typed.py: what mypy infers for starred comprehensions, and what it rejects."""
from typing import reveal_type
pages: list[list[int]] = [[1, 2], [3]]
layers: list[dict[str, int]] = [{"a": 1}, {"b": 2}]
names: list[str] = ["ab", "cd"]
numbers: list[int] = [1, 2]
reveal_type([*p for p in pages])
reveal_type({*p for p in pages})
reveal_type({**d for d in layers})
reveal_type((*p for p in pages))
reveal_type([*n for n in names]) # a str is an iterable, so mypy accepts it: list[str]
bad_list: list[int] = [*n for n in numbers] # an int is not an iterable
python -m mypy --python-version 3.15 typed.py
typed.py:9: note: Revealed type is "list[int]"
typed.py:10: note: Revealed type is "set[int]"
typed.py:11: note: Revealed type is "dict[str, int]"
typed.py:12: note: Revealed type is "typing.Generator[int, None, None]"
typed.py:13: note: Revealed type is "list[str]"
typed.py:15: error: List comprehension has incompatible type List[int]; expected List[Iterable[int]] [misc]
Found 1 error in 1 file (checked 1 source file)
The list, set and dictionary comprehensions have the types you would expect, and the generator expression is a Generator[int, None, None]. The error line catches the bug of spreading an int. Notice that mypy accepts the names line and infers list[str], since a string really is an iterable of strings. The type checker cannot warn you about the letters trap from Step 5, so keep that guard in your code.
A guard that does not work: ast.parse with feature_version
You might think ast.parse(source, feature_version=(3, 14)) could check whether code is safe for 3.14. It cannot. The documentation says that setting feature_version “will result in a ‘best-effort’ attempt to parse using that Python version’s grammar.” This short script shows what that means for the new syntax:
"""step10_gate.py: ast.parse(feature_version=...) does not guard against the new syntax."""
import ast
import sys
SOURCE = "def flatten(pages):\n return [*p for p in pages]\n"
print("Python", sys.version.split()[0])
try:
ast.parse(SOURCE, feature_version=(3, 14))
except SyntaxError as exc:
print("feature_version=(3, 14): SyntaxError:", exc.msg)
else:
print("feature_version=(3, 14): accepted, no error")
python step10_gate.py
Python 3.15.1
feature_version=(3, 14): accepted, no error
Python 3.15.1 accepted the new syntax even when asked to parse as 3.14. Use Ruff or mypy with a target version, and run your tests on the oldest Python you support, instead.
A simple rule for choosing
If your project still supports Python 3.14 or older, keep using itertools.chain.from_iterable for lists and a loop with update for dictionaries. Switch to the new syntax in the same change that raises requires-python to 3.15, and let Ruff confirm that nothing else is out of date. Applications you deploy yourself can adopt it as soon as every environment runs 3.15.
Check the whole thing end to end
Run these four commands in the folder with all of the files. Together they confirm your interpreter, the module, the tests and the tests of the tests:
python step01_version.py
python step06_project.py
python -m pytest -q test_scanreport.py
python step07_mutations.py
You should see -> [1, 2, 3] from the first, the four findings and the lazy page count from the second, 9 passed from the third, and 6 of 6 mutants caught from the last. If all four match, you have a working understanding of unpacking in comprehensions and a small module you can reuse.
Where to go next
Python 3.15 added several small features that work well together. To keep a merged configuration from being changed by accident, read our tutorial on frozendict in Python 3.15, and use its deep_freeze idea on the dictionary you get from {**layer for layer in layers}. To tell a missing argument from None, see the sentinel() tutorial, which pairs well with the None layers from Step 3. For safe query building with another 3.15 feature, there is the t-strings tutorial, and for a different kind of laziness in 3.15, see lazy imports for faster startup.
For the official word, read PEP 798, which explains the design and the options it rejected, the language reference section on unpacking in comprehensions, and the What’s New entry. A good exercise: find a loop in your own code that calls extend or update inside a for statement, rewrite it with the new syntax, and check it with a random comparison test like the one in Step 7.








No Comment! Be the first one.