From 79a66ffca0c8c87026ca6ee0f5a09cb2c44818ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Tue, 20 Feb 2024 14:52:35 +0100 Subject: [PATCH 1/4] docs: Django sample on emulator Run the Django sample on the emulator. --- samples/python/django/pgadapter.py | 133 +++++++++++++++++++++++++ samples/python/django/requirements.txt | 9 +- samples/python/django/sample.py | 15 ++- samples/python/django/setting.py | 8 +- 4 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 samples/python/django/pgadapter.py diff --git a/samples/python/django/pgadapter.py b/samples/python/django/pgadapter.py new file mode 100644 index 0000000000..ae6c39eae0 --- /dev/null +++ b/samples/python/django/pgadapter.py @@ -0,0 +1,133 @@ +# Copyright 2023 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility for starting and stopping PGAdapter in an embedded container + +Defines functions for starting and stopping PGAdapter in an embedded Docker +container. Requires that Docker is installed on the local system. +""" + +import io +import json +import os +import socket +import time +import google.auth +import google.oauth2.credentials +import google.oauth2.service_account +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + +# Global variables for the in-process PGAdapter instance. +in_process_pgadapter_host = None +in_process_pgadapter_port = None + + +def start_pgadapter(project: str, + instance: str, + emulator: bool = False, + credentials: str = None) -> (DockerContainer, str): + """Starts PGAdapter in an embedded Docker container + + Starts PGAdapter in an embedded Docker container and returns the TCP port + number where PGAdapter is listening for incoming connections. You can Use any + standard PostgreSQL driver to connect to this port. + + Parameters + ---------- + project : str + The Google Cloud project that PGAdapter should connect to. + instance : str + The Cloud Spanner instance that PGAdapter should connect to. + emulator: bool + Whether PGAdapter should connect to the Cloud Spanner emulator or real + Cloud Spanner. + credentials : str or None + The credentials file that PGAdapter should use. If None, then this + function will try to load the default credentials from the environment. + + Returns + ------- + container, port : tuple[DockerContainer, str] + The Docker container running PGAdapter and + the port where PGAdapter is listening. Connect to this port on localhost + with a standard PostgreSQL driver to connect to Cloud Spanner. + """ + + if emulator: + # Start PGAdapter with the Cloud Spanner emulator in a Docker container + container =( + DockerContainer("gcr.io/cloud-spanner-pg-adapter/pgadapter-emulator") + .with_exposed_ports(5432) + .with_command("-p " + project + " -i " + instance)) + container.start() + else: + # Start PGAdapter in a Docker container + container = DockerContainer("gcr.io/cloud-spanner-pg-adapter/pgadapter") \ + .with_exposed_ports(5432) \ + .with_command(" -p " + project + + " -i " + instance + + " -x -c /credentials.json") + container.start() + # Determine the credentials that should be used by PGAdapter and write these + # to a file in the container. + credentials_info = _determine_credentials(credentials) + container.exec("sh -c 'cat <> /credentials.json\n" + + json.dumps(credentials_info, indent=0) + + "\nEOT'") + # Wait until PGAdapter has started and is listening on the exposed port. + wait_for_logs(container, "PostgreSQL version:") + port = container.get_exposed_port("5432") + _wait_for_port(port=int(port)) + + global in_process_pgadapter_host + global in_process_pgadapter_port + in_process_pgadapter_port = port + in_process_pgadapter_host = "localhost" + + return container, port + + +def _determine_credentials(credentials: str): + if credentials is None: + explicit_file = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + else: + explicit_file = credentials + if explicit_file is None: + credentials, _ = google.auth.default() + if type(credentials).__name__ == \ + google.oauth2.credentials.Credentials.__name__: + info = json.loads(credentials.to_json()) + info["type"] = "authorized_user" + else: + raise ValueError("GOOGLE_APPLICATION_CREDENTIALS has not been set " + "and no explicit credentials were supplied") + else: + with io.open(explicit_file, "r") as file_obj: + info = json.load(file_obj) + return info + + +def _wait_for_port(port: int, poll_interval: float = 0.1, timeout: float = 5.0): + start = time.time() + while True: + try: + with socket.create_connection(("localhost", port), timeout=timeout): + break + except OSError: + duration = time.time() - start + if timeout and duration > timeout: + raise TimeoutError("container did not listen on port {} in {} seconds" + .format(port, timeout)) + time.sleep(poll_interval) diff --git a/samples/python/django/requirements.txt b/samples/python/django/requirements.txt index 5b803663fa..25c37e589b 100644 --- a/samples/python/django/requirements.txt +++ b/samples/python/django/requirements.txt @@ -1,3 +1,6 @@ -psycopg2-binary~=2.9.3 -pytz~=2022.1 -Django~=4.1.8 +psycopg-binary==3.1.18 +pytz==2024.1 +Django==4.2.10 +google==3.0.0 +google.auth==2.28.0 +testcontainers==3.7.1 diff --git a/samples/python/django/sample.py b/samples/python/django/sample.py index 95d2f72089..0829b47002 100644 --- a/samples/python/django/sample.py +++ b/samples/python/django/sample.py @@ -9,6 +9,9 @@ from django.db.models import IntegerField, CharField, BooleanField from django.db.models.functions import Cast import random +from pgadapter import start_pgadapter + + x = 0 def create_sample_singer(singer_id): @@ -191,22 +194,28 @@ def jsonb_filter(): if __name__ == "__main__": + # Start PGAdapter in an embedded container. + container, port = start_pgadapter("emulator-project", + "test-instance", + True, + None) + os.environ.setdefault('PGPORT', str(port)) try: tables_created = False - #setting up django + # Setting up django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'setting') django.setup() print('Django Setup Created') - #creating the tables if they don't exist + # Creating the tables if they don't exist create_tables() print('Tables corresponding to data models created') tables_created = True - #importing the models + # Importing the models from sample_app.model import Singer, Album, Track, Concert, Venue print('Starting Django Test') diff --git a/samples/python/django/setting.py b/samples/python/django/setting.py index 8ab82013c6..05e42c7757 100644 --- a/samples/python/django/setting.py +++ b/samples/python/django/setting.py @@ -1,14 +1,16 @@ +import os + INSTALLED_APPS = [ 'sample_app' ] DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', + "ENGINE": "django.db.backends.postgresql", 'NAME': 'postgres', 'USER': 'postgres', 'PASSWORD': 'postgres', - 'PORT': '5432', - 'HOST': 'localhost' + 'PORT': os.getenv('PGPORT', '5432'), + 'HOST': os.getenv('PGHOST', 'localhost') } } From 4692b1998008c9649e62863251bd618f5f3c9c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Wed, 24 Apr 2024 07:48:42 +0200 Subject: [PATCH 2/4] chore: cleanup and add test runner --- .github/workflows/samples.yaml | 5 + samples/python/django/sample.py | 131 +++++++++++++--------- samples/python/django/sample_app/model.py | 51 +++++---- 3 files changed, 113 insertions(+), 74 deletions(-) diff --git a/.github/workflows/samples.yaml b/.github/workflows/samples.yaml index 42cc19d760..3c97a7a3a8 100644 --- a/.github/workflows/samples.yaml +++ b/.github/workflows/samples.yaml @@ -55,6 +55,11 @@ jobs: run: | pip install -r requirements.txt python run_sample.py + - name: Run Django Sample tests + working-directory: ./samples/python/django + run: | + pip install -r requirements.txt + python sample.py nodejs-samples: runs-on: ubuntu-latest steps: diff --git a/samples/python/django/sample.py b/samples/python/django/sample.py index 0829b47002..f7cb16516e 100644 --- a/samples/python/django/sample.py +++ b/samples/python/django/sample.py @@ -1,59 +1,69 @@ +# Copyright 2024 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os -import sys import datetime import django import pytz +import random +import string +import sys from django.db import connection from django.db import transaction from django.db.transaction import atomic from django.db.models import IntegerField, CharField, BooleanField from django.db.models.functions import Cast -import random from pgadapter import start_pgadapter -x = 0 - def create_sample_singer(singer_id): - global x - x += 1 return Singer(id=singer_id, first_name='singer', - last_name=str(x), - full_name= 'singer'+str(x), + last_name=random_string(10), + full_name=random_string(20), active=True, created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) + def create_sample_album(album_id, singer=None): - global x - x += 1 return Album(id=album_id, singer=singer, - title='album'+str(x), - marketing_budget = 200000, - release_date = datetime.date.today(), - cover_picture= b'hello world', + title=random_string(10), + marketing_budget=200000, + release_date=datetime.date.today(), + cover_picture=b'hello world', created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) + def create_sample_track(track_id, track_number, album = None): - global x - x += 1 return Track(track_id=track_id, album=album, track_number=track_number, - title='track'+str(x), + title=random_string(15), sample_rate=124.543, created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) -def get_sample_venue_description(x): + +def get_sample_venue_description(): random.seed(datetime.datetime.now().timestamp()) description = { - 'address': 'address'+str(x), - 'capacity': random.randint(100*x, 500*x), + 'address': random_string(20), + 'capacity': random.randint(1000, 50000), 'isPopular': random.choice([True, False]) } @@ -61,26 +71,28 @@ def get_sample_venue_description(x): def create_sample_venue(venue_id): - global x - x += 1 return Venue(id=venue_id, - name='venue'+str(x), - description=get_sample_venue_description(x), + name=random_string(10), + description=get_sample_venue_description(), created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) + def create_sample_concert(concert_id, venue = None, singer = None): - global x - x += 1 return Concert(id=concert_id, venue=venue, singer=singer, - name='concert'+str(x), + name=random_string(20), start_time=datetime.datetime.now(pytz.UTC), - end_time=datetime.datetime.now(pytz.UTC)+datetime.timedelta(hours=1), + end_time=datetime.datetime.now(pytz.UTC) + datetime.timedelta(hours=1), created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) + +def random_string(length): + return ''.join(random.choice(string.ascii_lowercase) for _ in range(length)) + + def create_tables(): file = open('create_data_model.sql', 'r') ddl_statements = file.read() @@ -88,6 +100,7 @@ def create_tables(): with connection.cursor() as cursor: cursor.execute(ddl_statements) + @atomic(savepoint=False) def add_data(): singer = create_sample_singer('1') @@ -105,6 +118,7 @@ def add_data(): concert = create_sample_concert('1', venue, singer) concert.save() + @atomic(savepoint=False) def delete_all_data(): Track.objects.all().delete() @@ -113,40 +127,41 @@ def delete_all_data(): Venue.objects.all().delete() Singer.objects.all().delete() + def foreign_key_operations(): singer1 = Singer.objects.filter(id='1')[0] album1 = Album.objects.filter(id='1')[0] - #originally album1 belongs to singer1 + # Originally album1 belongs to singer1 if album1.singer_id != singer1.id: raise Exception("Album1 doesn't belong to singer1") singer2 = create_sample_singer('2') singer2.save() - global x - x += 1 album2 = singer2.album_set.create(id='2', - title='album'+str(x), + title=random_string(20), marketing_budget=250000, cover_picture=b'new world', created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) - #checking if the newly created album2 is associated with singer 2 + # Checking if the newly created album2 is associated with singer 2 if album2.singer_id != singer2.id: raise Exception("Album2 is not associated with singer2") - #checking if the album2 is actually saved to the db + # Checking if the album2 is actually saved to the db if len(Album.objects.filter(id=album2.id)) == 0: raise Exception("Album2 not found in the db") - #associating album1 to singer2 + # Associating album1 to singer2 singer2.album_set.add(album1) - #checking if album1 belongs to singer2 + # Checking if album1 belongs to singer2 if album1.singer_id != singer2.id: - raise Exception("Couldn't change the parent of album1 fromm singer1 to singer2") + raise Exception("Couldn't change the parent of " + "album1 fromm singer1 to singer2") + def transaction_rollback(): transaction.set_autocommit(False) @@ -157,10 +172,11 @@ def transaction_rollback(): transaction.rollback() transaction.set_autocommit(True) - #checking if singer3 is present in the actual table or not + # Checking if singer3 is present in the actual table or not if len(Singer.objects.filter(id='3')) > 0: raise Exception('Transaction Rollback Unsuccessful') + def jsonb_filter(): venue1 = create_sample_venue('10') venue2 = create_sample_venue('100') @@ -171,26 +187,38 @@ def jsonb_filter(): venue3.save() # In order to query inside the fields of a jsonb column, - # we first need to use annotate to cast the respective jsonb field to the relevant data type. + # we first need to use annotate to cast the respective jsonb + # field to the relevant data type. # In this example, the 'address' field is cast to CharField # and then a filter is applied to this field. - # Make sure to enclose the filter value in double quotes("") for string values. + # Make sure to enclose the filter value in double quotes("") for string + # values. - fetched_venue1 = Venue.objects.annotate(address=Cast('description__address', output_field=CharField())).filter(address='"'+venue1.description['address']+'"').first() + fetched_venue1 = Venue.objects.annotate( + address=Cast('description__address', + output_field=CharField())).filter( + address='"'+venue1.description['address']+'"').first() if fetched_venue1.id != venue1.id: - raise Exception('No Venue found with address ' + venue1.description['address']) + raise Exception('No Venue found with address ' + + venue1.description['address']) - fetched_venue2 = Venue.objects.annotate(capacity=Cast('description__capacity', output_field=IntegerField())).filter(capacity=venue2.description['capacity']).first() + fetched_venue2 = Venue.objects.annotate( + capacity=Cast('description__capacity', + output_field=IntegerField())).filter( + capacity=venue2.description['capacity']).first() if fetched_venue2.id != venue2.id: - raise Exception('No Venue found with capacity ' + venue1.description['capacity']) + raise Exception('No Venue found with capacity ' + + venue1.description['capacity']) - - fetched_venues3 = Venue.objects.annotate(isPopular=Cast('description__isPopular', output_field=BooleanField())).filter(isPopular=venue3.description['isPopular']).only('id') + fetched_venues3 = Venue.objects.annotate( + isPopular=Cast('description__isPopular', + output_field=BooleanField())).filter( + isPopular=venue3.description['isPopular']).only('id') if venue3 not in fetched_venues3: - raise Exception('No Venue found with popularity ' + venue1.description['isPopular']) - + raise Exception('No Venue found with popularity ' + + venue1.description['isPopular']) if __name__ == "__main__": @@ -199,10 +227,11 @@ def jsonb_filter(): "test-instance", True, None) - os.environ.setdefault('PGPORT', str(port)) + os.environ.setdefault("PGHOST", "localhost") + os.environ.setdefault("PGPORT", str(port)) + tables_created = False try: - tables_created = False # Setting up django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'setting') @@ -241,4 +270,4 @@ def jsonb_filter(): print(e) if tables_created: delete_all_data() - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/samples/python/django/sample_app/model.py b/samples/python/django/sample_app/model.py index f4c88032de..4d958c56b3 100644 --- a/samples/python/django/sample_app/model.py +++ b/samples/python/django/sample_app/model.py @@ -1,30 +1,32 @@ -''' Copyright 2022 Google LLC - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -''' +# Copyright 2022 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from django.db import models from django.db.models import Q, F -from django.contrib.postgres.fields import JSONField + class BaseModel(models.Model): - class Meta(): + objects = models.Manager() + + class Meta: abstract = True created_at = models.DateTimeField() updated_at = models.DateTimeField() + class Singer(BaseModel): - class Meta(): + class Meta: db_table = 'singers' id = models.CharField(primary_key=True, null=False) @@ -33,8 +35,9 @@ class Meta(): full_name = models.CharField(null=False) active = models.BooleanField() + class Album(BaseModel): - class Meta(): + class Meta: db_table = 'albums' id = models.CharField(primary_key=True, null=False) @@ -44,8 +47,9 @@ class Meta(): cover_picture = models.BinaryField() singer = models.ForeignKey(Singer, on_delete=models.DO_NOTHING) + class Track(BaseModel): - class Meta(): + class Meta: db_table = 'tracks' # Here, track_id is a column that is supposed to be primary key by Django. @@ -63,7 +67,7 @@ class Meta(): class Venue(BaseModel): - class Meta(): + class Meta: db_table = 'venues' id = models.CharField(primary_key=True, null=False) name = models.CharField(null=False) @@ -71,13 +75,14 @@ class Meta(): class Concert(BaseModel): - class Meta(): + class Meta: db_table = 'concerts' - constraints = [models.CheckConstraint(check = Q(end_time__gte=F('start_time')), name='chk_end_time_after_start_time' )] + constraints = [models.CheckConstraint( + check=Q(end_time__gte=F('start_time')), + name='chk_end_time_after_start_time')] id = models.CharField(primary_key=True, null=False) venue = models.ForeignKey(Venue, on_delete=models.DO_NOTHING) singer = models.ForeignKey(Singer, on_delete=models.DO_NOTHING) name = models.CharField(null=False) start_time = models.DateTimeField(null=False) end_time = models.DateTimeField(null=False) - From c5cc5555c0b5c7a0a95909a767157429b33980ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Wed, 24 Apr 2024 08:11:50 +0200 Subject: [PATCH 3/4] chore: more cleanup --- samples/python/django/sample.py | 56 ++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/samples/python/django/sample.py b/samples/python/django/sample.py index f7cb16516e..dfee97b414 100644 --- a/samples/python/django/sample.py +++ b/samples/python/django/sample.py @@ -14,6 +14,8 @@ import os import datetime +import uuid + import django import pytz import random @@ -27,8 +29,8 @@ from pgadapter import start_pgadapter -def create_sample_singer(singer_id): - return Singer(id=singer_id, +def create_sample_singer(): + return Singer(id=str(uuid.uuid4()), first_name='singer', last_name=random_string(10), full_name=random_string(20), @@ -37,8 +39,8 @@ def create_sample_singer(singer_id): updated_at=datetime.datetime.now(pytz.UTC)) -def create_sample_album(album_id, singer=None): - return Album(id=album_id, +def create_sample_album(singer=None): + return Album(id=str(uuid.uuid4()), singer=singer, title=random_string(10), marketing_budget=200000, @@ -48,8 +50,8 @@ def create_sample_album(album_id, singer=None): updated_at=datetime.datetime.now(pytz.UTC)) -def create_sample_track(track_id, track_number, album = None): - return Track(track_id=track_id, +def create_sample_track(track_number, album=None): + return Track(track_id=str(uuid.uuid4()), album=album, track_number=track_number, title=random_string(15), @@ -70,16 +72,16 @@ def get_sample_venue_description(): return description -def create_sample_venue(venue_id): - return Venue(id=venue_id, +def create_sample_venue(): + return Venue(id=str(uuid.uuid4()), name=random_string(10), description=get_sample_venue_description(), created_at=datetime.datetime.now(pytz.UTC), updated_at=datetime.datetime.now(pytz.UTC)) -def create_sample_concert(concert_id, venue = None, singer = None): - return Concert(id=concert_id, +def create_sample_concert(venue=None, singer=None): + return Concert(id=str(uuid.uuid4()), venue=venue, singer=singer, name=random_string(20), @@ -103,21 +105,23 @@ def create_tables(): @atomic(savepoint=False) def add_data(): - singer = create_sample_singer('1') + singer = create_sample_singer() singer.save() - album = create_sample_album('1', singer) + album = create_sample_album(singer) album.save() - track = create_sample_track('1', '2', album) + track = create_sample_track('2', album) track.save(force_insert=True) - venue = create_sample_venue('1') + venue = create_sample_venue() venue.save() - concert = create_sample_concert('1', venue, singer) + concert = create_sample_concert(venue, singer) concert.save() + return singer.id, album.id + @atomic(savepoint=False) def delete_all_data(): @@ -128,18 +132,18 @@ def delete_all_data(): Singer.objects.all().delete() -def foreign_key_operations(): - singer1 = Singer.objects.filter(id='1')[0] - album1 = Album.objects.filter(id='1')[0] +def foreign_key_operations(singer_id: str, album_id: str): + singer1 = Singer.objects.filter(id=singer_id)[0] + album1 = Album.objects.filter(id=album_id)[0] # Originally album1 belongs to singer1 if album1.singer_id != singer1.id: raise Exception("Album1 doesn't belong to singer1") - singer2 = create_sample_singer('2') + singer2 = create_sample_singer() singer2.save() - album2 = singer2.album_set.create(id='2', + album2 = singer2.album_set.create(id=str(uuid.uuid4()), title=random_string(20), marketing_budget=250000, cover_picture=b'new world', @@ -166,7 +170,7 @@ def foreign_key_operations(): def transaction_rollback(): transaction.set_autocommit(False) - singer3 = create_sample_singer('3') + singer3 = create_sample_singer() singer3.save() transaction.rollback() @@ -178,9 +182,9 @@ def transaction_rollback(): def jsonb_filter(): - venue1 = create_sample_venue('10') - venue2 = create_sample_venue('100') - venue3 = create_sample_venue('1000') + venue1 = create_sample_venue() + venue2 = create_sample_venue() + venue3 = create_sample_venue() venue1.save() venue2.save() @@ -249,10 +253,10 @@ def jsonb_filter(): print('Starting Django Test') - add_data() + singer_id, album_id = add_data() print('Adding Data Successful') - foreign_key_operations() + foreign_key_operations(singer_id, album_id) print('Testing Foreign Key Successful') transaction_rollback() From d77ba3b3de4adcdcacc8c60ad0cddfb92c00b603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Tue, 30 Apr 2024 14:27:17 +0200 Subject: [PATCH 4/4] deps: bump Python version --- .github/workflows/samples.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/samples.yaml b/.github/workflows/samples.yaml index 529e34da49..e40cf5c5ef 100644 --- a/.github/workflows/samples.yaml +++ b/.github/workflows/samples.yaml @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - run: python --version - name: Install pip run: python -m pip install --upgrade pip