Compare commits

...

10 Commits

Author SHA1 Message Date
Paul Harrison c114366357 feat: Dockerize API 2022-12-15 16:43:09 +00:00
Paul Harrison 484c636513 chore: Update README 2022-12-15 16:40:52 +00:00
Paul Harrison 9baafc80ed feat: Hand ranking API
Hand ranking API with a health check root endpoint and rank endpoint.
2022-12-15 16:02:14 +00:00
Paul Harrison b4ba284dcc feat: Construct ranked hand with description
Refactors rank test functions to retern details required for
description, then return RankedHand from rank_hand function.
2022-12-15 16:02:14 +00:00
Paul Harrison d924e39608 feat: Get hand rank
Method to get rank of a given hand.
2022-12-15 16:02:14 +00:00
Paul Harrison 5393e7b799 chore: Validate hand length 2022-12-15 16:02:14 +00:00
Paul Harrison 68bfee17f1 chore: Introduce card factory 2022-12-15 16:02:14 +00:00
Paul Harrison 6be02c447d feat: Ensure hand comprises unique cards 2022-12-15 16:02:14 +00:00
Paul Harrison c337984965 fix: Suit enum definition 2022-12-15 16:02:14 +00:00
Paul Harrison 2ba3a42b9b chore: Add coverage 2022-12-15 16:02:14 +00:00
17 changed files with 1256 additions and 17 deletions

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM python:3.11-slim AS os-cache
ENV TZ=UTC
ENV PYTHONUNBUFFERED=1
ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_CREATE=true
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
ARG DEBIAN_FRONTEND=noninteractive
RUN apt update -y
RUN apt upgrade -y
RUN apt install -y build-essential
WORKDIR /app
# FIXME: README.md seemed to be required here but I'm not sure why!
COPY poetry.lock pyproject.toml Makefile README.md ./
RUN pip install "poetry~=1.2"
FROM os-cache AS image
COPY poker ./poker
RUN make install-prod
FROM os-cache AS test-image
COPY setup.cfg ./
COPY poker ./poker
COPY tests ./tests
RUN make install

View File

@ -1,6 +1,10 @@
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
SHELL := /bin/bash SHELL := /bin/bash
.PHONY: api
api: ## Run API
@poetry run uvicorn --host 0.0.0.0 poker.api:app
.PHONY: black .PHONY: black
black: ## Run black formatter black: ## Run black formatter
@poetry run black poker tests; @poetry run black poker tests;
@ -9,6 +13,10 @@ black: ## Run black formatter
black-check: ## Run black formatter black-check: ## Run black formatter
@poetry run black poker tests --check; @poetry run black poker tests --check;
.PHONY: build
build: ## Build Docker container
@COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose.yml build;
.PHONY: clean .PHONY: clean
clean: ## Remove python cache files clean: ## Remove python cache files
-@find . -name '*.pyc' -exec rm -f {} +; -@find . -name '*.pyc' -exec rm -f {} +;
@ -18,6 +26,10 @@ clean: ## Remove python cache files
-@find . -name '*.pytest_cache' -exec rm -fr {} +; -@find . -name '*.pytest_cache' -exec rm -fr {} +;
-@find . -name '*.coverage' -exec rm -fr {} +; -@find . -name '*.coverage' -exec rm -fr {} +;
.PHONY: coverage
coverage: ## Report test coverage
@poetry run coverage report --rcfile=setup.cfg;
.PHONY: flake8 .PHONY: flake8
flake8: ## Run flake8 linting flake8: ## Run flake8 linting
@poetry run flake8 poker tests --config=setup.cfg; @poetry run flake8 poker tests --config=setup.cfg;
@ -63,7 +75,7 @@ quality: flake8 mypy isort-check black-check pydocstyle ## Run linting checks
.PHONY: test .PHONY: test
test: ## Run test pipeline test: ## Run test pipeline
@poetry run pytest -c=setup.cfg -x @poetry run coverage run --rcfile=setup.cfg --source=poker -m pytest -c=setup.cfg -x
.PHONY: uninstall .PHONY: uninstall
uninstall: ## Remove virtual environment uninstall: ## Remove virtual environment
@ -71,4 +83,4 @@ uninstall: ## Remove virtual environment
.PHONY: update .PHONY: update
update: ## Update poetry.lock using pyproject.toml update: ## Update poetry.lock using pyproject.toml
@poetry update; @poetry update;

View File

