Adventures in Rye

Rye is a fantastic packaging solution for Python. See Python Package Management – Rye is All You Need by Elliot Simpson (Kiwi PyCon 2024)

There have been a couple of things to work out though.

Installing Rye on A Colleague’s Windows Computer in a Corporate Setting

The installation didn’t seem to properly work. Solution – switch off VPN when trying to install rye. Success! šŸ™‚

Guardrails-AI

Most versions worked fine with rye but 0.5.10 didn’t make it possible to run the guardrails hub commands to install valid_sql e.g.

guardrails hub install hub://guardrails/valid_sql

So, having activated the virtual environment, I did a pip installation instead

python -m pip install guardrails-ai

and then added the dependency to pyproject.toml so rye wouldn’t remove it if I ran rye sync later. Installing valid_sql from guardrails hub worked after that.

wxPython

I ran:

rye add wxpython

and it built it successfully. Unfortunately, I didn’t build support for webview in.

Problem – trying to run the following didn’t work:

wx.html2.WebView.New() -> NotImplementedError

To build support for webview I needed to:

sudo apt install libwebkit2gtk-4.1-dev

and

sudo apt install clang

to avoid the error:

error: command 'clang' failed: No such file or directory

and possibly libwebkit2gtk-4.1-dev (although I haven’t checked whether removing it breaks things)

rye remove wxpython

Then, before running:

rye add wxpython --sync

I deleted ~/.cache/uv/built-wheels-v2/index/b2a7eb67d4c26b82/wxpython

Everything built correctly after that.

Is Coding Dead? – Teaching Python in a ChatGPT Age

Teaching Python Podcast – Episode 130: Coding is Dead? is basically about the impact on teaching Python of LLMs / Generative AI / ChatGPT / Copilot / Gemini (I’ll use ChatGPT for short). The video is a conversation between four people, and, although it includes a lot of great content,Ā  it can be a little diffuse. I strongly recommend speeding the video up a lot.

Here are some notes and reflections:

  • Programming more accessible?: ChatGPT makes programming more accessible to more people. They might be able to build things by asking repeated questions to theĀ ChatGPT until it seems to work. The example was given of a clever non-programmer who built the website his business needed.
  • Programming more economic?: ChatGPT makes programming available to solve problems where it wouldn’t have been economic before. The alternative to ChatGPT code (with human guidance) might be no solution at all in many cases.
  • Motivating for young students?: ChatGPT will be very useful teaching Python to kids – they can experience the motivation from making something actually rewarding to make (e.g. a Fortnite tracker) using ChatGPT. You can then teach on top of that e.g. what a file is (so they can actually find the output statistics on the file system)
  • Correctness even more of a concern?: The big question we need to move front and centre – “Is it correct?” This is now the big question people need to be able to assess. We need to emphasise it to our students. Sometimes ChatGPT can fix broken code but it doesn’t know what you are actually wanting (or should be wanting) the code to do. See https://xkcd.com/568/ (“You’ll never find a programming language that frees you from the burden of clarifying your ideas” [“But I know what I mean!”])
  • Loss of human-readable code?: As with modern HTML, we may be sacrificing the ability for humans to read and write Python in return for letting ChatGPT do most of the work writing the code. And if we are going to have lots of mysterious code, why should it even be Python? Why not binary? [One possibility – code in the wild splits into better-than-ever code (ChatGPT assists good coders with documentation and simple, starter tests) and impenetrable code that is not written for human readers. Perhaps we are about to enter both the best of times and the worst of times**]
  • Embrace the change?: Analogy of calculators and mental arithmetic – maybe we need to embrace the change, at least when teaching young people. BTW this doesn’t mean people won’t need to learn how to read Python and understand it – but maybe not everyone will need that. And the others will still learn something practical about making things using code. Our main lesson for the latter might be how to avoid the worst hazards of this approach.
  • Allow ChatGPT in interviews?: Job applicants – get them to explain their code carefully. Care less about how they wrote it and more about whether they can understand it, maintain it etc. Insisting on no-ChatGPT interviews etc is like asking people to write sorting algorithms. Misguided and a bad test of candidate ability and value.
  • Allow ChatGPT in assessment?: Focus on how people can explain what they did and what they actually achieved using all the available tools.

Of course, this revolution is still playing out (sometimes participants in the conversation learned something important from the others) and we’re just starting to learn how best to relate to it.

And I haven’t answered the question “Is Coding Dead?”. Old-style coding is, but coding itself as a skill and activity will be alive and well. Who would code now without access to Google and Stack Overflow?

