True constants

Summary

How to ensure that variables defined as constants by some notational convention are guaranteed not to change, without requiring them to be attribute of an object.

This example demonstrates the use of:

  • Source transformation to extract information from the source without changing its content.

  • Custom module class

  • Specialized dict to temporarily replace the read-only module dict.

Source code

It took me quite a while to come up with the solution described below, and I learned a fair bit along the way. Given the relative complexity of the final solution, I thought it would be appropriate to start slowly, explaining what is usually done by Pythonistas.

Consenting adults

Unlike some other programming languages that force their users to do things in a very strict fashion, Python gives a lot of flexibility and assume that they are responsible adults, capable of using common sense in their use of the language. Thus, certain features considered to be absolutely essential in other languages, such as keeping some variables hidden, or forcing other variables to never have their type and sometimes not even their value changed once assigned, are not present in Python. Instead, Pythonistas rely on conventions to indicate their intent.

  • You want to indicate that a variable should be considered “private”? Just use a name starting with an underscore to communicate this to another programmer.

    • If that is not strong enough, use a double underscore to start its name. Other Python programmers will know what you mean.

  • You want to indicate that a variable should be constant? Just write its name in UPPERCASE_LETTERS.

    • For Python 3.8, use a Final qualifier type hint as per PEP 591

Still, this does not prevent people from asking “How do I create a constant in Python?” such as was asked on StackOverflow some 9 years ago, which has resulted in 34 different answers so far, all of which indicate that you cannot enforce this convention in Python at a module level. Answers that were provided are still actively edited as new features get added to Python, including recent comments about using the Final qualifier.

While it is noted that you can use tools such as mypy only to check without running the program that a variable meant to be used as a constant is not expected to change, all the other answers rely, in one way or another, on creating an object, which can be another module, and have the would-be constants defined as attribute to that object. Such objects are carefully designed with properties that prevent changes to the values of attributes intended to be constants.

However, this type of solution is more cumbersome to use than being able to simply use an agreed-upon convention. As Raymond Hettinger often says: there must be a better way. ;-)

Demonstration

I can think of nothing better than a quick demonstration using the console:

>>> from ideas.examples import constants
>>> hook = constants.add_hook()
>>> from ideas import console
>>> console.start()
Configuration values for the console:
    callback_params: {'on_prevent_change': <function on_change_print ...>}
    console_dict: {}
    transform_source from ideas.examples.constants
--------------------------------------------------
Ideas Console version 0.0.4. [Python version: 3.7.3]

~>> NAME = 3
~>> NAME = 42
You cannot change the value of IdeasConsole.NAME to 42
~>> del NAME
You cannot delete NAME in module IdeasConsole.
~>> NAME
3
~>> a = 3
~>> a = 4
~>> try:
...     from typing import Final  # Python 3.8+
... except ImportError:
...     class Final:
...         pass
...
~>> greetings : Final = "Hello"
~>> greetings = 3
You cannot change the value of IdeasConsole.greetings to 3
~>>
~>> NAME += 3
You cannot change the value of IdeasConsole.NAME to 6
~>> globals()['NAME'] = 6
You cannot change the value of IdeasConsole.NAME to 6
~>>
~>> from tests.constants import uppercase
You cannot change the value of IDEAS:\tests\constants\uppercase.py.XX to 44
You cannot change the value of IDEAS:\tests\constants\uppercase.py.XX to 38
You cannot change the value of IDEAS:\tests\constants\uppercase.py.XX to Sneaky
You cannot change the value of IDEAS:\tests\constants\uppercase.py.YY to (3, 3)
You cannot change the value of IDEAS:\tests\constants\uppercase.py.YY to 1
~>>
~>> uppercase.XX = 99
You cannot change the value of IDEAS:\tests\constants\uppercase.py.XX to 99
~>>
~>> uppercase.XX
36
~>> # Cheating ...
~>> uppercase.__dict__['XX'] = 99
~>> uppercase.XX
99

I have not (yet) found a way to prevent the cheat that is done. I think it might be possible by creating a different module object.

Todo

Try to create a different module object.

How does it work

Suppose I have a variable X in module Y. Normally, there are two ways that we can change the value of this variable:

  1. By some statement execute within module Y; something like X = new_value

  2. By importing Y in another module and doing something like Y.X = new_value from that module.

To prevent changes to variables intended to be constants, two different strategies must be used, depending on whether case 1 or case 2 above is used.

Suggestion for you

Try to implement your own variation, perhaps by introducing let as a new keyword:

let this_variable = 3  # this_variable is thus declared as constant

This import hook makes it possible to define constants, that is variables which cannot have their initial value changed.

In this import hook, constants are identified in two ways:

  1. names in all UPPERCASE_LETTERS, which is a common Python convention

  2. variables that were declared to be constant by the inclusion of a Final type hint.

class ideas.examples.constants.FinalDict(module_filename, on_prevent_change=True)[source]

A FinalDict is a dict subclass which ensures that constants are not over-written.

Constants are identified in two ways:

  1. names in all UPPERCASE_LETTERS, which is a common Python convention

  2. variables that were declared to be constant by the inclusion of a Final type hint.

Note: We only override methods which could result in changing the value of a constant.

pop(key) value, remove specified key and return the corresponding value,[source]

unless the key is identified as a constant.

If key is not found, d is returned if given, otherwise KeyError is raised

setdefault(key, default=None)[source]

Insert key with a value of default if key is not in the dictionary.

It prevents changes if the key is identified as a constant.

update(mapping_or_iterable=(), **kwargs)[source]

Updates the content of the dict from a mapping or an iterable, or from a list of keywords arguments.

Keys identified as constants are prevented from changing.

ideas.examples.constants.add_hook(on_prevent_change=None, **_kwargs)[source]

Creates and adds the import hook in sys.meta_path

When an attempt is made to change the value of a constant, on_prevent_change is called. By default, this function just prints a warning about what was done. This could be replaced by a function that logs the results or one that raises an exception, etc.

ideas.examples.constants.exec_(code_object, filename=None, module=None, callback_params=None, **kwargs)[source]

Executes a code_object in a local instance of a FinalDict.

The argument globals_ is assumed to be the original module’s __dict__. A module’s __dict__ is a read-only object; therefore, we cannot execute code in it while expecting to be able to prevent changes to variables intended to remain constants. However, we can do so indirectly.

Instead of executing code using the module’s __dict__ directly, we start by making a copy of its content into a FinalDict instance. We execute the code in that instance, and use its value after execution to update the module’s __dict__.

ideas.examples.constants.on_change_print(filename=None, key=None, value=None, kind=None)[source]

Function called by default when an attempt is made to change the value of a constant.

ideas.examples.constants.transform_source(source, filename=None, **_kwargs)[source]

Identifies simple assignments with a Final type hint, returning the source unchanged.

The pattern we are looking for is:

|python_identifier : Final ...

where | indicates the beginning of a line.