@ -1,6 +1,6 @@
# Single Poker Hand Ranking Service # Single Poker Hand Ranking Service
## Requirements ## Project Scope
This service comprises an API to compute the rank of an individual poker hand. The requirements are to: This service comprises an API to compute the rank of an individual poker hand. The scope of this project is to:
- Write an algorithm that takes a hand of cards and identifies the ranking of the given hand. - Write an algorithm that takes a hand of cards and identifies the ranking of the given hand.
- Expose an API to serve this algorithm via an endpoint `/rank`, that accepts a valid poker hand and returns its ranking. - Expose an API to serve this algorithm via an endpoint `/rank`, that accepts a valid poker hand and returns its ranking.
@ -88,3 +88,18 @@ Result: "full house: 4 over 2"
Query: "6H 7H 8H 9H 10H" Query: "6H 7H 8H 9H 10H"
Result: "straight flush: 10-high diamonds" Result: "straight flush: 10-high diamonds"
``` ```
## Requirements
- Python 3.11
- [Poetry](https://python-poetry.org/)
- [GNU Make](https://www.gnu.org/software/make/)
## Usage
- Install with `make install`.
- Run linting and tests with `make quality test coverage clean`.
- Build the API container with `make build`, then run API with `docker compose up -d`.
- Check API health with `curl localhost:8000`
- Query API for rank with e.g.
```shell
curl -X POST -d '2H 3D 5S 10C KD' localhost:8000/rank
```

9
compose.yml Normal file
View File

@ -0,0 +1,9 @@
services:
api:
container_name: poker-api
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
command: make api

295
poetry.lock generated
View File

@ -1,3 +1,20 @@
[[package]]
name = "anyio"
version = "3.6.2"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
trio = ["trio (>=0.16,<0.22)"]
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "22.1.0" version = "22.1.0"
@ -32,11 +49,19 @@ d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"] uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2022.9.24"
description = "Python package for providing Mozilla's CA Bundle."
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.3" version = "8.1.3"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
@ -47,10 +72,21 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
category = "dev" category = "main"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "coverage"
version = "6.5.0"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
toml = ["tomli"]
[[package]] [[package]]
name = "eradicate" name = "eradicate"
version = "2.1.0" version = "2.1.0"
@ -59,6 +95,24 @@ category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "fastapi"
version = "0.87.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0"
starlette = "0.21.0"
[package.extras]
all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.114)", "uvicorn[standard] (>=0.12.0,<0.19.0)"]
doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer[all] (>=0.6.1,<0.7.0)"]
test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.8.0)", "coverage[toml] (>=6.5.0,<7.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.114)", "sqlalchemy (>=1.3.18,<=1.4.41)", "types-orjson (==3.6.2)", "types-ujson (==5.5.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"]
[[package]] [[package]]
name = "flake8" name = "flake8"
version = "5.0.4" version = "5.0.4"
@ -123,6 +177,60 @@ ci = ["coverage (>=4.0.0,<5.0.0)", "coveralls", "flake8-builtins", "flake8-comma
dev = ["coverage (>=4.0.0,<5.0.0)", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"] dev = ["coverage (>=4.0.0,<5.0.0)", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"]
test = ["coverage (>=4.0.0,<5.0.0)", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"] test = ["coverage (>=4.0.0,<5.0.0)", "flake8-builtins", "flake8-commas", "flake8-fixme", "flake8-print", "flake8-quotes", "flake8-todo", "pytest (>=4)", "pytest-cov (>=2)"]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "httpcore"
version = "0.16.1"
description = "A minimal low-level HTTP client."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
anyio = ">=3.0,<5.0"
certifi = "*"
h11 = ">=0.13,<0.15"
sniffio = ">=1.0.0,<2.0.0"
[package.extras]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "httpx"
version = "0.23.1"
description = "The next generation HTTP client."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
certifi = "*"
httpcore = ">=0.15.0,<0.17.0"
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]]
name = "idna"
version = "3.4"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "1.1.1" version = "1.1.1"
@ -145,6 +253,21 @@ pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
plugins = ["setuptools"] plugins = ["setuptools"]
requirements_deprecated_finder = ["pip-api", "pipreqs"] requirements_deprecated_finder = ["pip-api", "pipreqs"]
[[package]]
name = "loguru"
version = "0.6.0"
description = "Python logging made (stupidly) simple"
category = "main"
optional = false
python-versions = ">=3.5"
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (>=4.1.1)", "black (>=19.10b0)", "colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "isort (>=5.1.1)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "tox (>=3.9.0)"]
[[package]] [[package]]
name = "mccabe" name = "mccabe"
version = "0.7.0" version = "0.7.0"
@ -296,6 +419,28 @@ pluggy = ">=0.12,<2.0"
[package.extras] [package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "rfc3986"
version = "1.5.0"
description = "Validating URI References per RFC 3986"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
[package.extras]
idna2008 = ["idna"]
[[package]]
name = "sniffio"
version = "1.3.0"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]] [[package]]
name = "snowballstemmer" name = "snowballstemmer"
version = "2.2.0" version = "2.2.0"
@ -304,6 +449,20 @@ category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "starlette"
version = "0.21.0"
description = "The little ASGI library that shines."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.4.0" version = "4.4.0"
@ -312,12 +471,42 @@ category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[[package]]
name = "uvicorn"
version = "0.20.0"
description = "The lightning-fast ASGI server."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "win32-setctime"
version = "1.1.0"
description = "A small Python utility to set file creation time on Windows"
category = "main"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = ">=3.11,<3.12" python-versions = ">=3.11,<3.12"
content-hash = "0de1b1934bd8af99bf38ce61df2b14773e345daa43c6cb1917bb5b282e4b1e65" content-hash = "13bef4122cae8394c862b2497c696d777b88604803563af278fbbfdc0d7c3b78"
[metadata.files] [metadata.files]
anyio = [
{file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
{file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
]
attrs = [ attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
@ -345,6 +534,10 @@ black = [
{file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"}, {file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"},
{file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"},
] ]
certifi = [
{file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"},
{file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"},
]
click = [ click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
@ -353,10 +546,66 @@ colorama = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
coverage = [
{file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"},
{file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"},
{file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"},
{file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"},
{file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"},
{file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"},
{file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"},
{file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"},
{file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"},
{file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"},
{file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"},
{file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"},
{file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"},
{file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"},
{file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"},
{file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"},
{file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"},
{file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"},
{file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"},
{file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"},
{file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"},
{file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"},
{file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"},
{file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"},
{file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"},
{file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"},
{file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"},
{file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"},
{file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"},
{file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"},
{file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"},
{file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"},
{file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"},
{file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"},
{file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"},
{file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"},
{file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"},
{file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"},
{file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"},
{file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"},
{file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"},
{file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"},
{file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"},
{file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"},
{file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"},
{file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"},
{file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"},
{file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"},
{file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"},
{file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"},
]
eradicate = [ eradicate = [
{file = "eradicate-2.1.0-py3-none-any.whl", hash = "sha256:8bfaca181db9227dc88bdbce4d051a9627604c2243e7d85324f6d6ce0fd08bb2"}, {file = "eradicate-2.1.0-py3-none-any.whl", hash = "sha256:8bfaca181db9227dc88bdbce4d051a9627604c2243e7d85324f6d6ce0fd08bb2"},
{file = "eradicate-2.1.0.tar.gz", hash = "sha256:aac7384ab25b1bf21c4c012de9b4bf8398945a14c98c911545b2ea50ab558014"}, {file = "eradicate-2.1.0.tar.gz", hash = "sha256:aac7384ab25b1bf21c4c012de9b4bf8398945a14c98c911545b2ea50ab558014"},
] ]
fastapi = [
{file = "fastapi-0.87.0-py3-none-any.whl", hash = "sha256:254453a2e22f64e2a1b4e1d8baf67d239e55b6c8165c079d25746a5220c81bb4"},
{file = "fastapi-0.87.0.tar.gz", hash = "sha256:07032e53df9a57165047b4f38731c38bdcc3be5493220471015e2b4b51b486a4"},
]
flake8 = [ flake8 = [
{file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"},
{file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"},
@ -375,6 +624,22 @@ flake8-eradicate = [
flake8-use-fstring = [ flake8-use-fstring = [
{file = "flake8-use-fstring-1.4.tar.gz", hash = "sha256:6550bf722585eb97dffa8343b0f1c372101f5c4ab5b07ebf0edd1c79880cdd39"}, {file = "flake8-use-fstring-1.4.tar.gz", hash = "sha256:6550bf722585eb97dffa8343b0f1c372101f5c4ab5b07ebf0edd1c79880cdd39"},
] ]
h11 = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
httpcore = [
{file = "httpcore-0.16.1-py3-none-any.whl", hash = "sha256:8d393db683cc8e35cc6ecb02577c5e1abfedde52b38316d038932a84b4875ecb"},
{file = "httpcore-0.16.1.tar.gz", hash = "sha256:3d3143ff5e1656a5740ea2f0c167e8e9d48c5a9bbd7f00ad1f8cff5711b08543"},
]
httpx = [
{file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"},
{file = "httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"},
]
idna = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
]
iniconfig = [ iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
@ -383,6 +648,10 @@ isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
] ]
loguru = [
{file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"},
{file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
]
mccabe = [ mccabe = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
@ -497,11 +766,31 @@ pytest = [
{file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
] ]
rfc3986 = [
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
]
sniffio = [
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
]
snowballstemmer = [ snowballstemmer = [
{file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
{file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
] ]
starlette = [
{file = "starlette-0.21.0-py3-none-any.whl", hash = "sha256:0efc058261bbcddeca93cad577efd36d0c8a317e44376bcfc0e097a2b3dc24a7"},
{file = "starlette-0.21.0.tar.gz", hash = "sha256:b1b52305ee8f7cfc48cde383496f7c11ab897cd7112b33d998b1317dc8ef9027"},
]
typing-extensions = [ typing-extensions = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
] ]
uvicorn = [
{file = "uvicorn-0.20.0-py3-none-any.whl", hash = "sha256:c3ed1598a5668208723f2bb49336f4509424ad198d6ab2615b7783db58d919fd"},
{file = "uvicorn-0.20.0.tar.gz", hash = "sha256:a4e12017b940247f836bc90b72e725d7dfd0c8ed1c51eb365f5ba30d9f5127d8"},
]
win32-setctime = [
{file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
{file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
]

56
poker/api.py Normal file
View File

@ -0,0 +1,56 @@
from fastapi import Body, FastAPI
from loguru import logger
from poker.constants import Suit, Value
from poker.models import Card, Hand
from poker.rank import rank_hand
app = FastAPI()
_CARD_PATTERN = r"\s".join(5 * ["(2|3|4|5|6|7|8|9|10|J|K|Q|A)[CDHS]"])
_SUIT_MAP = {
"C": Suit.CLUBS,
"D": Suit.DIAMONDS,
"H": Suit.HEARTS,
"S": Suit.SPADES,
}
_VALUE_MAP = {
"2": Value.TWO,
"3": Value.THREE,
"4": Value.FOUR,
"5": Value.FIVE,
"6": Value.SIX,
"7": Value.SEVEN,
"8": Value.EIGHT,
"9": Value.NINE,
"10": Value.TEN,
"J": Value.JACK,
"Q": Value.QUEEN,
"K": Value.KING,
"A": Value.ACE,
}
@app.get("/")
async def health_check() -> str:
"""Health check endpoint."""
return "OK"
@app.post("/rank")
def rank(body: str = Body(regex=_CARD_PATTERN, example="2H 3D 5S 10C KD")) -> str:
"""Rank hand.
TODO: Improve error response.
"""
logger.info(f"Input hand: {body}")
cards = [
Card(suit=_SUIT_MAP[card[-1]], value=_VALUE_MAP[card[:-1]])
for card in body.split()
]
ranked_hand = rank_hand(Hand(cards=cards))
logger.info(f"Hand rank: {ranked_hand.rank}")
return ranked_hand.description

View File

@ -3,19 +3,38 @@ from enum import IntEnum, auto
from poker.utils.enum import AutoName from poker.utils.enum import AutoName
class Rank(IntEnum):
"""Poker rank enum."""
ROYAL_FLUSH = 1
STRAIGHT_FLUSH = 2
FOUR_OF_A_KIND = 3
FULL_HOUSE = 4
FLUSH = 5
STRAIGHT = 6
THREE_OF_A_KIND = 7
TWO_PAIR = 8
PAIR = 9
HIGH_CARD = 10
def __str__(self) -> str:
"""Return string representation."""
out: str = self.name.lower().replace("_", " ").title()
return out
class Suit(AutoName): class Suit(AutoName):
"""Card suit enum.""" """Card suit enum."""
CLUBS: auto() CLUBS = auto()
DIAMONDS: auto() DIAMONDS = auto()
HEARTS: auto() HEARTS = auto()
SPADES: auto() SPADES = auto()
class Value(IntEnum): class Value(IntEnum):
"""Card value enum.""" """Card value enum."""
ACE = 1
TWO = 2 TWO = 2
THREE = 3 THREE = 3
FOUR = 4 FOUR = 4
@ -28,3 +47,12 @@ class Value(IntEnum):
JACK = 11 JACK = 11
QUEEN = 12 QUEEN = 12
KING = 13 KING = 13
ACE = 14
def __str__(self) -> str:
"""Return string representation."""
if self.value in [1, 11, 12, 13, 14]:
out: str = self.name.lower()
else:
out = str(self.value)
return out

View File

@ -1,6 +1,10 @@
from pydantic import BaseModel, Field from __future__ import annotations
from poker.constants import Suit, Value from collections import Counter
from pydantic import BaseModel, validator
from poker.constants import Rank, Suit, Value
class Card(BaseModel): class Card(BaseModel):
@ -9,8 +13,46 @@ class Card(BaseModel):
suit: Suit suit: Suit
value: Value value: Value
def __hash__(self) -> int:
"""Hash function."""
return hash(f"{self.value} {self.suit}")
def __le__(self, other: Card) -> bool:
"""Less than or equal to."""
return self.value <= other.value
def __lt__(self, other: Card) -> bool:
"""Strictly less than."""
return self.value < other.value
class Hand(BaseModel): class Hand(BaseModel):
"""Hand domain model class.""" """Hand domain model class."""
cards: list[Card] = Field(..., min_length=5, max_length=5) cards: list[Card]
@validator("cards")
def validate_unique(cls, cards: list[Card]) -> list[Card]:
"""Validate hand comprises unique cards."""
if len(cards) != len(set(cards)):
raise ValueError("Hand contains duplicate cards.")
return cards
@validator("cards")
def validate_length(cls, cards: list[Card]) -> list[Card]:
"""Validate hand has five cards."""
if len(cards) != 5:
raise ValueError("Hand must have five cards.")
return cards
@property
def value_counts(self) -> list[tuple[Value, int]]:
"""Return count of each card value in hand."""
return Counter([card.value for card in self.cards]).most_common()
class RankedHand(Hand):
"""Ranked hand domain model class."""
rank: Rank
description: str

3
poker/rank/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from poker.rank.hands import rank_hand
__all__ = ["rank_hand"]

View File

@ -0,0 +1,14 @@
from poker.constants import Rank
DESCRIPTIONS = {
Rank.ROYAL_FLUSH: "royal flush: {suit}",
Rank.STRAIGHT_FLUSH: "straight flush: {high}-high {suit}",
Rank.FOUR_OF_A_KIND: "four of a kind: {value}",
Rank.FULL_HOUSE: "full house: {trips} over {pair}",
Rank.FLUSH: "flush: {suit}",
Rank.STRAIGHT: "straight: {high}-high",
Rank.THREE_OF_A_KIND: "three of a kind: {value}",
Rank.TWO_PAIR: "two pair: {high} and {low}",
Rank.PAIR: "pair: {value}",
Rank.HIGH_CARD: "high card: {value}",
}

172
poker/rank/hands.py Normal file
View File

@ -0,0 +1,172 @@
from typing import Optional, Union
from poker.constants import Rank, Value
from poker.models import Card, Hand, RankedHand
from poker.rank.descriptions import DESCRIPTIONS
def _is_flush(cards: list[Card]) -> bool:
"""Calculate whether hand is a flush."""
return len(set(card.suit for card in cards)) == 1
def _is_straight(cards: list[Card]) -> Optional[Value]:
"""Calculate whether hand is a straight.
Arguments:
cards: List of cards in hand.
Returns:
High card if hand is a straight, None otherwise.
"""
card_values = sorted([card.value for card in cards])
# If card values are not unique it is not a straight
if len(set(card_values)) != len(card_values):
return None
# Check for a ace low straight
if card_values == [Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.ACE]:
return Value.FIVE
# If all card differences are 1 it is a straight
diffs = [second - first for first, second in zip(card_values[:-1], card_values[1:])]
if set(diffs) == {1}:
return max(cards).value
return None
def royal_flush(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand a royal flush."""
if not _is_flush(hand.cards):
return {}
values = [card.value for card in hand.cards]
_is = all(
value in values
for value in [Value.ACE, Value.KING, Value.QUEEN, Value.JACK, Value.TEN]
)
if _is:
return {"suit": hand.cards[0].suit}
return {}
def straight_flush(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand a straight flush."""
high = _is_straight(hand.cards)
if high is not None and _is_flush(hand.cards):
return {
"high": high,
"suit": hand.cards[0].suit,
}
return {}
def four_of_a_kind(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand has four of a kind."""
if hand.value_counts[0][1] == 4:
return {"value": hand.value_counts[0][0]}
return {}
def full_house(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand is a full house."""
if hand.value_counts[0][1] == 3 and hand.value_counts[1][1] == 2:
return {
"trips": hand.value_counts[0][0],
"pair": hand.value_counts[1][0],
}
return {}
def flush(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand is a flush."""
high = _is_straight(hand.cards)
if _is_flush(hand.cards) and high is None:
return {"suit": hand.cards[0].suit}
return {}
def straight(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand is a flush."""
high = _is_straight(hand.cards)
if high is not None and not _is_flush(hand.cards):
return {"high": high}
return {}
def three_of_a_kind(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand is a full house."""
if (
hand.value_counts[0][1] == 3
and hand.value_counts[1][1] == 1
and hand.value_counts[2][1] == 1
):
return {"value": hand.value_counts[0][0]}
return {}
def two_pair(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand is a two pair."""
if hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 2:
values = [hand.value_counts[0][0], hand.value_counts[1][0]]
return {
"high": max(values),
"low": min(values),
}
return {}
def pair(hand: Hand) -> dict[str, Union[str, int]]:
"""Calculate whether hand is a pair."""
if hand.value_counts[0][1] == 2 and hand.value_counts[1][1] == 1:
return {"value": hand.value_counts[0][0]}
return {}
def high_card(hand: Hand) -> dict[str, Union[str, int]]:
"""Get high card."""
return {"value": max(hand.cards).value}
_FUNCTIONS = {
Rank.ROYAL_FLUSH: royal_flush,
Rank.STRAIGHT_FLUSH: straight_flush,
Rank.FOUR_OF_A_KIND: four_of_a_kind,
Rank.FULL_HOUSE: full_house,
Rank.FLUSH: flush,
Rank.STRAIGHT: straight,
Rank.THREE_OF_A_KIND: three_of_a_kind,
Rank.TWO_PAIR: two_pair,
Rank.PAIR: pair,
Rank.HIGH_CARD: high_card,
}
def rank_hand(hand: Hand) -> RankedHand:
"""Get hand rank.
TODO: Use a factory pattern to avoid this huge flow control.
"""
out = None
for rank, func in _FUNCTIONS.items():
result = func(hand)
if result:
return RankedHand(
cards=hand.cards,
rank=rank,
description=DESCRIPTIONS[rank].format(**result),
)
if out is None:
raise ValueError("No rank found.")

View File

@ -1,26 +1,31 @@
[tool.poetry] [tool.poetry]
name = "poker" name = "poker"
version = "0.2.3" version = "2.0.0"
description = "Single poker hand ranking service." description = "Single poker hand ranking service."
authors = ["Paul Harrison"] authors = ["Paul Harrison"]
readme = "README.md" readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.11,<3.12" python = ">=3.11,<3.12"
fastapi = "^0.87.0"
loguru = "^0.6.0"
pydantic = "^1.10.2" pydantic = "^1.10.2"
uvicorn = "^0.20.0"
[tool.poetry.group.test.dependencies] [tool.poetry.group.test.dependencies]
coverage = "^6.5.0"
pytest = "^7.2.0" pytest = "^7.2.0"
httpx = "^0.23.1"
[tool.poetry.group.lint.dependencies] [tool.poetry.group.lint.dependencies]
black = "^22.10.0" black = "^22.10.0"
isort = "^5.10.1"
flake8 = "^5.0.4" flake8 = "^5.0.4"
flake8-builtins = "^2.0.1" flake8-builtins = "^2.0.1"
flake8-blind-except = "^0.2.1" flake8-blind-except = "^0.2.1"
flake8-eradicate = "^1.4.0" flake8-eradicate = "^1.4.0"
flake8-use-fstring = "^1.4" flake8-use-fstring = "^1.4"
isort = "^5.10.1"
mypy = "^0.991" mypy = "^0.991"
pydocstyle = "^6.1.1" pydocstyle = "^6.1.1"

View File

@ -1,3 +1,12 @@
[coverage:run]
data_file = tests/.coverage
relative_files = True
[coverage:report]
precision = 1
skip_empty = True
show_missing = True
[flake8] [flake8]
max-line-length = 88 max-line-length = 88
ignore = ignore =

24
tests/conftest.py Normal file
View File

@ -0,0 +1,24 @@
from typing import Any, Callable, TypeVar
import pytest
from mypy_extensions import KwArg
from poker.constants import Suit, Value
from poker.models import Card
T = TypeVar("T")
Factory = Callable[[KwArg(Any)], T]
@pytest.fixture(scope="session")
def card_factory() -> Factory[Card]:
"""Get Card object factory."""
default = {
"suit": Suit.CLUBS,
"value": Value.ACE,
}
def factory(**kwargs: Any) -> Card:
return Card(**(default | kwargs))
return factory

67
tests/test_api.py Normal file
View File

@ -0,0 +1,67 @@
import pytest
from fastapi.testclient import TestClient
from poker.api import app
@pytest.fixture(scope="function")
def client() -> TestClient:
return TestClient(app)
def test_health_check(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
assert response.json() == "OK"
@pytest.mark.parametrize(
"body,expected",
[
("AH KH QH JH 10H", "royal flush: hearts"),
("6H 7H 8H 9H 10H", "straight flush: 10-high hearts"),
("AH AC AD AS KH", "four of a kind: ace"),
("AH AC AD KS KH", "full house: ace over king"),
("KC 10C 8C 7C 5C", "flush: clubs"),
("10H 9C 8D 7S 6H", "straight: 10-high"),
("AH AC AD KS QH", "three of a kind: ace"),
("AH AC KD KS 7H", "two pair: ace and king"),
("AH AC KD JS 7H", "pair: ace"),
("AH KC QD 9S 7H", "high card: ace"),
],
ids=[
"royal-flush",
"straight-flush",
"four-of-a-kind",
"full-house",
"flush",
"straight",
"three-of-a-kind",
"two-pair",
"pair",
"high-card",
],
)
def test_rank(client: TestClient, body: str, expected: str) -> None:
response = client.post("/rank", json=body)
assert response.status_code == 200
assert response.json() == expected
@pytest.mark.parametrize(
"body",
[
"AH KH QH JH",
"AH KH QH JH 99W",
],
ids=[
"too-few",
"not-a-card",
],
)
def test_rank_bad_input(client: TestClient, body: str) -> None:
response = client.post("/rank", json=body)
assert response.status_code != 200

21
tests/test_models.py Normal file
View File

@ -0,0 +1,21 @@
import pytest
from pydantic import ValidationError
from poker.constants import Value
from poker.models import Card, Hand
from tests.conftest import Factory
def test_hand_should_contain_unique_cards(card_factory: Factory[Card]) -> None:
cards = [
Value.ACE,
Value.TWO,
Value.THREE,
Value.FOUR,
]
with pytest.raises(ValidationError):
_ = Hand(
cards=tuple(card_factory(value=value) for value in cards + [Value.FOUR])
)
_ = Hand(cards=tuple(card_factory(value=value) for value in cards + [Value.FIVE]))

442
tests/test_rank.py Normal file
View File

@ -0,0 +1,442 @@
from itertools import cycle
from typing import Optional, Union
import pytest
from poker.constants import Suit, Value
from poker.models import Card, Hand
from poker.rank.hands import (
_is_flush,
_is_straight,
flush,
four_of_a_kind,
full_house,
high_card,
pair,
royal_flush,
straight,
straight_flush,
three_of_a_kind,
two_pair,
)
from tests.conftest import Factory
@pytest.mark.parametrize(
"suits,expected",
[
([Suit.CLUBS, Suit.DIAMONDS, Suit.SPADES, Suit.HEARTS, Suit.CLUBS], False),
([Suit.CLUBS, Suit.CLUBS, Suit.CLUBS, Suit.CLUBS, Suit.CLUBS], True),
],
ids=[
"non-unique",
"unique",
],
)
def test__is_flush(
card_factory: Factory[Card], suits: list[Suit], expected: dict[str, Union[str, int]]
) -> None:
result = _is_flush([card_factory(suit=suit) for suit in suits])
if expected:
assert result
else:
assert not result
@pytest.mark.parametrize(
"values,expected",
[
([Value.TWO, Value.TWO, Value.THREE, Value.FOUR, Value.FIVE], None),
([Value.THREE, Value.TWO, Value.ACE, Value.TEN, Value.KING], None),
([Value.ACE, Value.TWO, Value.THREE, Value.FOUR, Value.FIVE], Value.FIVE),
([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], Value.ACE),
([Value.THREE, Value.FIVE, Value.TWO, Value.FOUR, Value.SIX], Value.SIX),
],
ids=[
"non-unique",
"not-flush",
"low-ace",
"high-ace",
"middle-flush",
],
)
def test__is_straight(
card_factory: Factory[Card], values: list[Value], expected: Optional[Value]
) -> None:
suits = cycle(Suit)
result = _is_straight(
[card_factory(suit=suit, value=value) for suit, value in zip(suits, values)]
)
assert result == expected
@pytest.mark.parametrize(
"values,different_suits,expected",
[
([Value.TWO, Value.TEN, Value.THREE, Value.FOUR, Value.FIVE], False, {}),
(
[Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN],
False,
{"suit": Suit.CLUBS},
),
([Value.ACE, Value.KING, Value.TEN, Value.JACK, Value.QUEEN], True, {}),
],
ids=[
"not-flush",
"royal-flush",
"flush-not-royal",
],
)
def test_is_royal_flush(
card_factory: Factory[Card],
values: list[Value],
different_suits: bool,
expected: dict[str, Union[str, int]],
) -> None:
suits = cycle(Suit)
if different_suits:
result = royal_flush(
Hand(
cards=[
card_factory(suit=suit, value=value)
for suit, value in zip(suits, values)
]
)
)
else:
result = royal_flush(
Hand(cards=[card_factory(value=value) for value in values])
)
assert result == expected
@pytest.mark.parametrize(
"values,different_suits,expected",
[
([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, {}),
([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, {}),
(
[Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX],
False,
{"high": Value.SIX, "suit": Suit.CLUBS},
),
],
ids=[
"straight-only",
"flush-only",
"straight-flush",
],
)
def test_is_straight_flush(
card_factory: Factory[Card],
values: list[Value],
different_suits: bool,
expected: dict[str, Union[str, int]],
) -> None:
suits = cycle(Suit)
if different_suits:
result = straight_flush(
Hand(
cards=[
card_factory(suit=suit, value=value)
for suit, value in zip(suits, values)
]
)
)
else:
result = straight_flush(
Hand(cards=[card_factory(value=value) for value in values])
)
assert result == expected
@pytest.mark.parametrize(
"cards,expected",
[
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.TWO),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{"value": Value.TWO},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TEN),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.TWO),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{},
),
],
ids=[
"four-of-a-kind",
"not-four-of-a-kind",
],
)
def test_is_four_of_a_kind(
cards: list[Card], expected: dict[str, Union[str, int]]
) -> None:
result = four_of_a_kind(Hand(cards=cards))
assert result == expected
@pytest.mark.parametrize(
"cards,expected",
[
(
[
Card(suit=Suit.CLUBS, value=Value.THREE),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.THREE),
],
{"trips": Value.TWO, "pair": Value.THREE},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.TWO),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{},
),
],
ids=[
"full-house",
"not-full-house",
],
)
def test_is_full_house(cards: list[Card], expected: dict[str, Union[str, int]]) -> None:
result = full_house(Hand(cards=cards))
assert result == expected
@pytest.mark.parametrize(
"values,different_suits,expected",
[
([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], True, {}),
(
[Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN],
False,
{"suit": Suit.CLUBS},
),
([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, {}),
],
ids=[
"straight-only",
"flush-only",
"straight-flush",
],
)
def test_is_flush(
card_factory: Factory[Card],
values: list[Value],
different_suits: bool,
expected: dict[str, Union[str, int]],
) -> None:
suits = cycle(Suit)
if different_suits:
result = flush(
Hand(
cards=[
card_factory(suit=suit, value=value)
for suit, value in zip(suits, values)
]
)
)
else:
result = flush(Hand(cards=[card_factory(value=value) for value in values]))
assert result == expected
@pytest.mark.parametrize(
"values,different_suits,expected",
[
(
[Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX],
True,
{"high": Value.SIX},
),
([Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN], False, {}),
([Value.TWO, Value.THREE, Value.FOUR, Value.FIVE, Value.SIX], False, {}),
],
ids=[
"straight-only",
"flush-only",
"straight-flush",
],
)
def test_is_straight(
card_factory: Factory[Card],
values: list[Value],
different_suits: bool,
expected: dict[str, Union[str, int]],
) -> None:
suits = cycle(Suit)
if different_suits:
result = straight(
Hand(
cards=[
card_factory(suit=suit, value=value)
for suit, value in zip(suits, values)
]
)
)
else:
result = straight(Hand(cards=[card_factory(value=value) for value in values]))
assert result == expected
@pytest.mark.parametrize(
"cards,expected",
[
(
[
Card(suit=Suit.CLUBS, value=Value.THREE),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.THREE),
],
{},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.ACE),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{"value": Value.TWO},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TEN),
Card(suit=Suit.SPADES, value=Value.ACE),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{},
),
],
ids=["full-house", "three-of-a-kind", "not-three-of-a-kind"],
)
def test_is_three_of_a_kind(
cards: list[Card], expected: dict[str, Union[str, int]]
) -> None:
result = three_of_a_kind(Hand(cards=cards))
assert result == expected
@pytest.mark.parametrize(
"cards,expected",
[
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.THREE),
Card(suit=Suit.DIAMONDS, value=Value.THREE),
],
{},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.ACE),
Card(suit=Suit.SPADES, value=Value.ACE),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{"high": Value.ACE, "low": Value.TWO},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.ACE),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{},
),
],
ids=["full-house", "two-pair", "three-of-a-kind"],
)
def test_is_two_pair(cards: list[Card], expected: dict[str, Union[str, int]]) -> None:
result = two_pair(Hand(cards=cards))
assert result == expected
@pytest.mark.parametrize(
"cards,expected",
[
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.TWO),
Card(suit=Suit.SPADES, value=Value.THREE),
Card(suit=Suit.DIAMONDS, value=Value.THREE),
],
{},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.ACE),
Card(suit=Suit.SPADES, value=Value.ACE),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{},
),
(
[
Card(suit=Suit.CLUBS, value=Value.TWO),
Card(suit=Suit.DIAMONDS, value=Value.TWO),
Card(suit=Suit.HEARTS, value=Value.KING),
Card(suit=Suit.SPADES, value=Value.ACE),
Card(suit=Suit.CLUBS, value=Value.THREE),
],
{"value": Value.TWO},
),
],
ids=["full-house", "two-pair", "pair"],
)
def test_is_pair(cards: list[Card], expected: dict[str, Union[str, int]]) -> None:
result = pair(Hand(cards=cards))
assert result == expected
def test_high_card(card_factory: Factory[Card]) -> None:
values = [Value.TWO, Value.THREE, Value.FIVE, Value.SIX, Value.TEN]
result = high_card(Hand(cards=[card_factory(value=value) for value in values]))
assert result == {"value": Value.TEN}