kakakakakku blog

Weekly Tech Blog: Keep on Learning!

Dependabot の uv サポートを試す

Python プロジェクトで uv を使っていて,Dependabot で依存関係を自動アップデートできないという悩みがあった.ちなみに Renovate はもともと uv をサポートしているという背景があった👌現時点では uv のドキュメントには uv is supported by Renovate. / Support for uv is not yet available. と書いてある.

docs.astral.sh

Dependabot の uv サポート

2025年3月13日に Dependabot の uv サポートが発表された🎉

github.blog

もともと Dependabot で uv をサポートするための issue があって,定期的に進捗確認をしていたから「ついに!」という感じ.

github.com

さっそく試す

package-ecosystemuv を指定すれば OK👌

👾 .github/dependabot.yml

version: 2

updates:
  - package-ecosystem: uv
    directory: /
    schedule:
      interval: daily
    open-pull-requests-limit: 1
    target-branch: main

docs.github.com

👾 pyproject.toml

検証用の pyproject.tomlboto3 の依存関係を設定した🐬

[project]
name = "sandbox-dependabot-uv"
version = "0.1.0"
description = "sandbox-dependabot-uv"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "boto3==1.37.16",
]

プルリクエスト

すると期待通りにプルリクエストが作られた👏

ちゃんと pyproject.tomluv.lock がアップデートされていてイイ感じ〜 \( 'ω')/

関連記事

kakakakakku.hatenablog.com

kakakakakku.hatenablog.com

JSON Schema の dependentRequired で「a を指定する場合は b もセットで必要」を実現する

JSON Schema で dependentRequired を使うと JSON のプロパティ構造を「条件付きで」バリデーションできる🔐 具体例を挙げると,任意プロパティが ab 2つあるときにa を指定する場合は b もセットで必要」というバリデーションをしたいときに使える❗️

json-schema.org

JSON Schema のドキュメント (Conditional schema validation) に載っている例だと,name プロパティは必須で,もし任意の credit_card プロパティを指定する場合は billing_address プロパティもセットで必要という感じ👌

{
  "type": "object",

  "properties": {
    "name": { "type": "string" },
    "credit_card": { "type": "number" },
    "billing_address": { "type": "string" }
  },

  "required": ["name"],

  "dependentRequired": {
    "credit_card": ["billing_address"]
  }
}

動作確認

pytest でテストコードを実装して dependentRequired の動作確認をしてみた❗️ちなみに JSON Schema のバージョンによって記法が違っていて,Draft 2020-12(正確には Draft 2019-09 以降)では dependentRequired で,Draft-07 だと dependencies となる.詳しくは Draft 2019-09 のリリースノートに載っている📝

json-schema.org

テスト項目としては以下の3種類の JSON を JSON Schema Draft 2020-12 と JSON Schema Draft-07 でバリデーションしてみた👌

  • INPUT_1(name)🙆‍♂️
  • INPUT_2(name and credit_card)🙅
  • INPUT_3(name and credit_card and billing_address)🙆‍♂️
import jsonschema
import pytest
from faker import Faker

fake = Faker()


SCHEMA_DRAFT_2020_12 = {
    '$schema': 'https://json-schema.org/draft/2020-12/schema',
    'type': 'object',
    'properties': {
        'name': {'type': 'string'},
        'credit_card': {'type': 'number'},
        'billing_address': {'type': 'string'},
    },
    'required': ['name'],
    'dependentRequired': {'credit_card': ['billing_address']},
}


SCHEMA_DRAFT_07 = {
    '$schema': 'https://json-schema.org/draft-07/schema',
    'type': 'object',
    'properties': {
        'name': {'type': 'string'},
        'credit_card': {'type': 'number'},
        'billing_address': {'type': 'string'},
    },
    'required': ['name'],
    'dependencies': {'credit_card': ['billing_address']},
}


INPUT_1 = {
    'name': fake.name(),
}

INPUT_2 = {
    'name': fake.name(),
    'credit_card': int(fake.credit_card_number()),
}

INPUT_3 = {
    'name': fake.name(),
    'credit_card': int(fake.credit_card_number()),
    'billing_address': fake.address(),
}


def idfn(param):
    return param['id']


