30 Hidden Gems in Python 3
30 Hidden Gems in Python 3
com
ABOUT OPENSOURCE.COM
What is Opensource.com?
MOSHE ZADKA
CHAPTERS
Introduction
THE RELEASE OF PYTHON 3, a backwards incompatible
version of Python, was a
news-making event. As Python rose in popularity, every version since has also been an event.
From async to the so-called “walrus operator” (the := looks like walrus eyes and teeth),
Pythonistas have been atwitter before, after, and during every single major release.
But what about the features that didn’t make the news?
In each one of those releases, there are hidden gems. Small improvements to the standard
library. A little improved ergonomics in the interpreter. Maybe even a new operator, one that
is not important to be on the front page.
Python 3 has been out since 2008, and it has had ten minor releases between 3.0 and 3.9.
Each of those releases packed more features than most people know. Some of those are
still little known.
The major challenge is not to find three cool things first released in a new version of Py-
thon. It’s not even to find three cool things that few people use. The challenge is how to pick
just three from all the delightful options.
Enjoy these curated picks. Here are 30 features, three from each of the first ten versions of
Python 3, that you might want to start using.
def make_accumulator(): This means that in January 2021, five articles were published
return _Accumulator() on the first day. On the second day, three more articles were
published, bringing the total to 8. On the third day, two more
While admittedly somewhat verbose, this does work: articles were published.
Months can have 28, 30, or 31 days. How hard is it to ex-
acc = make_accumulator() tract the month, day, and total articles?
print("1", acc(1)) In versions of Python before 3.0, you might write some-
print("5", acc(5)) thing like:
print("3", acc(3))
year, month, total = row[0], row[1], row[-1]
The output for this would be:
This is correct, but it obscures the format. With extended
1 1 destructuring, the same can be expressed this way:
5 6
3 9 year, month, *rest, total = row
In Python 3.x, nonlocal can achieve the same behavior with This means that if the format ever changes to prefix a de-
significantly less code. scription, you can change the code to:
finally: It turns out that when you calculate how many ways you can
after = timeit.default_timer() do something like making change from 50 cents, you use
print("took", after - before) the same coins repeatedly. You can use lru_cache to avoid
recalculating this over and over.
And you can use it with just:
import functools
import time
def change_for_a_dollar():
with timer(): @functools.lru_cache
time.sleep(10.5) def change_for(amount, coins):
took 10.511025413870811 if amount == 0:
return 1
functools.lru_cache if amount < 0 or len(coins) == 0:
Sometimes the caching results from a function in memory return 0
make sense. For example, imagine the classical problem: some_coin = next(iter(coins))
“How many ways can you make change for a dollar with return (
quarters, dimes, nickels, and cents?” change_for(amount, coins - set([some_coin]))
The code for this can be deceptively simple: +
change_for(amount - some_coin, coins)
def change_for_a_dollar(): )
def change_for(amount, coins): return change_for(100, frozenset([25, 10, 5, 1]))
if amount == 0: with timer():
return 1 change_for_a_dollar()
if amount < 0 or len(coins) == 0: took 0.004180959425866604
return 0
some_coin = next(iter(coins)) A three-fold improvement for the cost of one line. Not bad.
return (
change_for(amount, coins - set([some_coin])) Welcome to 2011
+ Although Python 3.2 was released 10 years ago, many of
change_for(amount - some_coin, coins) its features are still cool—and underused. Add them to your
) toolkit if you haven’t already.
return change_for(100, frozenset([25, 10, 5, 1]))
with timer():
change_for_a_dollar()
took 0.013737603090703487
This function takes a long time, so when you use it, you want If you use raise ... from None, you can get much more
to cache the results: readable tracebacks:
During handling of the above exception, another exception ValueError: ('invalid data', 'stuff')
occurs:
Welcome to 2012
ValueError Traceback (most recent call last) Although Python 3.3 was released almost a decade ago,
many of its features are still cool—and underused. Add them
<ipython-input-17-40dab921f9a9> in <module> to your toolkit if you haven’t already.
----> 1 last_letter_analyzed("stuff")
Links
<ipython-input-16-a525ae35267b> in last_letter_analyzed(data) [1] https://docs.python.org/3/library/itertools.html
7 analyzed = expensive_analysis(data) [2] https://more-itertools.readthedocs.io/en/stable/
8 if analyzed is None: [3] https://opensource.com/article/18/7/setting-devpi
----> 9 raise ValueError("invalid data", data) [4] https://www.python.org/dev/peps/pep-0420/
enum @enum.unique
One of my favorite logic puzzles is the self-descriptive Hard- class Language(enum.Enum):
est Logic Puzzle Ever [1]. Among other things, it talks about ja = enum.auto()
three gods who are called A, B, and C. Their identities are True, da = enum.auto()
False, and Random, in some order. You can ask them ques-
tions, but they only answer in the god language, where “da” and One advantage of enums is that in debugging logs or excep-
“ja” mean “yes” and “no,” but you do not know which is which. tions, the enum is rendered helpfully:
If you decide to use Python to solve the puzzle, how would
you represent the gods’ names and identities and the words name = Name.A
in the god language? The traditional answer has been to use identity = Identity.RANDOM
strings. However, strings can be misspelled with disastrous answer = Language.da
consequences. print(
"I suspect", name, "is", identity, "because they
If, in a critical part of your solution, you compare to the answered", answer)
string jaa instead of ja, you will have an incorrect solution. I suspect Name.A is Identity.RANDOM because they answered
While the puzzle does not specify what the stakes are, that’s Language.da
probably best avoided.
The enum module gives you the ability to define these functools.singledispatch
things in a debuggable yet safe manner: While developing the “infrastructure” layer of a game, you
want to deal with various game objects generically but still
import enum allow the objects to customize actions. To make the example
easier to explain, assume it’s a text-based game. When you
@enum.unique use an object, most of the time, it will just print You are using
class Name(enum.Enum): <x>. But using a special sword might require a random roll,
A = enum.auto() and it will fail otherwise.
B = enum.auto() When you acquire an object, it is usually added to the
C = enum.auto() inventory. However, a particularly heavy rock will smash a
random object; if that happens, the inventory will lose that else:
object. print("You fail")
One way to approach this is to have methods use and ac-
quire on objects. More and more of these methods will be deploy(sword)
added as the game’s complexity increases, making game You try to use sword
objects unwieldy to write. You succeed
Instead, functools.singledispatch allows you to add You have ['sword', 'torch']
methods retroactively—in a safe and namespace-respecting import random
manner.
You can define classes with no behavior: @acquire.register(Rock)
def acquire_rock(rock, inventory):
class Torch: to_remove = random.choice(list(inventory))
name="torch" inventory.remove(to_remove)
inventory.add(rock)
class Sword:
name="sword" deploy(Rock())
You use rock
class Rock: You have ['sword', 'rock']
name="rock"
import functools The rock might have crushed the torch, but your code is
much easier to read.
@functools.singledispatch
def use(x): pathlib
print("You use", x.name) The interface to file paths in Python has been “smart-string
manipulation” since the beginning of time. Now, with path-
@functools.singledispatch lib, Python has an object-oriented way to manipulate paths:
def acquire(x, inventory):
inventory.add(x) import pathlib
gitconfig = pathlib.Path.home() / ".gitconfig"
For the torch, those generic implementations are enough: text = gitconfig.read_text().splitlines()
You can call the function from both sources together without Welcome to 2015
having to construct an intermediate dictionary: Python 3.5 was released over six years ago, but some of
the features that first showed up in this release are cool—
show_status(**defaults, **others) and underused. Add them to your toolkit if you haven’t al-
Good You dig ready.
print("Tan of an eighth turn should be 1, got", However, it is possible to define a new class that adds infor-
round(math.tan(math.tau/8), 2)) mation to the string representation of filenames. This allows
print("Cos of an sixth turn should be 1/2, got", the logging to be more detailed, without changing the original
round(math.cos(math.tau/6), 2)) function:
thon 3.7 [1] was first released in 2018, and even though functools.singledispatch() annotation registration
it has been out for a few years, many of the features it If you thought singledispatch [2] couldn’t get any cooler,
introduced are underused and pretty cool. Here are three you were wrong. Now it is possible to register based on
of them. annotations: