Im Build-Prozess eines C++-Projekts kann es sinnvoll sein, Python-Skripte einzusetzen, beispielsweise für Codegeneratoren. Um sicherzustellen, dass diese Skripte stets mit denselben Abhängigkeiten arbeiten, unabhängig vom verwendeten Rechner, bietet sich die Nutzung einer virtuellen Python-Umgebung als gute Lösung an.

Kurze Einführung in venv

venv ist ein Python-Paket, das die Erstellung von virtuellen Umgebungen ermöglicht. Es hilft, Projektabhängigkeiten von globalen Python-Installationen zu trennen. Da venv die Isolation und Reproduzierbarkeit von Projekt Abhängigkeiten gewährleistet, wird empfohlen, es in den meisten Fällen - auch außerhalb von Build-Umgebungen - zu verwenden.

Hinweis Ein wesentlicher Aspekt von venv ist die Tatsache, dass es seit Python 3.3 ein Bestandteil der Python-Standardbibliothek ist. Das bedeutet, dass man keine zusätzlichen Pakete oder Tools installieren muss, um venv zu verwenden. Sollte das für Dich kein Hindernis sein, existieren Python-Bibliotheken auf PyPi, die die Erstellung von virtuellen Umgebungen noch benutzerfreundlicher und einfacher machen. Insbesondere ist hier virtualenv zu nennen, aus dem venv ursprünglich hervorgegangen ist.

Um eine virtuelle Umgebung mit venv zu erstellen, führe den folgenden Befehl aus:

python -m venv mein_venv

Der angegebene Befehl erstellt im aktuellen Arbeitsverzeichnis ein neues Verzeichnis mit dem Namen mein_venv. In diesem Verzeichnis wird die virtuelle Umgebung eingerichtet. Das bedeutet unter anderem, dass der Python-Interpreter und die Standardbibliothek dorthin kopiert werden.

Anschließend kann sie durch folgenden Befehl aktiviert werden:

Windows: mein_venv\Scripts\activate
Linux/macOS: source mein_venv/bin/activate

Von diesem Zeitpunkt an ist die virtuelle Umgebung aktiv. Dies lässt sich am einfachsten daran erkennen, dass der Kommandozeile mein_venv vorangestellt wird. Alle Aufrufe des Python-Interpreters beziehen sich nun auf die virtuelle Umgebung.

Achtung: Der Befehl ändert den Pfad. Verwendet ihr absolute Pfade zum installierten Python Interpreter so wird nicht die virtuelle Umgebung verwendet. Das kann insbesondere in Makefiles leicht passiern.

Um die Umgebung zu deaktivieren, gib einfach deactivate ein.

Pakete in einer virtuellen Umgebung installieren

Nachdem du deine virtuelle Umgebung erstellt und aktiviert hast, kannst du pip verwenden, um benötigte Pakete zu installieren. Am besten tust du dies, indem du eine requirements.txt erstellst und sie wie folgt mit pip verwendest.

pip install -r requirements.txt

Dadurch werden alle in der requirements.txt Datei angegebenen Pakete und ihre angegebenen Versionen in die aktive virtuelle Umgebung installiert. Das Format dieser Datei ist hier beschrieben.

Nach Installation der benötigen Pakete kann die virtuelle Umgebung, so wie eine globale Python Installation verwendet werden.

Tipp: Wird in der requirements.txtlokalen Paketen die -e Option vorangstellt (steht für “editable”), erkennt das System Änderungen direkt am ursprünglichen Speicherort des Pakets. Diese Option ist besonders nützlich, wenn lokale Pakete installiert werden sollen, die sich innerhalb der Build-Umgebung befinden. Dadurch können Anpassungen und Updates im Paket leichter verfolgt und übernommen werden, ohne das Paket jedes Mal erneut installieren zu müssen.

Verwendung von venv in einer Build Umgebung