@pytest.mark.parametrize(
    'patterns',
    [
        {
            'id': 'Draft 2020-12|1',
            'schema': SCHEMA_DRAFT_2020_12,
            'validator': jsonschema.Draft202012Validator,
            'input': INPUT_1,
            'valid': True,
        },
        {
            'id': 'Draft 2020-12|2',
            'schema': SCHEMA_DRAFT_2020_12,
            'validator': jsonschema.Draft202012Validator,
            'input': INPUT_2,
            'valid': False,
        },
        {
            'id': 'Draft 2020-12|3',
            'schema': SCHEMA_DRAFT_2020_12,
            'validator': jsonschema.Draft202012Validator,
            'input': INPUT_3,
            'valid': True,
        },
        {
            'id': 'Draft-07|1',
            'schema': SCHEMA_DRAFT_07,
            'validator': jsonschema.Draft7Validator,
            'input': INPUT_1,
            'valid': True,
        },
        {
            'id': 'Draft-07|2',
            'schema': SCHEMA_DRAFT_07,
            'validator': jsonschema.Draft7Validator,
            'input': INPUT_2,
            'valid': False,
        },
        {
            'id': 'Draft-07|3',
            'schema': SCHEMA_DRAFT_07,
            'validator': jsonschema.Draft7Validator,
            'input': INPUT_3,
            'valid': True,
        },
    ],
    ids=idfn,
)
def test_json_schema(patterns):
    if patterns['valid']:
        patterns['validator'](patterns['schema']).validate(patterns['input'])
    else:
        with pytest.raises(jsonschema.ValidationError):
            patterns['validator'](patterns['schema']).validate(patterns['input'])

実行すると期待通りになっていた👌

$ uv run pytest --verbose
(中略)
test_main.py::test_json_schema[Draft 2020-12|1] PASSED
test_main.py::test_json_schema[Draft 2020-12|2] PASSED
test_main.py::test_json_schema[Draft 2020-12|3] PASSED
test_main.py::test_json_schema[Draft-07|1] PASSED
test_main.py::test_json_schema[Draft-07|2] PASSED
test_main.py::test_json_schema[Draft-07|3] PASSED

まとめ

JSON Schema の dependentRequired は便利そうだから覚えておこう〜 \( 'ω')/

Testcontainers for Python と Moto を組み合わせて Boto3 を使った Python コードをテストする

Moto で AWS サービスをモックして AWS SDK for Python (Boto3) を使った Python コードをテストする場合に @mock_aws デコレータや with ブロックを使うという選択肢がある.詳しくは以下のドキュメントに載っている📝

docs.getmoto.org

また Moto には「Server Mode」というコンテナベースで起動する方法もある😀

docs.getmoto.org

普段 pytest を使って Python コードをテストする場合は MySQL や LocalStack などの依存関係を Testcontainers for Python で管理していて便利なので,Moto も使えたら良いのにな〜と思っていた💡残念ながら Testcontainers for Python は Moto をサポートしていないけど,任意のコンテナイメージを起動できる DockerContainer があって,試してみることにした.結果的にイメージ通りに Testcontainers for Python と Moto を組み合わせることができた❗️

testcontainers-python.readthedocs.io

サンプルコード

👾 src/mymodel.py

今回はサンプルとして Moto のドキュメントに載っていた MyModel クラスを少し修正してテスト対象にする.save() 関数を実行すると Amazon S3 にファイルをアップロードするシンプルな実装になっている💡

そして,Boto3 の Amazon S3 クライアントは環境変数 ENV によって3種類作れるようにしてある👌

  • local: ローカル開発用 (Moto)
  • test: テスト用 (testcontainers-python / Moto)
  • その他: 実際の AWS アカウント
import os

import boto3

if os.environ['ENV'] == 'local':
    s3 = boto3.client('s3', region_name='us-east-1', endpoint_url='http://localhost:5000')
elif os.environ['ENV'] == 'test':
    s3 = boto3.client(
        's3', region_name='us-east-1', endpoint_url=f'http://localhost:{os.environ['TESTCONTAINERS_MOTO_PORT']}'
    )
else:
    s3 = boto3.client('s3', region_name='us-east-1')


class MyModel:
    def __init__(self, name, value):
        self.name = name
        self.value = value

    def save(self):
        s3.put_object(Bucket='mybucket', Key=self.name, Body=self.value)


if __name__ == '__main__':
    model_instance = MyModel('steve', 'is awesome')
    model_instance.save()

👾 tests/test_mymodel.py

