aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Harrison <paul@harrison.sh>2022-11-20 15:13:16 +0000
committerPaul Harrison <paul@harrison.sh>2022-12-15 16:02:14 +0000
commit9baafc80ed889c232587cf5d4cfaa2db44d1825a (patch)
tree13371c1220a8eabbc0932d30cc9475b98b984c3b
parentb4ba284dcc20563b9b7dde770b7c9f67c6b25e76 (diff)
feat: Hand ranking API
Hand ranking API with a health check root endpoint and rank endpoint.
-rw-r--r--Makefile4
-rw-r--r--README.md15
-rw-r--r--poetry.lock232
-rw-r--r--poker/api.py56
-rw-r--r--poker/constants.py7
-rw-r--r--poker/rank/hands.py4
-rw-r--r--pyproject.toml6
-rw-r--r--tests/test_api.py67
8 files changed, 383 insertions, 8 deletions
diff --git a/Makefile b/Makefile
index 5d0b70c..82bf93e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,10 @@
.DEFAULT_GOAL := help
SHELL := /bin/bash
+.PHONY: api
+api: ## Run API
+ @poetry run uvicorn poker.api:app
+
.PHONY: black
black: ## Run black formatter
@poetry run black poker tests;
diff --git a/README.md b/README.md
index 4b8d985..d6cc434 100644
--- a/README.md
+++ b/README.md
@@ -88,3 +88,18 @@ Result: "full house: 4 over 2"
Query: "6H 7H 8H 9H 10H"
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`.
+- Run API with `make API`.
+- 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
+ ```
diff --git a/poetry.lock b/poetry.lock
index 3e3ef63..f64edbe 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,21 @@
[[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]]
name = "attrs"
version = "22.1.0"
description = "Classes Without Boilerplate"
@@ -33,10 +50,18 @@ jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
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]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
-category = "dev"
+category = "main"
optional = false
python-versions = ">=3.7"
@@ -47,7 +72,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
-category = "dev"
+category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
@@ -71,6 +96,24 @@ optional = false
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]]
name = "flake8"
version = "5.0.4"
description = "the modular source code checker: pep8 pyflakes and co"
@@ -135,6 +178,60 @@ dev = ["coverage (>=4.0.0,<5.0.0)", "flake8-builtins", "flake8-commas", "flake8-
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]]
name = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
@@ -157,6 +254,21 @@ plugins = ["setuptools"]
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]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
@@ -308,6 +420,28 @@ pluggy = ">=0.12,<2.0"
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]]
name = "snowballstemmer"
version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
@@ -316,6 +450,20 @@ optional = false
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]]
name = "typing-extensions"
version = "4.4.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
@@ -323,12 +471,42 @@ category = "main"
optional = false
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]
lock-version = "1.1"
python-versions = ">=3.11,<3.12"
-content-hash = "78fbc979be8f154829b70aebcbd4016a447d6415206c52aaf7c2505c3160b23f"
+content-hash = "13bef4122cae8394c862b2497c696d777b88604803563af278fbbfdc0d7c3b78"
[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 = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
@@ -356,6 +534,10 @@ black = [
{file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"},
{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 = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
@@ -420,6 +602,10 @@ eradicate = [
{file = "eradicate-2.1.0-py3-none-any.whl", hash = "sha256:8bfaca181db9227dc88bdbce4d051a9627604c2243e7d85324f6d6ce0fd08bb2"},
{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 = [
{file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"},
{file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"},
@@ -438,6 +624,22 @@ flake8-eradicate = [
flake8-use-fstring = [
{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 = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
@@ -446,6 +648,10 @@ isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{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 = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
@@ -560,11 +766,31 @@ pytest = [
{file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{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 = [
{file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
{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 = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{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"},
+]
diff --git a/poker/api.py b/poker/api.py
new file mode 100644
index 0000000..9faa89b
--- /dev/null
+++ b/poker/api.py
@@ -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
diff --git a/poker/constants.py b/poker/constants.py
index e0a7348..6402ef7 100644
--- a/poker/constants.py
+++ b/poker/constants.py
@@ -17,6 +17,11 @@ class Rank(IntEnum):
PAIR = 9
HIGH_CARD = 10
+ def __str__(self) -> str:
+ """Return string representation."""
+ out: str = self.name.lower().replace("_", " ").title()
+ return out
+
class Suit(AutoName):
"""Card suit enum."""
@@ -46,7 +51,7 @@ class Value(IntEnum):
def __str__(self) -> str:
"""Return string representation."""
- if self.value in [1, 11, 12, 13]:
+ if self.value in [1, 11, 12, 13, 14]:
out: str = self.name.lower()
else:
out = str(self.value)
diff --git a/poker/rank/hands.py b/poker/rank/hands.py
index 6a81af5..a622f87 100644
--- a/poker/rank/hands.py
+++ b/poker/rank/hands.py
@@ -162,7 +162,7 @@ def rank_hand(hand: Hand) -> RankedHand:
for rank, func in _FUNCTIONS.items():
result = func(hand)
if result:
- out = RankedHand(
+ return RankedHand(
cards=hand.cards,
rank=rank,
description=DESCRIPTIONS[rank].format(**result),
@@ -170,5 +170,3 @@ def rank_hand(hand: Hand) -> RankedHand:
if out is None:
raise ValueError("No rank found.")
-
- return out
diff --git a/pyproject.toml b/pyproject.toml
index 036f515..1df9ae7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,17 +1,21 @@
[tool.poetry]
name = "poker"
-version = "1.1.0"
+version = "2.0.0"
description = "Single poker hand ranking service."
authors = ["Paul Harrison"]
readme = "README.md"
[tool.poetry.dependencies]
python = ">=3.11,<3.12"
+fastapi = "^0.87.0"
+loguru = "^0.6.0"
pydantic = "^1.10.2"
+uvicorn = "^0.20.0"
[tool.poetry.group.test.dependencies]
coverage = "^6.5.0"
pytest = "^7.2.0"
+httpx = "^0.23.1"
[tool.poetry.group.lint.dependencies]
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..8d9768f
--- /dev/null
+++ b/tests/test_api.py
@@ -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