1. Letting go of Python 2.7

    About dropping Python 2.7 support in psutil, 3 years ago I stated (#2014):

    Not a chance, for many years to come. [Python 2.7] currently represents 7-10% of total downloads, meaning around 70k / 100k downloads per day.

    Only 3 years later, and to my surprise, downloads for Python 2.7 dropped to 0.36%! As such, as of psutil 7.0.0, I finally decided to drop support for Python 2.7!

    The numbers

    These are downloads per month:

    $ pypinfo --percent psutil pyversion
    Served from cache: False
    Data processed: 4.65 GiB
    Data billed: 4.65 GiB
    Estimated cost: $0.03
    
    | python_version | percent | download_count |
    | -------------- | ------- | -------------- |
    | 3.10           |  23.84% |     26,354,506 |
    | 3.8            |  18.87% |     20,862,015 |
    | 3.7            |  17.38% |     19,217,960 |
    | 3.9            |  17.00% |     18,798,843 |
    | 3.11           |  13.63% |     15,066,706 |
    | 3.12           |   7.01% |      7,754,751 |
    | 3.13           |   1.15% |      1,267,008 |
    | 3.6            |   0.73% |        803,189 |
    | 2.7            |   0.36% |        402,111 |
    | 3.5            |   0.03% |         28,656 |
    | Total          |         |    110,555,745 |
    

    According to pypistats.org Python 2.7 downloads represent 0.28% of the total, around 15,000 downloads per day.

    The pain

    Keeping 2.7 alive had become increasingly difficult, but still possible: tests ran via old PyPI backports and a tweaked GitHub Actions workflow on Linux and macOS, plus a separate third-party service (Appveyor) for Windows. But the workarounds in the source kept piling up:

    • A Python compatibility layer (psutil/_compat.py) plus #if PY_MAJOR_VERSION <= 3 branches in C, with constant str-vs-unicode juggling on both sides.
    • No f-strings, and no free use of enum for constants (which ended up with a different API shape than on Python 3).
    • An outdated pip and outdated deps.
    • 4 extra CI jobs per commit (Linux, macOS, Windows 32-bit and 64-bit), making the pipeline slower and flakier.
    • 7 wheels specific to Python 2.7 to ship on every release:
    psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl
    psutil-6.1.1-cp27-none-win32.whl
    psutil-6.1.1-cp27-none-win_amd64.whl
    psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl
    psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl
    psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl
    psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl
    

    The removal

    The removal was done in PR-2481, which dropped around 1500 lines of code (nice!). It felt liberating. In doing so, in the doc I still made the promise that the 6.1.* series will keep supporting Python 2.7 and will receive critical bug-fixes only (no new features). It will be maintained in a specific python2 branch. I explicitly kept the setup.py script compatible with Python 2.7 in terms of syntax, so that, when the tarball is fetched from PyPI, it will emit an informative error message on pip install psutil. The user trying to install psutil on Python 2.7 will see:

    $ pip2 install psutil
    As of version 7.0.0 psutil no longer supports Python 2.7.
    Latest version supporting Python 2.7 is psutil 6.1.X.
    Install it with: "pip2 install psutil==6.1.*".
    
  2. Recognize connection errors

    Lately I've been dealing with an asynchronous TCP client app which sends messages to a remote server. Some of these messages are important, and cannot get lost. Because the connection may drop at any time, I had to implement a mechanism to resend the message once the client reconnects. As such, I needed a way to identify what constitutes a connection error.

    Python provides a builtin ConnectionError exception precisely for this purpose, but it turns out it's not enough. After observing logs in production, I found some errors that were not related to the socket connection per se, but rather to the system connectivity, like ENETUNREACH ("network unreachable") or ENETDOWN ("network down"). It's interesting to note how this distinction is reflected in the UNIX errno code prefixes: ECONN* (connection errors) vs. ENET* (network errors). I've noticed ENET* errors usually occur on a DHCP renewal, or more in general when the Wi-Fi signal is weak or absent. Because this code runs on a cleaning robot which constantly moves around the house, connection can become unstable when the robot gets far from the Wi-Fi Access Point, so it's pretty common to bump into errors like these:

    File "/usr/lib/python3.7/ssl.py", line 934, in send
        return self._sslobj.write(data)
    OSError: [Errno 101] Network is unreachable
    
    File "/usr/lib/python3.7/socket.py", line 222, in getaddrinfo
        for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
    socket.gaierror: [Errno -3] Temporary failure in name resolution
    
    File "/usr/lib/python3.7/ssl.py", line 934, in send
        return self._sslobj.write(data)
    BrokenPipeError: [Errno 32] Broken pipe
    
    File "/usr/lib/python3.7/ssl.py", line 934, in send
        return self._sslobj.write(data)
    socket.timeout: The write operation timed out
    

    Production logs also revealed a considerable amount of SSL-related errors. I was uncertain what to do about those. The app is supposed to gracefully handle them, so theoretically they should represent a bug. Still, they are unequivocally related to the connection stream, and represent a failed attempt to send data, so we want to retry it. Examples of logs I found:

    File "/usr/lib/python3.7/ssl.py", line 934, in send
        return self._sslobj.write(data)
    ssl.SSLZeroReturnError: TLS/SSL connection has been closed (EOF)
    
    File "/usr/lib/python3.7/ssl.py", line 934, in send
        return self._sslobj.write(data)
    ssl.SSLError: [SSL: BAD_LENGTH] bad length
    

    Looking at production logs revealed what sort of brutal, rough and tumble place the Internet is, and how a network app must be ready to handle all sorts of unexpected error conditions which hardly show up during testing. To handle all of these cases I came up with this solution which I think is worth sharing, as it's generic enough to be reused in similar situations. If needed, this can easily be extended to include specific exceptions of third party libraries, like requests.exceptions.ConnectionError (EDIT: 2026-02: done for requests + botocore).

    """
    Recognize a connection error from an exception object.
    Author: Giampaolo Rodola
    License: MIT
    """
    
    import errno, socket, ssl
    
    import botocore.exceptions
    import requests.exceptions
    
    # Network errors, usually related to DHCP or wpa_supplicant (Wi-Fi).
    NETWORK_ERRNOS = frozenset((
        errno.ENETUNREACH,  # "Network is unreachable"
        errno.ENETDOWN,  # "Network is down"
        errno.ENETRESET,  # "Network dropped connection on reset"
        errno.ENONET,  # "Machine is not on the network"
        errno.ENOTCONN,  # "Transport endpoint is not connected"
        errno.EBADF,  # "Bad file descriptor"
    ))
    
    # requests lib connection errors
    REQUESTS_EXCEPTIONS = (
        requests.exceptions.ConnectionError,
        requests.exceptions.ProxyError,
        requests.exceptions.SSLError,
        requests.exceptions.Timeout,
        requests.exceptions.ConnectTimeout,
        requests.exceptions.ReadTimeout,
        requests.exceptions.ChunkedEncodingError,
    )
    
    # botocore lib connection errors
    BOTOCORE_EXCEPTIONS = (
        botocore.exceptions.ConnectionClosedError,
        botocore.exceptions.ConnectionError,
        botocore.exceptions.ConnectTimeoutError,
        botocore.exceptions.EndpointConnectionError,
        botocore.exceptions.ReadTimeoutError,
        botocore.exceptions.SSLError,
    )
    
    
    def is_connection_err(exc):
        """Return True if an exception is connection-related."""
        if isinstance(exc, ConnectionError):
            # https://docs.python.org/3/library/exceptions.html#ConnectionError
            # ConnectionError includes:
            # * BrokenPipeError (EPIPE, ESHUTDOWN)
            # * ConnectionAbortedError (ECONNABORTED)
            # * ConnectionRefusedError (ECONNREFUSED)
            # * ConnectionResetError (ECONNRESET)
            return True
        if isinstance(exc, socket.gaierror):
            # failed DNS resolution on connect()
            return True
        if isinstance(exc, (socket.timeout, TimeoutError)):
            # Timeout on connect(), recv(), send().
            return True
        if isinstance(exc, OSError):
            if exc.errno in NETWORK_ERRNOS:
                return True
        if isinstance(exc, ssl.SSLError):
            # Let's consider any SSL error a connection error. Usually this is:
            # * ssl.SSLZeroReturnError: "TLS/SSL connection has been closed"
            # * ssl.SSLError: [SSL: BAD_LENGTH]
            return True
        if isinstance(exc, REQUESTS_EXCEPTIONS):
            # Any indication that requests lib failed due to a connection
            # error.
            return True
        if isinstance(exc, BOTOCORE_EXCEPTIONS):
            # Any indication that boto3 lib failed due to a connection
            # error.
            return True
        return False
    
    
    # =====================================================================
    # --- unit tests
    # =====================================================================
    
    import unittest
    
    
    class TestIsConnectionErr(unittest.TestCase):
        def test_connection_error(self):
            for exc in (
                BrokenPipeError(),
                ConnectionAbortedError(),
                ConnectionRefusedError(),
                ConnectionResetError(),
            ):
                assert is_connection_err(exc)
    
        def test_not_connection_error(self):
            assert not is_connection_err(ValueError())
            assert not is_connection_err(OSError())
            assert not is_connection_err(Exception())
    
        def test_requests_exceptions(self):
            for exc in (
                requests.exceptions.ConnectionError(),
                requests.exceptions.Timeout(),
                requests.exceptions.SSLError(),
            ):
                assert is_connection_err(exc)
    
        def test_botocore_exceptions(self):
            for exc in (
                botocore.exceptions.ConnectionClosedError(endpoint_url="x"),
                botocore.exceptions.ConnectTimeoutError(endpoint_url="x"),
                botocore.exceptions.ReadTimeoutError(endpoint_url="x"),
            ):
                assert is_connection_err(exc)
    
    
    if __name__ == "__main__":
        unittest.main()
    

    To use it:

    try:
        sock.sendall(b"hello there")
    except Exception as err:
        if is_connection_err(err):
            schedule_on_reconnect(lambda: sock.sendall(b"hello there"))
        raise
    
  3. Sublime Text: remember cursor position plugin

    My editor of choice for Python development is Sublime Text. It has been for a very long time (10 years). It's fast, minimalist and straight to the point, which is why I always resisted the temptation to use more advanced and modern IDEs such as PyCharm or VS code, which admittedly have superior auto-completion and refactoring tools.

    There is a very simple feature I've always missed in ST: the possibility to "remember" / save the cursor position when a file is closed. The only plugin promising to do such a thing is called BufferScroll, but for some reason it ceased working for me at some point. I spent a considerable amount of time Googling for an alternative but, to my surprise, I couldn't find any plugin which implements such a simple feature. Therefore today I decided to bite the bullet and try to implement this myself, by writing my first ST plugin, which I paste below.

    What it does is this:

    • every time a file is closed, save the cursor position (x and y axes) to a JSON file
    • if that same file is re-opened, restore the cursor at that position

    What's neat about ST plugins is that they are just Python files which you can install by copying them into ST's config directory. On Linux you can copy the script below to:

    ~/.config/sublime-text-3/Packages/User/cursor_positions.py

    ...and it will work out of the box. This is exactly the kind of minimalism which I love about ST, and which I've always missed in other IDEs.

    # cursor_positions.py
    
    """
    A plugin for SublimeText which saves (remembers) cursor position when
    a file is closed.
    Install it by copying this file in ~/.config/sublime-text-3/Packages/User/
    directory (Linux).
    
    Author: Giampaolo Rodola'
    License: MIT
    """
    
    import datetime
    import json
    import os
    import tempfile
    import threading
    
    import sublime
    import sublime_plugin
    
    
    SUBLIME_ROOT = os.path.realpath(os.path.join(sublime.packages_path(), '..'))
    SESSION_FILE = os.path.join(
        SUBLIME_ROOT, "Local", "cursor_positions.session.json")
    # when reading the session file on startup, we'll remove entries
    # older than X days
    RM_FILE_OLDER_THAN_DAYS = 180
    
    
    def log(*args):
        print("    %s: " % os.path.basename(__file__), end="")
        print(*args)
    
    
    class Session:
    
        def __init__(self):
            self._lock = threading.Lock()
            os.makedirs(os.path.dirname(SESSION_FILE), exist_ok=True)
            self.prune_old_entries()
    
        # --- file
    
        def read_session_file(self):
            try:
                with self._lock:
                    with open(SESSION_FILE, "r") as f:
                        return json.load(f)
            except (FileNotFoundError, json.decoder.JSONDecodeError):
                return {}
    
        def write_session_file(self, d):
            # Use the same FS so that the move operation is atomic:
            # https://stackoverflow.com/a/18706666
            with tempfile.NamedTemporaryFile(
                    "wt", delete=False, dir=os.path.dirname(SESSION_FILE)) as f:
                f.write(json.dumps(d, indent=4, sort_keys=True))
            with self._lock:
                os.rename(f.name, SESSION_FILE)
    
        def prune_old_entries(self):
            old = self.read_session_file()
            new = old.copy()
            now = datetime.datetime.now()
            for file, entry in old.items():
                tstamp = entry["last_update"]
                last_update = datetime.datetime.strptime(
                    tstamp, '%Y-%m-%d %H:%M:%S.%f')
                delta_days = (now - last_update).days
                if delta_days > RM_FILE_OLDER_THAN_DAYS:
                    log("removing old saved file %r" % file)
                    del new[file]
            if new != old:
                self.write_session_file(new)
    
        # --- operations
    
        def add_entry(self, file, x, y):
            d = self.read_session_file()
            d[file] = dict(
                x=x,
                y=y,
                last_update=str(datetime.datetime.now()),
            )
            self.write_session_file(d)
    
        def load_entry(self, file):
            d = self.read_session_file()
            try:
                return d[file]
            except KeyError:
                return None
    
    
    session = Session()
    
    
    class Events(sublime_plugin.EventListener):
    
        # --- utils
    
        @staticmethod
        def get_cursor_pos(view):
            x, y = view.rowcol(view.sel()[0].begin())
            return x, y
    
        @staticmethod
        def set_cursor_pos(view, x, y):
            pt = view.text_point(x, y)
            view.sel().clear()
            view.sel().add(sublime.Region(pt))
            view.show(pt)
    
        def save_cursor_position(self, view):
            file_name = view.file_name()
            if file_name is None:
                return  # non-existent file
            log("saving cursor position for %s" % file_name)
            x, y = self.get_cursor_pos(view)
            session.add_entry(file_name, x, y)
    
        def load_cursor_position(self, view):
            entry = session.load_entry(view.file_name())
            if entry:
                self.set_cursor_pos(view, entry["x"], entry["y"])
    
        # --- callbacks
    
        def on_close(self, view):
            # called when a file is closed
            self.save_cursor_position(view)
    
        def on_load(self, view):
            # called when a file is opened
            self.load_cursor_position(view)
    
  4. New Pelican website

    Hello there. Here is my new blog / personal website! This is something I've been wanting to do for a very long time, since the old blog hosted at https://grodola.blogspot.com/ was... well, too old. =) This new site is based on Pelican, a static website generator similar to Jekyll. Unlike Jekyll, it uses Python instead of Ruby, and that's why I chose it. It's minimal, straight to the point and I feel I have control of things. This is what Pelican gave me out of the box:

    • blog functionality
    • ability to write content by using reStructuredText
    • RSS & Atom feed
    • easy integration with GitHub pages
    • ability to add comments via Disqus

    To this I added a mailing list (I used feedburner), so that users can subscribe and receive an email every time I make a new blog post. As you can see the website is very simple, but it's exactly what I wanted (minimalism). As for the domain name I opted for gmpy.dev, mostly because I know my name is hard to type and pronounce for non-English speakers. And also because I couldn't come up with a better name. ;)

    GIT-based workflow

    The main reason I blogged so rarely over the years was that blogger.com provided me no way to edit content in reST or markdown, and lacked GIT integration. This made me lazy. With Pelican + GitHub pages the workflow to create and publish new content is very straightforward. I use 2 branches: gh-pages, which is the source code of this web-site, and master, which is where the generated HTML content lives and is being served by GitHub pages. This is what I do when I have to create a new blog post:

    • under gh-pages branch I create a new file, e.g. content/blog/2020/new-blog-post.rst:
    New blog post
    #############
    
    :date: 2020-06-26
    :tags: announce, python
    
    Hello world!
    
    • commit it:
    git add content/blog/2020/new-blog-post.rst
    git ci -am "new blog post"
    git push
    
    • publish it:
    make github
    

    Within a minute or so, GitHub will automatically serve gmpy.dev with the updated content. And this is why I think I will start blogging more often. =) The core of Pelican is pelicanconf.py, which lets you customize a lot of things by remaining independent from the theme. I still ended up modifying the default theme though, writing a customized "archives" view and editing CSS to make the site look better on mobile phones. All in all, I am very satisfied with Pelican, and I'm keen on recommending it to anyone who doesn't need dynamic content.

    About me

    I spent most of last year (2019) in China, dating my girlfriend, working remotely from a shared office space in Shenzhen, and I even taught some Python to a class of Chinese folks with no previous programming experience. The course was about the basics of the language plus basic filesystem operations, and the most difficult thing to explain was indentation. I guess that shows the difference between knowing a language and knowing how to teach it.

    I got back to Italy in December 2019, just before the pandemic occurred. Because of my connections with China, I knew about the incoming pandemic sooner than the rest of my friends, which for a while (until the lockdown) made them think I was crazy. =)

    Since I knew I would be stuck at home for a long time, I bought a quite nice acoustic guitar (Taylor) after many years, and resumed playing (and singing). I learned a bunch of new songs, mostly by Queen, including Bohemian Rhapsody, which is something I've been wanting to do since forever.

    I also spent some time working on a personal project that I'm keeping private for the moment, something to speed up file copies, which follows the experiments I made in BPO-33671. It's still very beta, but I managed to make file copies around 170% faster compared to the cp command on Linux, which is pretty impressive (and I think I can push it even further). I will blog about that once I have something more solid / stable. Most likely it'll become my next OSS project, even though I have mixed feelings about that, since the amount of time I'm devoting to psutil is already a lot.

    Speaking of which, today I'm also releasing psutil 5.7.1, which adds support for Windows Nano.

    I guess this is all. Cheers and subscribe!

  5. System load average on Windows in Python

    psutil 5.6.2 is out. It implements an emulation of os.getloadavg() on Windows, kindly contributed by Ammar Askar, who originally implemented it for CPython's test suite.

    This idea has been floating around for quite a while. The first proposal dates back to 2010, when psutil was still hosted on Google Code, and it popped up multiple times over the years. There's a bunch of info online mentioning the pieces you'd theoretically use (the so-called System Processor Queue Length), but I couldn't find any real implementation. A quick search suggests there's real demand for this, but very few tools provide it natively (the only ones I could find are sFlowTrend and Zabbix). So I'm glad this finally landed in psutil / Python.

    Other improvements and bugfixes in psutil 5.6.2

    The full list is in the changelog. A couple worth mentioning:

    • #1476: ability to set a process's high I/O priority on Windows.
    • #1458: colorized test output. Nobody will use this directly, but it's nice and I'm porting it to other projects I maintain (e.g. pyftpdlib). Good candidate for a small PyPI module that could also include the unittest extensions I've been re-implementing piece by piece:
      • #1478: re-running failed tests.
      • display test timings / durations. This is something I'm also contributing to CPython: BPO-4080 and PR-12271.

    About me

    I'm currently in China (Shenzhen) for a mix of vacation and work, and I will likely take a break from Open Source for a while (about 2.5 months), during which I'll also go to the Philippines and Japan.

    External

Social

Feeds