テストコードでは,まずフィクスチャで Testcontainers for Python の DockerContainer を使って Moto のコンテナ (motoserver/moto) を起動していて,Amazon S3 バケット mybucket も追加している.なお Testcontainers for Python で Moto を起動するとポートはランダムに決まるため,環境変数 TESTCONTAINERS_MOTO_PORT に設定して src/mymodel.py でも参照できるようにしている👌

そして,実際のテストでは save() 関数によってアップロードされた Amazon S3 オブジェクトを取得して,中身を確認している.

import os

import boto3
import pytest
from testcontainers.core.container import DockerContainer


@pytest.fixture(scope='module', autouse=True)
def _setup():
    with DockerContainer('motoserver/moto').with_exposed_ports(5000) as container:
        os.environ['TESTCONTAINERS_MOTO_PORT'] = container.get_exposed_port(5000)

        s3 = boto3.client(
            's3', region_name='us-east-1', endpoint_url=f'http://localhost:{os.environ['TESTCONTAINERS_MOTO_PORT']}'
        )
        s3.create_bucket(Bucket='mybucket')

        yield


def test_my_model_save():
    from mymodel import MyModel, s3

    model_instance = MyModel('steve', 'is awesome')
    model_instance.save()

    body = s3.get_object(Bucket='mybucket', Key='steve')['Body'].read().decode('utf-8')

    assert body == 'is awesome'

動作確認

今回は Python プロジェクトを uv で管理しているけど,環境変数 ENV を設定して pytest を実行するとイメージ通りに動いた👌pytest 実行時に一時的に Moto が起動して,テスト終了後は自動的に削除される.便利〜 \( 'ω')/

$ ENV=test uv run pytest -p no:warnings --verbose tests
============================================================================================================================= test session starts ==============================================================================================================================
(中略)
configfile: pyproject.toml
collected 1 item

tests/test_mymodel.py::test_my_model_save PASSED                                                                                                                                                                                                                         [100%]

============================================================================================================================== 1 passed in 1.48s ===============================================================================================================================

GitHub Actions でテストを実行する

ついでに GitHub Actions でも pytest を実行できるようにしてみた👌

👾 .github/workflows/pytest.yml

name: Run pytest

on:
  workflow_dispatch:
  push:
    branches:
      - main

env:
  AWS_PROFILE: moto

jobs:
  pytest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - name: Setup Python
        run: uv python install
      - name: Install dependencies
        run: uv sync
      - name: Set AWS Profile for Moto
        run: |
          aws configure set --profile moto aws_access_key_id DUMMY
          aws configure set --profile moto aws_secret_access_key DUMMY
      - name: Run pytest
        run: ENV=test uv run pytest -p no:warnings --verbose tests

まとめ

Testcontainers for Python と Moto を組み合わせて pytest を実行できるようにしてみた❗️これは結構便利かな〜と思う.

ちなみにコードは GitHub に置いてあるので参考にどうぞ〜 \( 'ω')/

github.com

関連記事

kakakakakku.hatenablog.com

Python の mimetypes.guess_type() で .geojson を変換できるようにする

Python 標準ライブラリ mimetypes を使って GeoJSON ファイル名 (.geojson) から MIME タイプに変換しようとしたら (None, None) になってしまった😇

>>> import mimetypes
>>> mimetypes.guess_type('example.geojson')
(None, None)

docs.python.org

前提条件

  • Python 3.12

mimetypes.types_map を確認する

MIME タイプのマッピングは環境によって異なる場合があって,今回は macOS (Sonoma) と AWS Lambda 関数 (Python 3.12) で mimetypes.types_map の値から JSON 関連のマッピングを確認してみた.やはり .geojson のマッピングはなかった🥲

macOS (Sonoma)

{'.json': 'application/json', '.jsonml': 'application/jsonml+json'}

AWS Lambda 関数 (Python 3.12)

{'.json': 'application/json'}

mimetypes.add_type() で追加する

mimetypes.add_type() でマッピングを追加すれば OK👌

>>> mimetypes.add_type('application/geo+json', '.geojson')
>>> mimetypes.guess_type('example.geojson')
('application/geo+json', None)

knownfiles を確認する

MIME タイプのマッピング (knownfiles) は mimetypes.knownfiles で確認できる.

>>> mimetypes.knownfiles
['/etc/mime.types', '/etc/httpd/mime.types', '/etc/httpd/conf/mime.types', '/etc/apache/mime.types', '/etc/apache2/mime.types', '/usr/local/etc/httpd/conf/mime.types', '/usr/local/lib/netscape/mime.types', '/usr/local/etc/httpd/conf/mime.types', '/usr/local/etc/mime.types']

