Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
lpsinger committed Feb 7, 2024
0 parents commit e7da2f9
Show file tree
Hide file tree
Showing 14 changed files with 1,447 additions and 0 deletions.
14 changes: 14 additions & 0 deletions .github/actions/install-dynamodb/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Install DynamoDB
description: >
Install a local version of DynamoDB.
See https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html
outputs:
dynamodb-dir:
description: Path to the directory containing the JAR file
value: ${{ steps.step.outputs.dynamodb-dir }}
runs:
using: composite
steps:
- id: step
run: curl https://d1ni2b6xgvw0s0.cloudfront.net/v2.x/dynamodb_local_latest.tar.gz | tar -C ${{ github.action_path }} -xz && echo dynamodb-dir=${{ github.action_path }} >> $GITHUB_OUTPUT
shell: bash
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2

updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

- package-ecosystem: pip
directory: /
schedule:
interval: weekly
12 changes: 12 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
on:
push:
branches:
- main
tags:
- v*

jobs:
publish:
uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish_pure_python.yml@v1
secrets:
pypi_token: ${{ secrets.pypi_token }}
46 changes: 46 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Pull Request

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check for Lint
uses: chartboost/ruff-action@v1

- name: Check Formatting
uses: chartboost/ruff-action@v1
with:
args: format --check

- uses: actions/setup-python@v5
with:
python-version: '3.11'

- id: install-dynamodb
name: Install DynamoDB
uses: ./.github/actions/install-dynamodb

- name: Install Poetry
run: pipx install poetry

- name: Install Dependencies
run: poetry install --all-extras

- name: Pytest
run: poetry run pytest --dynamodb-dir=${{ steps.install-dynamodb.outputs.dynamodb-dir }} --cov --cov-report=xml

- name: MyPy
run: poetry run mypy .

- name: Upload to Codecov.io
uses: codecov/codecov-action@v4
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
*.egg-info
__pycache__
.coverage
.tox
.vscode
build
dist
_version.py
dynamodb-local-metadata.json
5 changes: 5 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"recommendations": [
"charliermarsh.ruff"
]
}
Empty file added README.md
Empty file.
188 changes: 188 additions & 0 deletions dynamodb_autoincrement/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Iterable, Optional

from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource

from .types import DynamoDBItem


@dataclass(frozen=True)
class BaseDynamoDBAutoIncrement(ABC):
dynamodb: DynamoDBServiceResource
counter_table_name: str
counter_table_key: DynamoDBItem
attribute_name: str
table_name: str
initial_value: int
dangerously: bool = False

@abstractmethod
def next(self, item: DynamoDBItem) -> tuple[Iterable[dict[str, Any]], str]:
raise NotImplementedError

def _put_item(self, *, TableName, **kwargs):
# FIXME: DynamoDB resource does not have put_item method; emulate it
self.dynamodb.Table(TableName).put_item(**kwargs)

def _get_item(self, *, TableName, **kwargs):
# FIXME: DynamoDB resource does not have get_item method; emulate it
return self.dynamodb.Table(TableName).get_item(**kwargs)

def _query(self, *, TableName, **kwargs):
return self.dynamodb.Table(TableName).query(**kwargs)

def put(self, item: DynamoDBItem):
TransactionCanceledException = (
self.dynamodb.meta.client.exceptions.TransactionCanceledException
)
while True:
puts, next_counter = self.next(item)
if self.dangerously:
for put in puts:
self._put_item(**put)
else:
try:
self.dynamodb.transact_write_items(
TransactItems=[{"Put": put} for put in puts]
)
except TransactionCanceledException:
continue
return next_counter


class DynamoDBAutoIncrement(BaseDynamoDBAutoIncrement):
def next(self, item):
counter = (
self._get_item(
AttributesToGet=[self.attribute_name],
Key=self.counter_table_key,
TableName=self.counter_table_name,
)
.get("Item", {})
.get(self.attribute_name)
)

if counter is None:
next_counter = self.initial_value
put_kwargs = {"ConditionExpression": "attribute_not_exists(#counter)"}
else:
next_counter = counter + 1
put_kwargs = {
"ConditionExpression": "#counter = :counter",
"ExpressionAttributeValues": {
":counter": counter,
},
}