TechNoon – A New Community Technical Education Initiative

Along with Ben Denham, I’m co-founder of TechNoon. The elevator pitch: “TechNoons are brief, free, industry-led, in-person training courses run at lunchtimes for people in technical jobs wanting to improve their skills.” TechNoon courses will usually have four sessions of 2-3 hours each (including homework). We think there is a major gap in technical education for people already in employment.

Why TechNoon is needed

  • Tertiary degrees and diplomas are not a good answer for people already in jobs. Even boot-camps are a significant challenge for most people with job commitments. Courses also tend to be too general with hit-and-miss content for people in specific roles.
  • On-line courses and individual articles and documentation can’t provide the individualised support and the motivation that an in-person course can.
  • Internal corporate training can’t achieve the economies of scale and specialisation of an industry-wide training ecosystem.
  • Vendor training doesn’t cover the range of skills required and is not focused primarily on what is good for industry.

What Next?

  • Have other people delivering pre-prepared TechNoon training content
  • Have courses run outside of Auckland
  • Extend the range of technical skills covered
  • Consolidate industry support in the form of trainers and venues
  • Set up a community organisation to underpin TechNoon and to provide train-the-trainers courses

No Good Deed Unpunished – tempfile and Snap

I was finally fixing up my superhelp package when I noticed that the HTML output no longer displayed successfully in the web browser (whether on Firefox or Chromium).

The error message in the browser was:

File not found

Firefox can’t find the file at
/tmp/superhelp_project_output/_home_g_Documents_python_python_scripts_superhelp_demo.py.html.