github.com

macOS だと /etc/apache2/mime.types に knownfiles があって,JSON 関連のマッピングを確認してみた.

mimetypes.types_map の値と同じで application/jsonapplication/jsonml+json はサポートされていて,application/geo+json はコメントアウトになっていた💡

$ grep -i json /etc/apache2/mime.types
# application/alto-costmap+json
# application/alto-costmapfilter+json
# application/alto-directory+json
# application/alto-endpointcost+json
# application/alto-endpointcostparams+json
# application/alto-endpointprop+json
# application/alto-endpointpropparams+json
# application/alto-error+json
# application/alto-networkmap+json
# application/alto-networkmapfilter+json
# application/calendar+json
# application/coap-group+json
# application/csvm+json
# application/geo+json
# application/jose+json
# application/jrd+json
application/json                json
# application/json-patch+json
# application/json-seq
application/jsonml+json             jsonml
# application/jwk+json
# application/jwk-set+json
# application/ld+json
# application/merge-patch+json
# application/ppsp-tracker+json
# application/problem+json
# application/rdap+json
# application/reputon+json
# application/scim+json
# application/vcard+json
# application/vnd.apache.thrift.json
# application/vnd.api+json
# application/vnd.bekitzur-stech+json
# application/vnd.collection+json
# application/vnd.collection.doc+json
# application/vnd.collection.next+json
# application/vnd.coreos.ignition+json
# application/vnd.document+json
# application/vnd.drive+json
# application/vnd.geo+json
# application/vnd.hal+json
# application/vnd.heroku+json
# application/vnd.hyperdrive+json
# application/vnd.ims.lis.v2.result+json
# application/vnd.ims.lti.v2.toolconsumerprofile+json
# application/vnd.ims.lti.v2.toolproxy+json
# application/vnd.ims.lti.v2.toolproxy.id+json
# application/vnd.ims.lti.v2.toolsettings+json
# application/vnd.ims.lti.v2.toolsettings.simple+json
# application/vnd.mason+json
# application/vnd.micro+json
# application/vnd.miele+json
# application/vnd.oftn.l10n+json
# application/vnd.oma.lwm2m+json
# application/vnd.oracle.resource+json
# application/vnd.pagerduty+json
# application/vnd.siren+json
# application/vnd.vel+json
# application/vnd.xacml+json
# model/gltf+json

Python 3.13 だと

ちなみに Python 3.13 のドキュメントを読むと mimetypes.guess_type()soft deprecated(警告は出ずに引き続き使える非推奨)になっていて,今後は mimetypes.guess_file_type() を使うことになりそう📝 覚えておこう〜

Deprecated since version 3.13: Passing a file path instead of URL is soft deprecated. Use guess_file_type() for this.

>>> import mimetypes
>>> mimetypes.guess_file_type('example.json')
('application/json', None)

docs.python.org

参考サイト

www.iana.org

Powertools for AWS Lambda (Python) で Cognito User Pools の トークン生成前トリガーを実装しよう

Amazon Cognito User Pools には AWS Lambda 関数を使って認証フローをカスタマイズできる「Lambda トリガー」という機能がある💡トークン生成前トリガー (Pre token generation Lambda trigger) を使うと,認証時に発行されるトークン (ID Token / Access Token) をカスタマイズできる👌

docs.aws.amazon.com

docs.aws.amazon.com

ちなみにトークン (Access Token) は2023年12月のリリースでカスタマイズできるようになっていたりする💡

aws.amazon.com

Powertools for AWS Lambda (Python)

Powertools for AWS Lambda (Python) の Event Source Data Classes を使うと,シンプルに Lambda トリガーを実装できるようになる.今回は PreTokenGenerationTriggerEvent を使ってトークン (ID Token) のペイロードにクレームを追加/削除するトークン生成前トリガーを実装する❗️

docs.powertools.aws.dev

準備

まずは Amazon Cognito User Pools を作っておく❗️今回はサクッと認証を試すためにアプリケーションクライアントの認証フローとして ALLOW_USER_PASSWORD_AUTH も有効化する.あとカスタム属性として X アカウント名を表す custom:x を追加しておく.その他はデフォルト設定で良いかなと思う👌

トークンを取得する(トークン生成前トリガーなし)