Alles zuvor Beschriebene zusammengenommen ermöglicht es, venv in einer Build-Umgebung zu verwenden. Wenn etwa Historie ein klassisches Makefile zum Einsatz kommt, kann man, bevor der Python-Interpreter aufgerufen wird, erst einmal eine virtuelle Umgebung aufsetzen.

Da dies jedoch einige Sekunden benötigt, die bei inkrementellen Builds durchaus störend sein können (vor allem für mich 😃), möchte ich hier noch ein Skript anbieten. Es führt die zuvor genannten Schritte aus und bietet darüber hinaus noch folgende Funktionen:

  • Es erstellt die virtuelle Umgebung nur, wenn sie noch nicht existiert.
  • Es installiert Pakete nur, wenn sich die requirements.txt nicht geändert hat.
  • Es baut die virtuelle Umgebung neu auf, wenn sich das Skript selbst geändert hat.
  • Schließlich aktiviert es die virtuelle Umgebung direkt.

Die angegebenen Funktionen werden durch das Erstellen von Zeitstempel-Dateien im Verzeichnis der virtuellen Umgebung erreicht.

Man kann das, was das Skript erreicht, auch durch Makerules erzielen. Allerdings finde ich den Ansatz über ein Skript angenehmer, da er von den meisten Entwicklern intuitiv besser verstanden wird. Entscheidet selbst.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""
This script creates a virtual environment and updates its dependencies.
The following features are supported.
- The script checks if the venv exists. If it does it will not recreate it.
- The script checks if the requirements.txt has changed. If it has, packages of the venv will be updated.
- The script checks if itself has changed. If it has, the venv will be recreated.
"""

import os
import subprocess
import sys
import argparse
import logging
from pathlib import Path

logger = logging.getLogger(__name__)

REQUIREMENTS_TIMESTAMP_FILENAME = ".requirements_timestamp"
SCRIPT_TIMESTAMP_FILENAME = ".setup_venv_timestamp"
PYTHON_CMD = "python"
SCRIPT_PATH = os.path.realpath(__file__)
SCRIPT_DIR = os.path.dirname(SCRIPT_PATH)
REQUIREMENTS_FILE = os.path.join(SCRIPT_DIR, "requirements.txt")

def get_file_timestamp(filepath):
    return os.path.getmtime(filepath)


def create_virtualenv(venv_dir, clear):
    """Creates a virtual environment with venv
    Parameters
    ----------
    venv_dir : path to the directory where the venv will be created
    clear    : if true the former venv will be deleted before creation of a new one.
    """
    sargs = [PYTHON_CMD]

    sargs += ["-m", "venv"]
    if clear:
        sargs += ["--clear"]

    sargs += [venv_dir]
    subprocess.run(sargs)
    python_executable = os.path.join(venv_dir, "Scripts", "python")

    # Upgrade pip to its newest version - does not work in some build environments
    subprocess.run([python_executable, "-m", "pip", "install", "--upgrade", "pip"])


def install_requirements(venv_dir, requirements_file):
    # Get the current working directory
    cwd = os.getcwd()
    logger.debug(f'install_requirements: {{old_cwd: "{cwd}"}}')
    python_executable = os.path.join(venv_dir, "Scripts", "python")

    # !!This is a hack!! The working directory is changed to the directory where this
    # script is contained to support relative imports in the requirements.txt.
    # The script and requiremnts.txt must therefore be always in the same directory.
    os.chdir(SCRIPT_DIR)
    cwd = os.getcwd()
    logger.debug(f'changed working directory to: {{cwd: "{cwd}"}}')
    logger.debug(f'install_requirements: {{python_loc: "{python_executable}"}}')
    subprocess.run([python_executable, "-m", "pip", "install", "-r", requirements_file])
    os.chdir(cwd)
    cwd = os.getcwd()
    logger.debug(f'changed working directory to: {{cwd: "{cwd}"}}')


def update_virtual_env(
    venv_dir,
    requirements_timestamp_file,
    current_requirements_timestamp,
    script_timestamp_file,
    current_script_timestamp,
):
    print("Installing/Updating requirements...")
    install_requirements(venv_dir, REQUIREMENTS_FILE)
    Path(requirements_timestamp_file).write_text(str(current_requirements_timestamp))
    Path(script_timestamp_file).write_text(str(current_script_timestamp))


def is_requirement_txt_outdated(
    current_requirements_timestamp, stored_requirements_timestamp
):
    """Returns true when the requirements.txt file was changed"""
    return (stored_requirements_timestamp is None) or (
        current_requirements_timestamp > stored_requirements_timestamp
    )


def is_script_outdated(current_script_timestamp, stored_script_timestamp):
    """Returns true when this script was changed"""
    return (stored_script_timestamp is None) or (
        current_script_timestamp > stored_script_timestamp
    )


def main(arguments):
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )

    parser.add_argument(
        "-d",
        "--venv_root",
        help="Root dir where the environment is generated",
        default=".",
        type=str,
    )

    parser.add_argument(
        "-n",
        "--venv_name",
        help="Name of the environment to be generated",
        default="venv",
        type=str,
    )

    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Activates verbose output",
        default=False,
    )

    parser.add_argument(
        "-c",
        "--clear",
        action="store_true",
        help="Delete the contents of the environment directory before environment creation.",
        default=False,
    )

    args = parser.parse_args(arguments)
    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)

    venv_dir = os.path.join(args.venv_root, args.venv_name)
    logger.info(f"Directory of venv is {venv_dir}")
    venv_exists = os.path.isdir(venv_dir)

    # Get timestamp of this script
    script_timestamp_file = os.path.join(venv_dir, SCRIPT_TIMESTAMP_FILENAME)
    logger.info(f"Path of script timestamp file is {script_timestamp_file}")

    current_script_timestamp = get_file_timestamp(SCRIPT_PATH)
    stored_script_timestamp = (
        float(Path(script_timestamp_file).read_text().strip())
        if os.path.isfile(script_timestamp_file)
        else None
    )

    script_outdated = is_script_outdated(
        current_script_timestamp, stored_script_timestamp
    )
    clear_venv = args.clear or script_outdated
    if (not venv_exists) or clear_venv:
        print("Creating virtual environment...")
        create_virtualenv(venv_dir, clear_venv)

    #----

    # Get timestamp of requirements.txt
    requirements_timestamp_file = os.path.join(
        venv_dir, REQUIREMENTS_TIMESTAMP_FILENAME
    )
    logger.info(f"Path of requirements timestamp file is {requirements_timestamp_file}")

    current_requirements_timestamp = get_file_timestamp(REQUIREMENTS_FILE)
    stored_requirements_timestamp = (
        float(Path(requirements_timestamp_file).read_text().strip())
        if os.path.isfile(requirements_timestamp_file)
        else None
    )

    # Check requirements.txt
    req_txt_outdated = is_requirement_txt_outdated(
        current_requirements_timestamp, stored_requirements_timestamp
    )

    if req_txt_outdated or script_outdated:
        update_virtual_env(
            venv_dir,
            requirements_timestamp_file,
            current_requirements_timestamp,
            script_timestamp_file,
            current_script_timestamp,
        )
    else:
        print("Virtual environment is up-to-date.")

    return 0  # successfull termination


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Zusammenfassung

In diesem Artikel habe ich gezeigt, warum die Verwendung von venv im Build-Prozess eines C++ Projekts sinnvoll ist. Wir haben gesehen, wie man eine virtuelle Umgebung erstellt, aktiviert und deaktiviert. Zudem haben wir besprochen, wie man eine requirements.txt Datei verwendet um komfortabel Abhängigkeiten zu installieren. Letztlich wird dem Leser ein Skript an die Hand gegeben um die Schritte zu automatisieren.