At first I assumed it was because of breaking changes in a dependency or something like that – I have encountered a few as I finally try to bring my project up to date. But no – the real answer is that both Firefox and Chromium on my machine are installed as snap packages. And snap packages are isolated from system paths such as /tmp – they use their own version (see file:///tmp/ in Firefox does not show contents of /tmp).

So I can’t use the elegant Python tempfile-based solution (cross-platform, semantic, documented) and will need to manage creating the file myself. And I thought I was doing the right thing … sigh …

Internal Python Imports Without Tears

Flaky, Breaky Internal Importing

Maybe you’ve been lucky so far and never experienced the flaky, breaky side of internal importing in Python – for example, mysterious ModuleNotFound exceptions. But if internal importing has been a problem, typically when code is imported from a multi-folder code base, it is good to know there is at least one guaranteed solution.

If you want your internal imports to work reliably, the following rules will guarantee success. Other approaches may also work, at least some of the time, but the rules presented below will always work. Following these rules will also make it possible to run modules as scripts1 no matter where they occur in the code structure. This can be especially useful during development.

Tear-Free Rules2

  • Be Absolute
  • Anchor Well
  • Folder Imports Bad!
  • Don’t Just ā€œRunā€ Code

#1 Be Absolute

Use absolute importing everywhere in your code package, not just where you think internal importing is broken

Python has both relative and absolute importing. Relative importing is relative to the file doing the importing and uses dots e.g. one for the existing folder, two for the parent folder, three for grandparent etc. How many dots you need, and what your import looks like, depends on where you are in the folder structure. Absolute importing starts from an anchor point which is the same wherever the file doing the importing is.

DO:

import charm.tables.freq
import charm.conf
import charm.utils.stats.parametric.ttest

DON’T:

from ..stats.parametric import ttest

from . import ttest  ## importing same thing but doing it from another module

#2 Anchor Well

Anchor absolute imports from the code package folder. E.g. if we have a module in a location like: /home/pygrant/projects/charm/charm/tables/freq.py we would import it like:

import charm.tables.freq

assuming the code package folder was the rightmost charm folder.

It is common to use the same name for the surrounding folder as the code package folder but we don’t have to and the following example might make the situation more clear. If we have a module in a location like: /home/pygrant/projects/charm_project/charm/tables/freq.py we would import it from charm (the code package folder) not charm_project (the surrounding folder).

DO:

import charm.tables.freq

DON’T:

import tables.freq  ## <===== missing the anchor i.e. the code package folder

#3 Folder Imports Bad!

Don’t import folders – instead import modules or attributes of modules.

DO:

import charm.tables.freq

DON’T:

import charm.tables  ## <== a folder - so you might be importing
                     ## the __init__.py under that folder if there is one
tables.freq.get_html()

#4 Don’t Just “Run” Code

One doesn’t simply run code. Code is always executed with a particular context, often implicitly. Use one of the ways that works i.e.

  • that puts the surrounding folder in the sys.path, so Python can find your modules to actually import their contents
  • and resolves module names (in particular, folders used with the dot notation) – once again, so Python can find your modules

Either use -m option

python -m code_package.folder.folder.script  <– without .py extension

or

ensure your IDE has the surrounding folder, the folder surrounding the code_package, in its python path (sys.path) (possibly defined in quirky, possibly unintuitive IDE-specific ways)

You can always run the following one-liner before the problem to see what is in your python path:

import sys; print('\n'.join(sys.path))

Final Comments

I have seen mysterious internal importing problems impact numerous Python developers. The Python import system is very flexible and extensible but it is far from simple. Flaky, breaky internal importing is definitely not only a problem for beginners.

Confusion is increased by such factors as:

  • Python ignoring repeated imports of the same module (name caching). This is a perfectly logical behaviour but it means a faulty import in one piece of code might be ignored in favour of a working import in another piece of code. Or vice versa – a working import statement might be ignored because of an earlier faulty import. Remember in Rule #1 – “Use absolute importing everywhere in your code package”
  • IDE quirks e.g. in VS Code I was advised by a friend that the following was necessary for success:

    In .vscode/settings.json add:

    "terminal.integrated.env.windows": {
    "PYTHONPATH": "${workspaceFolder}"
    }

    where ${workspaceFolder} points “to the surrounding folder”, either relative to the workspace folder using the ${...} syntax, or as an absolute path. Also put this in .env as an absolute path to the surrounding folder:
    PYTHONPATH=<path-to-surrounding folder>

    Simple right? šŸ˜‰

    PyCharm seems to require using the correct content root and allowing the Content Roots to be added to the PYTHONPATH. If the project is created off the surrounding folder this is probably the default behaviour but if this doesn’t happen it is not obvious how to fix the problem.

You don’t necessarily have to follow the “rules” above to get your internal imports working, but why take the risk? Follow the rules and then you can turn your precious attention to other programming issues. Here they are again:

  • Be Absolute
  • Anchor Well
  • Folder Imports Bad!
  • Don’t Just “Run” Code
  1. Running a script means actually doing something (e.g. writing a file, making an API call, etc)
    rather than just defining something without running it (e.g. defining a function or a class). ā†©ļøŽ
  2. The “Tears” theme is a nod to the popular statistics book “Statistics Without Tears” ā†©ļøŽ

The When of Python Project

The When of Python is a fledgling community initiative. The goal is to effectively shrink Python so it fits our brains by providing guidance on when we should use particular language features (and when we should not).

For example, should we use collections.namedtuple, typing.namedtuple, or dataclasses.dataclass? Should we use the walrus operator? StrEnum? Structural Pattern Matching? Comprehensions? Lambda? etc.

Find out more at https://whenof.python.nz/blog. The project can be followed at https://twitter.com/WhenOfPython and the video of the Kiwi PyCon talk which launched the project is at https://t.co/MgGi6kQeme. There is also a Proof-of-Concept app you can check out at https://whenof.python.nz

Making External Objects Play Nicely with Type Hinting

The Problem

We want our function to type hint such that only objects with a particular method e.g. .attack(), can be received. Something like this:

def all_attack(units: Sequence(HasAttackMethod)):

One of the objects only has a .fire() method. How do we make sure it can meet the requirement and be used in the same way as the other objects?

Extend the External Object – Make a Wrapper

Here we simply make a light wrapper around the object that doesn’t change the interface apart from adding the missing method. The new method is added to the object using setattr and everything else is delegated to the original object.

Now we can supply the extended object and it will function as required. It will also meet the required type specification, meeting the Attacker Protocol by having an .attack() method.

"""
OK - we have one weird external object - how do we handle it?
"""
from functools import partial
from typing import Protocol, Sequence


class Soldier:
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Soldier {self.name} swings their sword!")

class Archer:
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Archer {self.name} fires their arrow!")

class Catapult:
    def __init__(self, name):
        self.name = name
    def fire(self):  ## not the required name
        print(f"Catapult {self.name} hurls their fireball!")

s1 = Soldier('Tim')
s2 = Soldier('Sal')
a1 = Archer('Cal')
c1 = Catapult('Mech')


class Extend():
    def __init__(self, original, method_name, method):
        self.original = original
        ## being a method self needs to come first
        method_with_self_arg = partial(method, self=self)
        ## Add method e.g. .attack() 
        setattr(self, method_name, method_with_self_arg)
    def __getattr__(self, name, *args, **kwargs):
        """
        Delegate everything other than the added method
        to the original object
        """
        original_attr = self.original.__getattribute__(name)
        if hasattr(original_attr, '__call__'):  ## handle methods
            return original_attr(*args, **kwargs)
        else:  ## handle ordinary attributes
            return original_attr

def catapult_attack(self):
    self.original.fire()

c1_extended = Extend(original=c1, method_name='attack',
    method=catapult_attack)

class Attacker(Protocol):
    def attack(self) -> None:
        ...

def all_attack(units: Sequence[Attacker]):
    for unit in units:
        unit.attack()

all_attack([s1, s2, a1, c1_extended])
print(c1_extended.name)

Making a Polymorpher

Another approach is to make a polymorpher function. This relies upon the classes pip package https://pypi.org/project/classes/. See also: https://classes.readthedocs.io/en/latest/pages/supports.html#regular-types

As at the time of writing there seem to be problems making this code pass mypy which defeats the purpose. The explanation seems to be some brittleness in the (very promising) classes package but that is not fully confirmed.

For example, if you were making a game and you needed a function to only accept objects with an attack() method you could do it like this:

def all_attack(units: Sequence[Supports[RequiredAttackType]]):
    for unit in units:
        unit.attack()

There are some nasty and opaque steps required to make this work. These can be concentrated in one function to make it easy to use this duck hinting approach. In the code below the nastiest part is in polymorpher(). The function is included to show how it works and so others can potentially improve on it.

"""
Purpose: in a static-typing context,
how do we enable external objects
to comply with type requirements in a function?

E.g. all_attack(units: Sequence[RequiredType])
which only permits sequences of the required type.

That is no problem with Soldier and Archer
as they have attack methods.
But Catapult doesn't have an attack method.
How do we give it one without:
* changing the source code
* monkey patching

And how do we type hint (enforced by mypy) so that only objects which have the attack method can be received?

Basically it would be nice if we could add an attack method to
Catapult using a decorator e.g. like

@attack.instance(Catapult)
def _fn_catapult(catapult: Catapult):
    print(f"Catapult {catapult.name} hurls their fireball!")

And, instead of saying which types are acceptable,
follow a duck typing approach and say
what behaviours are required e.g. must have an attack method. E.g.

def all_attack(units: Sequence[Supports[RequiredAttackType]]):
    …

To make that all work, we have made a nasty, opaque function (polymorpher) that gives us both parts needed - the decorator,
and the behaviour type.
"""

from typing import Protocol, Sequence

## pip installation required
from classes import AssociatedType, Supports, typeclass

## UNITS *********************************************************

class Soldier:
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Soldier {self.name} swings their sword!")

class Archer:
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Archer {self.name} fires their arrow!")

## assume this class defined an object we can't / shouldn't modify
class Catapult:
    def __init__(self, name):
        self.name = name

s1 = Soldier('Tim')
s2 = Soldier('Sal')
a1 = Archer('Cal')
c1 = Catapult('Mech')

## ATTACKING *******************************************************
Opaque, somewhat nasty function that makes it easy to make external objects polymorphic so they'll work with a function requiring 
compliant objects

def polymorpher(*, method_name: str):
    ## want separate types depending on method_name
    RequiredType = type(method_name, (AssociatedType, ), {})
    @typeclass(RequiredType)
    def fn(instance):
        pass
    def self2pass(self):
        pass
    RequiredProtocol = type(
        f'{method_name}_protocol',
        (Protocol, ),
        {'_is_runtime_protocol': True,
             method_name: self2pass,
        })
    @fn.instance(protocol=RequiredProtocol)
    def _fn_run_method(obj: RequiredProtocol):
        return getattr(obj, method_name)()
    return fn, RequiredType

## method_name and returned fn don't have to share name
attack, RequiredAttackType = polymorpher(method_name='attack')

## now easy to make external objects polymorphic
so they'll work with a function requiring compliant objects

@attack.instance(Catapult)
def _fn_catapult(catapult: Catapult):
    print(f"Catapult {catapult.name} hurls their fireball!")

def all_attack(units: Sequence[Supports[RequiredAttackType]]):
    for unit in units:
        attack(unit)  ## note - not unit.attack()

## UNITs ATTACKing ****************************************************

units = [s1, s2, a1, c1]
all_attack(units)

Wrap Up

Type hinting and duck typing are both parts of modern Python and they need to play together well – ideally in a simpler and standardised way. The code above hopefully assists with that evolution.

See also https://www.daan.fyi/writings/python-protocols and https://medium.com/alan/python-typing-with-mypy-progressive-type-checking-on-a-large-code-base-74e13356bd3a

And a big thanks to Ben Denham for doing most of the heavy lifting with making the polymorpher (almost ;-)) work.