まずは「トークン生成前トリガーなし」でトークン (ID Token) を取得する.AWS CLI で aws cognito-idp initiate-auth コマンドを実行する💡

awscli.amazonaws.com

$ CLIENT_ID=xxx
$ EMAIL=xxx
$ PASSWORD=xxx

$ aws cognito-idp initiate-auth \
  --client-id ${CLIENT_ID} \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters USERNAME=${EMAIL},PASSWORD=${PASSWORD} | jq .
{
  "ChallengeParameters": {},
  "AuthenticationResult": {
    "AccessToken": "xxx",
    "ExpiresIn": 3600,
    "TokenType": "Bearer",
    "RefreshToken": "xxx",
    "IdToken": "xxx"
  }
}

取得した IdToken の値から JWT Payload を抜き出すと以下のようになっていた❗️追加したカスタム属性 custom:x にも @kakakakakku という値が入っている👌

{
  "sub": "f7a4da98-a041-70e4-7954-8bbc5d3ca764",
  "email_verified": true,
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxxxxxx",
  "cognito:username": "f7a4da98-a041-70e4-7954-8bbc5d3ca764",
  "custom:x": "@kakakakakku",
  "origin_jti": "85ed5e84-1346-41fa-8269-1e5aa0a35bab",
  "aud": "xxx",
  "event_id": "525e5170-54f5-450c-a053-258fde4ea50f",
  "token_use": "id",
  "auth_time": 1726876453,
  "exp": 1726880053,
  "iat": 1726876453,
  "jti": "db5441ca-8710-4fe7-b0cc-4b31f6fc77e0",
  "email": "y.yoshida22@gmail.com"
}

トークン (ID Token) の詳細は以下のドキュメントに載っている📝

docs.aws.amazon.com

トークン生成前トリガー

次に AWS Lambda 関数を実装してトークン生成前トリガーを設定する.

AWS Lambda 関数の実装は Powertools for AWS Lambda (Python) の Event Source Data Classes を使って,my_key クレームの追加と custom:x クレームの削除をする.実装自体は簡単にできる👌

👾 app.py(サンプル1)

シンプルに実装する場合

from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import PreTokenGenerationTriggerEvent


def lambda_handler(event, context):
    event: PreTokenGenerationTriggerEvent = PreTokenGenerationTriggerEvent(event)
    event.response.claims_override_details.claims_to_add_or_override = {'my_key': 'my_value'}
    event.response.claims_override_details.claims_to_suppress = ['custom:x']

    return event.raw_event

👾 app.py(サンプル2)

ハンドラとロジックを分離してテストをしやすく実装する場合

import json

from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import PreTokenGenerationTriggerEvent


def main(event):
    event: PreTokenGenerationTriggerEvent = PreTokenGenerationTriggerEvent(event)
    event.response.claims_override_details.claims_to_add_or_override = {'my_key': 'my_value'}
    event.response.claims_override_details.claims_to_suppress = ['custom:x']

    return event.raw_event


def lambda_handler(event, context):
    return main(event)


if __name__ == '__main__':
    with open('./events/event.json', 'r') as f:
        event = json.load(f)
    print(main(event))

トークンを取得する(トークン生成前トリガーあり)

もう一度 AWS CLI で aws cognito-idp initiate-auth コマンドを実行して,IdToken の値から JWT Payload を抜き出すと以下のようになっていた❗️期待通りに my_key が追加されていて,カスタム属性 custom:x は削除されていた👌

{
  "sub": "f7a4da98-a041-70e4-7954-8bbc5d3ca764",
  "email_verified": true,
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxxxxxx",
  "cognito:username": "f7a4da98-a041-70e4-7954-8bbc5d3ca764",
  "origin_jti": "888ee4b8-4c3b-4a78-a34c-7e5f04c3788c",
  "aud": "xxx",
  "event_id": "bf25e9fe-beb5-4ad2-b1c7-188eda16ea75",
  "token_use": "id",
  "auth_time": 1726876789,
  "my_key": "my_value",
  "exp": 1726880389,
  "iat": 1726876789,
  "jti": "f75d7eaa-c826-4c40-9951-6dbe8bcc100a",
  "email": "y.yoshida22@gmail.com"
}

まとめ

Powertools for AWS Lambda (Python) の Event Source Data Classes を使って Amazon Cognito User Pools のトークン生成前トリガーをシンプルに実装するサンプルの紹介でした〜 \( 'ω')/