puts = [
{
**put_kwargs,
"ExpressionAttributeNames": {
"#counter": self.attribute_name,
},
"Item": {
**self.counter_table_key,
self.attribute_name: next_counter,
},
"TableName": self.counter_table_name,
},
{
"ConditionExpression": "attribute_not_exists(#counter)",
"ExpressionAttributeNames": {
"#counter": self.attribute_name,
},
"Item": {self.attribute_name: next_counter, **item},
"TableName": self.table_name,
},
]

return puts, next_counter


class DynamoDBHistoryAutoIncrement(BaseDynamoDBAutoIncrement):
def list(self) -> list[int]:
result = self._query(
TableName=self.table_name,
ExpressionAttributeNames={
**{f"#{i}": key for i, key in enumerate(self.counter_table_key.keys())},
"#counter": self.attribute_name,
},
ExpressionAttributeValues={
f":{i}": value
for i, value in enumerate(self.counter_table_key.values())
},
KeyConditionExpression=" AND ".join(
f"#{i} = :{i}" for i in range(len(self.counter_table_key.keys()))
),
ProjectionExpression="#counter",
)
return sorted(item[self.attribute_name] for item in result["Items"])

def get(self, version: Optional[int] = None) -> DynamoDBItem:
if version is None:
kwargs = {
"TableName": self.counter_table_name,
"Key": self.counter_table_key,
}
else:
kwargs = {
"TableName": self.table_name,
"Key": {**self.counter_table_key, self.attribute_name: version},
}
return self._get_item(**kwargs).get("Item")

def next(self, item):
existing_item = self._get_item(
TableName=self.counter_table_name,
Key=self.counter_table_key,
).get("Item")

counter = (
None if existing_item is None else existing_item.get(self.attribute_name)
)

if counter is None:
next_counter = self.initial_value
put_kwargs = {"ConditionExpression": "attribute_not_exists(#counter)"}
else:
next_counter = counter + 1
put_kwargs = {
"ConditionExpression": "#counter = :counter",
"ExpressionAttributeValues": {
":counter": counter,
},
}

if existing_item is not None and counter is not None:
existing_item[self.attribute_name] = next_counter
next_counter += 1

puts = [
{
**put_kwargs,
"ExpressionAttributeNames": {
"#counter": self.attribute_name,
},
"Item": {
**item,
**self.counter_table_key,
self.attribute_name: next_counter,
},
"TableName": self.counter_table_name,
},
]

if existing_item is not None:
puts.append(
{
"ConditionExpression": "attribute_not_exists(#counter)",
"ExpressionAttributeNames": {
"#counter": self.attribute_name,
},
"Item": existing_item,
"TableName": self.table_name,
}
)

return puts, next_counter
Empty file.
36 changes: 36 additions & 0 deletions dynamodb_autoincrement/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pytest


@pytest.fixture
def create_tables(dynamodb):
for kwargs in [
{
"AttributeDefinitions": [
{"AttributeName": "tableName", "AttributeType": "S"}
],
"BillingMode": "PAY_PER_REQUEST",
"KeySchema": [{"AttributeName": "tableName", "KeyType": "HASH"}],
"TableName": "autoincrement",
},
{
"BillingMode": "PAY_PER_REQUEST",
"AttributeDefinitions": [
{"AttributeName": "widgetID", "AttributeType": "N"}
],
"KeySchema": [{"AttributeName": "widgetID", "KeyType": "HASH"}],
"TableName": "widgets",
},
{
"BillingMode": "PAY_PER_REQUEST",
"AttributeDefinitions": [
{"AttributeName": "widgetID", "AttributeType": "N"},
{"AttributeName": "version", "AttributeType": "N"},
],
"KeySchema": [
{"AttributeName": "widgetID", "KeyType": "HASH"},
{"AttributeName": "version", "KeyType": "RANGE"},
],
"TableName": "widgetHistory",
},
]:
dynamodb.create_table(**kwargs)
Loading

0 comments on commit e7da2f9

Please sign in to comment.