diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..caf6143 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,104 @@ +name: Build and Test + +on: + pull_request: + branches: [pipfile-experiment] + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + + - name: Install Python, Pipenv and Pipfile packages + uses: kojoru/prepare-pipenv@v1 + with: + python-version: ${{ matrix.python-version }} + + #installing requirements.txt + - name: Install project (editable) + test/build tools + run: | + if [ -f requirements.txt ]; then pipenv run pip install -r requirements.txt; fi + pipenv install -e . + pipenv install pytest build twine + + - name: Test with pytest + run: | + pipenv install pytest + pipenv --venv + pipenv run python -m pytest + + - name: Build package + run: | + rm -rf dist build src/*.egg-info + pipenv run python -m build . + + - name: Twine check (README/metadata) + run: pipenv run twine check dist/* + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.python-version }} + path: dist/* + + + deliver: + name: Publish + if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v') + needs: [build] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist-3.11 + path: dist + + - name: Install Python, pipenv and Pipfile packages + uses: kojoru/prepare-pipenv@v1 + + - name: Build package + run: | + pipenv install build + pipenv run python -m build . + + - name: Publish to PyPI test server + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + + - name: Publish to PyPI (production) + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + + #ADDED to verify upload + verify-upload: + name: Verify install from registry + if: github.event_name != 'pull_request' + needs: [deliver] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify Install + run: | + python -m pip install --upgrade pip + pip install eatnyc + python - <<'PY' + import eatnyc + print("✅ Imported eatnyc OK. Version:", getattr(eatnyc, "__version__", "unknown")) + PY \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2f24a10..3c1268a 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,10 @@ dmypy.json # Cython debug symbols cython_debug/ +dist/ +build/ +*.egg-info/ + +dist/ +build/ +*.egg-info/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..620044d --- /dev/null +++ b/Makefile @@ -0,0 +1,132 @@ +# ======================= +# Project Settings +# ======================= +PACKAGE := eatnyc + +# Read version from pyproject.toml (safe, single-line Python) +VERSION := $(shell python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + +WHEEL := dist/$(PACKAGE)-$(VERSION)-py3-none-any.whl + +# ======================= +# Phony Targets +# ======================= +.PHONY: help show-version clean build uninstall install-wheel reinstall \ + test test-smoke verify install-testpypi \ + dev-on dev-off \ + test-unit \ + release-test release \ + tag push-tag + +# ======================= +# Help +# ======================= +help: + @echo "Make targets:" + @echo " show-version - Print detected version from pyproject.toml" + @echo " clean - Remove build artifacts" + @echo " build - Build wheel/sdist" + @echo " uninstall - Uninstall $(PACKAGE) from current Pipenv env" + @echo " install-wheel - Install local wheel" + @echo " reinstall - Clean build + install local wheel" + @echo " test - Smoke test (import and show path)" + @echo " test-smoke - Alias of 'test'" + @echo " verify - Show import path and installed version" + @echo " test-unit - Run pytest (use in dev-on/editable mode)" + @echo " install-testpypi - Install $(PACKAGE)==$(VERSION) from TestPyPI" + @echo " dev-on - Editable install (-e .)" + @echo " dev-off - Switch to TestPyPI-installed package" + @echo " release-test - Upload dist/* to TestPyPI via twine" + @echo " release - Upload dist/* to PyPI via twine" + @echo " tag - Create git tag v$(VERSION)" + @echo " push-tag - Push tag v$(VERSION) to origin" + +# ======================= +# Info +# ======================= +show-version: + @echo "Detected version: $(VERSION)" + +# ======================= +# Build & Install (local wheel testing) +# ======================= +clean: + @echo "Cleaning build artifacts..." + rm -rf dist build src/*.egg-info + +build: clean + @echo "Building distribution..." + pipenv run python -m build + +install-wheel: + @echo "Installing wheel (no Pipfile change): $(WHEEL)" + pipenv run pip install "$(WHEEL)" + +uninstall: + @echo "Uninstalling $(PACKAGE)..." + -pipenv run pip uninstall -y $(PACKAGE) + +reinstall: uninstall build install-wheel + @echo "Reinstalled local wheel for $(PACKAGE) $(VERSION)." + +# ======================= +# Quick tests (smoke) +# ======================= +test: + @echo "Quick import test..." + pipenv run python -c "import $(PACKAGE), sys; print('OK:', $(PACKAGE).__file__)" + +test-smoke: test + +verify: + @echo "Verify package path & version..." + pipenv run python -c "import importlib.metadata as m, $(PACKAGE); print('Path :', $(PACKAGE).__file__); print('Version:', m.version('$(PACKAGE)'))" + +# ======================= +# Unit tests (pytest) — run in dev-on/editable mode +# ======================= +test-unit: + @echo "Running pytest..." + pipenv run pytest -q + +# ======================= +# TestPyPI Install Testing +# ======================= +install-testpypi: + @echo "Installing $(PACKAGE)==$(VERSION) from TestPyPI..." + PIP_INDEX_URL=https://test.pypi.org/simple \ + PIP_EXTRA_INDEX_URL=https://pypi.org/simple \ + pipenv run pip install $(PACKAGE)==$(VERSION) + +# ======================= +# Developer Mode Toggle (editable) +# ======================= +dev-on: uninstall + @echo "Switching to developer editable mode..." + pipenv install -e . + @echo "✅ Developer mode ON (editable)." + +dev-off: uninstall install-testpypi + @echo "✅ Developer mode OFF (TestPyPI package installed)." + +# ======================= +# Release (local twine) — requires ~/.pypirc or env TWINE_USERNAME/TWINE_PASSWORD +# ======================= +release-test: build + @echo "Uploading to TestPyPI via twine..." + pipenv run twine upload -r testpypi dist/* + +release: build + @echo "Uploading to PyPI via twine..." + pipenv run twine upload dist/* + +# ======================= +# Git tags (to trigger CI release workflows) +# ======================= +tag: + @git tag v$(VERSION) + @echo "Created tag v$(VERSION)." + +push-tag: + @git push origin v$(VERSION) + @echo "Pushed tag v$(VERSION) to origin." diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..ddecb93 --- /dev/null +++ b/Pipfile @@ -0,0 +1,17 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +pytest = "*" +build = "*" +twine = "*" +eatnyc = {file = ".", editable = true} + +[dev-packages] +build = "*" +twine = "*" +pytest = "*" +exceptiongroup = "*" +eatnyc = {file = ".", editable = true} diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..97f8efa --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,864 @@ +{ + "_meta": { + "hash": { + "sha256": "35ae6d1e238ec73daefd18987bd5f432accca14788aa0767d33c764802e0ddef" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "backports.tarfile": { + "hashes": [ + "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", + "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991" + ], + "markers": "python_version >= '3.8'", + "version": "==1.2.0" + }, + "build": { + "hashes": [ + "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", + "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.3.0" + }, + "certifi": { + "hashes": [ + "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", + "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.10.5" + }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, + "docutils": { + "hashes": [ + "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", + "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8" + ], + "markers": "python_version >= '3.9'", + "version": "==0.22.2" + }, + "eatnyc": { + "editable": true, + "file": "." + }, + "exceptiongroup": { + "hashes": [ + "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", + "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.0" + }, + "id": { + "hashes": [ + "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", + "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, + "idna": { + "hashes": [ + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + ], + "markers": "python_version >= '3.8'", + "version": "==3.11" + }, + "importlib-metadata": { + "hashes": [ + "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", + "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd" + ], + "markers": "python_version >= '3.9'", + "version": "==8.7.0" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "jaraco.classes": { + "hashes": [ + "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + ], + "markers": "python_version >= '3.8'", + "version": "==3.4.0" + }, + "jaraco.context": { + "hashes": [ + "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" + ], + "markers": "python_version >= '3.8'", + "version": "==6.0.1" + }, + "jaraco.functools": { + "hashes": [ + "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", + "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294" + ], + "markers": "python_version >= '3.9'", + "version": "==4.3.0" + }, + "keyring": { + "hashes": [ + "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", + "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" + ], + "markers": "python_version >= '3.9'", + "version": "==25.6.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", + "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.0.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "more-itertools": { + "hashes": [ + "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", + "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd" + ], + "markers": "python_version >= '3.9'", + "version": "==10.8.0" + }, + "nh3": { + "hashes": [ + "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", + "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", + "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", + "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", + "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", + "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", + "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", + "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", + "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", + "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", + "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", + "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", + "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", + "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", + "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", + "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", + "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", + "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", + "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", + "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", + "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", + "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", + "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", + "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", + "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", + "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a" + ], + "markers": "python_version >= '3.8'", + "version": "==0.3.2" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", + "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pytest": { + "hashes": [ + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + }, + "readme-renderer": { + "hashes": [ + "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", + "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1" + ], + "markers": "python_version >= '3.9'", + "version": "==44.0" + }, + "requests": { + "hashes": [ + "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", + "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" + ], + "markers": "python_version >= '3.9'", + "version": "==2.32.5" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.0" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "rich": { + "hashes": [ + "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", + "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==14.2.0" + }, + "tomli": { + "hashes": [ + "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", + "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", + "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", + "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", + "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", + "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", + "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", + "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", + "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", + "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", + "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", + "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", + "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", + "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", + "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", + "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", + "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", + "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", + "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", + "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", + "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", + "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", + "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", + "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", + "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", + "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", + "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", + "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", + "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", + "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", + "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", + "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", + "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", + "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", + "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", + "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", + "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", + "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", + "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", + "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", + "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", + "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876" + ], + "markers": "python_version >= '3.8'", + "version": "==2.3.0" + }, + "twine": { + "hashes": [ + "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", + "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==6.2.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + }, + "zipp": { + "hashes": [ + "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", + "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" + ], + "markers": "python_version >= '3.9'", + "version": "==3.23.0" + } + }, + "develop": { + "backports.tarfile": { + "hashes": [ + "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", + "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991" + ], + "markers": "python_version >= '3.8'", + "version": "==1.2.0" + }, + "build": { + "hashes": [ + "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", + "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.3.0" + }, + "certifi": { + "hashes": [ + "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", + "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.10.5" + }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, + "docutils": { + "hashes": [ + "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", + "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8" + ], + "markers": "python_version >= '3.9'", + "version": "==0.22.2" + }, + "eatnyc": { + "editable": true, + "markers": "python_version >= '3.10'" + }, + "exceptiongroup": { + "hashes": [ + "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", + "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.0" + }, + "id": { + "hashes": [ + "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", + "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, + "idna": { + "hashes": [ + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + ], + "markers": "python_version >= '3.8'", + "version": "==3.11" + }, + "importlib-metadata": { + "hashes": [ + "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", + "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd" + ], + "markers": "python_version >= '3.9'", + "version": "==8.7.0" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "jaraco.classes": { + "hashes": [ + "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" + ], + "markers": "python_version >= '3.8'", + "version": "==3.4.0" + }, + "jaraco.context": { + "hashes": [ + "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" + ], + "markers": "python_version >= '3.8'", + "version": "==6.0.1" + }, + "jaraco.functools": { + "hashes": [ + "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", + "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294" + ], + "markers": "python_version >= '3.9'", + "version": "==4.3.0" + }, + "keyring": { + "hashes": [ + "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", + "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" + ], + "markers": "python_version >= '3.9'", + "version": "==25.6.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", + "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.0.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "more-itertools": { + "hashes": [ + "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", + "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd" + ], + "markers": "python_version >= '3.9'", + "version": "==10.8.0" + }, + "nh3": { + "hashes": [ + "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", + "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", + "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", + "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", + "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", + "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", + "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", + "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", + "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", + "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", + "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", + "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", + "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", + "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", + "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", + "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", + "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", + "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", + "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", + "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", + "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", + "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", + "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", + "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", + "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", + "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a" + ], + "markers": "python_version >= '3.8'", + "version": "==0.3.2" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", + "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pytest": { + "hashes": [ + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + }, + "readme-renderer": { + "hashes": [ + "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", + "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1" + ], + "markers": "python_version >= '3.9'", + "version": "==44.0" + }, + "requests": { + "hashes": [ + "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", + "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" + ], + "markers": "python_version >= '3.9'", + "version": "==2.32.5" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.0" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "rich": { + "hashes": [ + "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", + "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==14.2.0" + }, + "twine": { + "hashes": [ + "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", + "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==6.2.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + }, + "zipp": { + "hashes": [ + "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", + "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" + ], + "markers": "python_version >= '3.9'", + "version": "==3.23.0" + } + } +} diff --git a/README.md b/README.md index 6022e0e..f566830 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,276 @@ -# Python Package Exercise +# eatnyc - NYC Restaurant Recommender +![Build and Test](https://github.com/swe-students-fall2025/3-python-package-team_avalon/actions/workflows/build.yaml/badge.svg) -An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. +**eatnyc** is a lightweight Python package that recommends top-rated NYC restaurants based on cuisine, neighborhood, price, and rating. +It’s designed to help users explore the city’s dining scene and discover great places through data-driven recommendations — directly from the command line or in Python. + +--- + +## How to install and use this package +1. **Create and Activate a virtual environment** +```bash +pipenv --python 3.11 +pipenv shell +``` +2. ### Option 1: Install from PyPI (for users) +```bash +pip install eatnyc +``` + +Or install a specific version: +```bash +pip install eatnyc==0.1.2 +``` +### Install locally (for developers) +```bash +pipenv install -e . +``` +If that set up fails for you, use: +```bash +python3 -m pipenv install -e . +``` + +### Option 2: Try it from **TestPyPI** (current test version) +You can try out the latest build of eatnyc from the [TestPyPI](https://test.pypi.org/project/eatnyc/) repository. +**Install from TestPyPI** +Replace 0.1.2 with your latest version number (see pyproject.toml) +```bash +pipenv install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple eatnyc==0.1.2 +``` +For now: The --extra-index-url flag ensures dependencies are installed from the real PyPI, while your package is pulled from TestPyPI + +3. **Run the package** +You can use eatnyc either as a CLI app or a Python module. + +#### Command line: +```bash +eatnyc -n 5 --sort rating +``` +#### Python module: +```bash +python -m eatnyc +``` +--- +### Example Program +```python +from eatnyc import load_data, top_n, filter_restaurants, sample_dish, format_card + + +def main(): + # === Load Data === + data = load_data() + print("rows:", len(data)) + + # === Top restaurants by rating === + print("\n=== Top 5 by rating (simple print) ===") + for r in top_n(data, n=5): + print(f"- {r['name']} ({r['cuisine']}, {r['price']}) ★ {r['rating']} – {r['sample_dish']}") + + # === Top restaurants by rating with format_card === + print("\n=== Top 5 by rating (format_card output) ===") + for r in top_n(data, n=5): + print(format_card(r, width=60)) + + # === Filtering example === + print("\n=== Korean restaurants in Koreatown with $$ and rating >= 4.5 (simple print) ===") + filtered = filter_restaurants( + data, + cuisine="Korean", + neighborhood="Koreatown", + price="$$", + min_rating=4.5, + ) + for r in filtered: + print(f"- {r['name']} ({r['cuisine']}, {r['price']}) ★ {r['rating']} – {r['sample_dish']}") + + # === Same filter + card display === + print("\n=== Same filtered results (format_card output) ===") + for r in filtered: + print(format_card(r, width=60)) + + # === Sample dish recommendations === + print("\n=== Sample dish: Random Italian restaurant ===") + italian = sample_dish(cuisine="Italian", seed=42) + if isinstance(italian, dict) and "error" not in italian: + print(f"Try: {italian['sample_dish']} at {italian['name']}") + print(format_card(italian, width=60)) + else: + print(f"{italian['error']}") + print(f"{italian['message']} {', '.join(italian['suggestions'])}") + + print("\n=== Sample dish: Random restaurant (any cuisine) ===") + random_restaurant = sample_dish(seed=123) + if random_restaurant: + print(f"Try: {random_restaurant['sample_dish']} at {random_restaurant['name']}") + print(format_card(random_restaurant, width=60)) + + print("\n=== Sample dish: Invalid cuisine (should show suggestions) ===") + invalid = sample_dish(cuisine="Martian") + if isinstance(invalid, dict) and "error" in invalid: + print(f"{invalid['error']}") + print(f"{invalid['message']} {', '.join(invalid['suggestions'][:3])}") + + +if __name__ == "__main__": + main() +``` +Run the example: +```bash +pipenv run python examples/demo.py +``` + +## Function Documentation + +### `load_data(path=None, validate=True)` +Loads NYC restaurant data from CSV file. + +**Parameters:** +- `path` (str, optional): Path to CSV file. If `None`, loads bundled data file. +- `validate` (bool, default=True): Whether to validate required columns. + +**Returns:** +- `list[dict]`: List of restaurant dictionaries with normalized fields (lowercase keys, float ratings). + +**Example:** +```python +data = load_data() # Load bundled data +data = load_data("custom.csv") # Load custom file +``` + +### `filter_restaurants(data, cuisine=None, neighborhood=None, price=None, min_rating=None, limit=None)` +Filters restaurants based on multiple criteria. + +**Parameters:** +- `data` (list): List of restaurant dictionaries (from `load_data()`). +- `cuisine` (str, optional): Filter by cuisine type. +- `neighborhood` (str, optional): Filter by neighborhood. +- `price` (str, optional): Filter by price range (e.g., "$", "$$", "$$$"). +- `min_rating` (float, optional): Minimum rating threshold. +- `limit` (int, optional): Maximum number of results to return. + +**Returns:** +- `list[dict]`: Filtered list of restaurant dictionaries. + +**Example:** +```python +italian = filter_restaurants(data, cuisine="Italian", min_rating=4.5, limit=10) +``` + +### `top_n(data, n=5, sort_by="rating", descending=True)` +Returns top N restaurants sorted by a specified field. + +**Parameters:** +- `data` (list): List of restaurant dictionaries. +- `n` (int, default=5): Number of results to return. Use `None` for all results. +- `sort_by` (str, default="rating"): Field to sort by (e.g., "rating", "name", "price"). +- `descending` (bool, default=True): Whether to sort in descending order. + +**Returns:** +- `list[dict]`: Top N restaurants sorted by the specified field. + +**Example:** +```python +top_5 = top_n(data, n=5, sort_by="rating", descending=True) +top_10_by_price = top_n(data, n=10, sort_by="price", descending=False) +``` + +### `sample_dish(cuisine=None, seed=None)` +Returns a random restaurant with a sample dish recommendation. + +**Parameters:** +- `cuisine` (str, optional): Filter by cuisine type. If `None`, returns any restaurant. +- `seed` (int, optional): Random seed for reproducible results. + +**Returns:** +- `dict` or `None`: Restaurant dictionary with sample dish, or `None` if no matches. +- If cuisine not found, returns dict with `error`, `suggestions`, and `message` keys. + +**Example:** +```python +random_dish = sample_dish() # Any restaurant +italian_dish = sample_dish(cuisine="Italian", seed=42) # Reproducible +``` + +### `format_card(row, style="ascii", width=60, show_dish=True)` +Formats a restaurant dictionary as a display card. + +**Parameters:** +- `row` (dict): Restaurant dictionary (from `load_data()` or filter functions). +- `style` (str, default="ascii"): Display style - "ascii" for box format, "markdown" for markdown. +- `width` (int, default=60): Card width in characters (minimum 24). +- `show_dish` (bool, default=True): Whether to include sample dish in output. + +**Returns:** +- `str`: Formatted card string. + +**Example:** +```python +card = format_card(restaurant, style="ascii", width=48) +print(card) +``` + +**See the complete example program:** [examples/demo.py](examples/demo.py) - demonstrates all functions working together. + +## How to Run Unit Tests +Simple unit tests are included in the 'tests' directory. To run them: +1. **Install 'pytest' inside your virtual environment:** +```bash +pipenv install pytest +``` +2. **Run the tests from project root:** +```bash +pipenv run pytest -q +``` +3. All tests should pass. Any failed test indicates that the package code is behaving differently from the expected results. + +# Developer Workflow (Building & Publishing) +If you modify the code and want to publish a new version to TestPyPI, follow these steps: +```bash +#1. CLEAN old build artifacts +rm -rf dist build src/*.egg-info +pipenv install build twine + +#2. BUMP version number in pyproject.toml (e.g., 0.1.0 → 0.1.1) + +#3. BUILD the package +pipenv run python -m build +pipenv run twine check dist/* + +#4. PUBLISH to PyPI +pipenv run twine upload dist/* + +#(Optional) UPLOAD new version to TestPyPI +pipenv run twine upload -r testpypi dist/* +``` +5. REINSTALL to test it: +```bash +pipenv run pip uninstall -y eatnyc +pipenv install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple eatnyc==0.1.2 +``` +6. Verify installation with: +```bash +pip install eatnyc +python -c "import eatnyc; print(eatnyc.__version__)" +``` +## Developer Mode Switch (using Makefile) + +```bash +make dev-on # install editable +make dev-off # restore TestPyPI version +make verify # confirm path +``` + +--- +## Project Links +- **PyPI:** [https://pypi.org/project/eatnyc](https://pypi.org/project/eatnyc) +- **TestPyPI:** [https://test.pypi.org/project/eatnyc](https://test.pypi.org/project/eatnyc) +- **Github Repo:** [https://github.com/swe-students-fall2025/3-python-package-team_avalon.git](https://github.com/swe-students-fall2025/3-python-package-team_avalon.git) +- **Source:** [https://github.com/swe-students-fall2025/3-python-package-team_avalon](https://github.com/swe-students-fall2025/3-python-package-team_avalon) +- **Issues:** [https://github.com/swe-students-fall2025/3-python-package-team_avalon/issues](https://github.com/swe-students-fall2025/3-python-package-team_avalon/issues) + +# Contributors +- [amiraadum](https://github.com/amiraadum) +- [Ivan-Wang-tech](https://github.com/Ivan-Wang-tech) +- [hyunkyuu](https://github.com/hyunkyuu) +- [jmo7728](https://github.com/jmo7728) +- [lilyluo7412](https://github.com/lilyluo7412) diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 0000000..116f480 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,60 @@ +from eatnyc import load_data, top_n, filter_restaurants, sample_dish, format_card + + +def main(): + # === Load Data === + data = load_data() + print("rows:", len(data)) + + # === Top restaurants by rating === + print("\n=== Top 5 by rating (simple print) ===") + for r in top_n(data, n=5): + print(f"- {r['name']} ({r['cuisine']}, {r['price']}) ★ {r['rating']} – {r['sample_dish']}") + + # === Top restaurants by rating with format_card === + print("\n=== Top 5 by rating (format_card output) ===") + for r in top_n(data, n=5): + print(format_card(r, width=60)) + + # === Filtering example === + print("\n=== Korean restaurants in Koreatown with $$ and rating >= 4.5 (simple print) ===") + filtered = filter_restaurants( + data, + cuisine="Korean", + neighborhood="Koreatown", + price="$$", + min_rating=4.5, + ) + for r in filtered: + print(f"- {r['name']} ({r['cuisine']}, {r['price']}) ★ {r['rating']} – {r['sample_dish']}") + + # === Same filter + card display === + print("\n=== Same filtered results (format_card output) ===") + for r in filtered: + print(format_card(r, width=60)) + + # === Sample dish recommendations === + print("\n=== Sample dish: Random Italian restaurant ===") + italian = sample_dish(cuisine="Italian", seed=42) + if isinstance(italian, dict) and "error" not in italian: + print(f"Try: {italian['sample_dish']} at {italian['name']}") + print(format_card(italian, width=60)) + else: + print(f"{italian['error']}") + print(f"{italian['message']} {', '.join(italian['suggestions'])}") + + print("\n=== Sample dish: Random restaurant (any cuisine) ===") + random_restaurant = sample_dish(seed=123) + if random_restaurant: + print(f"Try: {random_restaurant['sample_dish']} at {random_restaurant['name']}") + print(format_card(random_restaurant, width=60)) + + print("\n=== Sample dish: Invalid cuisine (should show suggestions) ===") + invalid = sample_dish(cuisine="Martian") + if isinstance(invalid, dict) and "error" in invalid: + print(f"{invalid['error']}") + print(f"{invalid['message']} {', '.join(invalid['suggestions'][:3])}") + + +if __name__ == "__main__": + main() diff --git a/examples/test_eatnyc.py b/examples/test_eatnyc.py new file mode 100644 index 0000000..41c84cd --- /dev/null +++ b/examples/test_eatnyc.py @@ -0,0 +1,88 @@ +from importlib.metadata import version as pkg_version +from collections import Counter +from pprint import pprint + +from eatnyc import load_data, filter_restaurants, top_n, sample_dish, format_card + +def print_header(title: str): + print("\n" + "=" * 72) + print(title) + print("=" * 72) + +print_header("Version") +print("eatnyc version:", pkg_version("eatnyc")) + +# 1) Load data (list[dict]) +print_header("Load Data") +data = load_data() +print("raw rows:", len(data)) +print("first 3 rows:") +pprint(data[:3]) + +if not data: + raise SystemExit("No data loaded. Check bundled CSV and package-data config.") + +# 1.1 Explore distinct field values to choose valid filters +print_header("Distinct values (to choose valid filters)") +def distinct(key): + vals = [ (row.get(key) or "").strip() for row in data ] + vals = [v for v in vals if v] + return [v for v,_ in Counter(vals).most_common()] + +cuisines = sorted({c for row in data for c in row.get("_cuisines", [])}) +neighborhoods = distinct("neighborhood") +prices = distinct("price") + +print("cuisines:", cuisines[:15], ("... total %d" % len(cuisines)) if len(cuisines)>15 else "") +print("neighborhoods (sample):", neighborhoods[:10]) +print("prices:", prices) + +# 2) Filter — use values that exist in your CSV +# Example that should match your sample data: +# cuisine: "american" exists (4 Charles Prime Rib) +# neighborhood: "West Village" exists +# price: "$$$$" exists +print_header("Filter Restaurants (American @ West Village, $$$$, rating>=4.0, limit=10)") +filtered = filter_restaurants( + data, + cuisine="American", # case-insensitive via your normalizer + neighborhood="West Village", # must match CSV spelling + price="$$$$", + min_rating=4.0, + limit=10, +) +print("filtered rows:", len(filtered)) +pprint(filtered[:3]) + +# 3) Top-N from filtered +print_header("Top-N (rating desc) from filtered") +top = top_n(filtered, n=5, sort_by="rating", descending=True) +print(f"top size: {len(top)}") +for i, r in enumerate(top, 1): + print(f"[{i}] {r.get('name')} | {r.get('cuisine')} | {r.get('neighborhood')} | {r.get('price')} | ★{r.get('rating')}") + +# 4) Render a card for the first item (ASCII + Markdown) +if top: + first = top[0] + print_header("format_card (ASCII)") + print(format_card(first, style="ascii", width=48, show_dish=True)) + + print_header("format_card (Markdown)") + print(format_card(first, style="markdown", show_dish=True)) +else: + print_header("No results to format (top is empty)") + +# 5) Sample dish demo — robust handling of None / error dict / row dict +print_header("sample_dish (try an existing cuisine, e.g. 'american' or 'french')") +sd = sample_dish(cuisine="American", seed=42) + +if not sd: + print("No sample_dish available in dataset.") +elif isinstance(sd, dict) and "error" in sd: + print("No dish for selected cuisine.") + print("error:", sd["error"]) + print("suggestions:", sd.get("suggestions")) +else: + print("Random pick with dish:") + print(f"{sd.get('name')} — {sd.get('sample_dish')} " + f"(cuisine: {sd.get('cuisine')}, rating: {sd.get('rating')})") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..47d87cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "eatnyc" +version = "0.1.2" +description = "A tiny NYC restaurant recommender near NYU." +readme = { file = "README.md", content-type = "text/markdown" } # explicit +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Team Avalon" }] +keywords = ["nyc", "restaurants", "fun", "food", "recommendations"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Utilities" +] + +[project.urls] +Homepage = "https://github.com/swe-students-fall2025/3-python-package-team_avalon" +Source = "https://github.com/swe-students-fall2025/3-python-package-team_avalon" +Issues = "https://github.com/swe-students-fall2025/3-python-package-team_avalon/issues" + +[tool.setuptools] +package-dir = {"" = "src"} +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +eatnyc = ["data/*.csv"] + +[project.scripts] +eatnyc = "eatnyc.core:cli" \ No newline at end of file diff --git a/src/eatnyc/__init__.py b/src/eatnyc/__init__.py new file mode 100644 index 0000000..4975421 --- /dev/null +++ b/src/eatnyc/__init__.py @@ -0,0 +1,10 @@ +from importlib.metadata import version, PackageNotFoundError +from .core import load_data, filter_restaurants, top_n, sample_dish, format_card + +__all__ = ["load_data", "filter_restaurants", "top_n", "sample_dish", "format_card"] + +# Try to get the installed version; fallback for dev/local runs +try: + __version__ = version("eatnyc") +except PackageNotFoundError: + __version__ = "0.0.0" diff --git a/src/eatnyc/__main__.py b/src/eatnyc/__main__.py new file mode 100644 index 0000000..e1aa4af --- /dev/null +++ b/src/eatnyc/__main__.py @@ -0,0 +1,4 @@ +from .core import cli + +if __name__ == "__main__": + cli() diff --git a/src/eatnyc/core.py b/src/eatnyc/core.py new file mode 100644 index 0000000..572676a --- /dev/null +++ b/src/eatnyc/core.py @@ -0,0 +1,273 @@ +import csv +import random +from importlib.resources import files + +_DATA_PKG = "eatnyc" +_DEFAULT_CSV = "data/nyc_restaurant_data.csv" + +# columns we expect in the CSV +_REQUIRED_COLS = {"name", "cuisine", "neighborhood", "price", "rating", "sample_dish"} + + +# new function (NORMALIZE_ROWS) +def _normalize_row(row: dict) -> dict: + # Clean up one CSV row: strip spaces, normalize case/types, compute helper fields. + clean = {k.strip().lower(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()} + + # rating → float (default 0.0) + try: + clean["rating"] = float(clean.get("rating", "") or 0.0) + except ValueError: + clean["rating"] = 0.0 + + # cuisines list (split on / , ; | ) + raw_c = clean.get("cuisine", "") + parts = [] + for sep in ["/", ",", ";", "|"]: + raw_c = raw_c.replace(sep, " ") + parts = [p for p in (w.strip().lower() for w in raw_c.split()) if p] + clean["_cuisines"] = parts # helper list for filtering + + # price normalize (e.g., $, $$, $$$) + clean["price"] = (clean.get("price") or "").strip() + + return clean + + +# Load the NYC restaurant CSV into a list of dicts. +def load_data(path: str | None = None, validate: bool = True) -> list[dict]: + + # If `path` is None, loads the bundled file from eatnyc/data/. + # Keys are lowercased; rating is converted to float. + # Adds helper lists: `_cuisines`. + if path is None: + # use importlib.resources so this works from wheels/zip installs + resource = files(_DATA_PKG) / _DEFAULT_CSV + f = resource.open("r", encoding="utf-8", newline="") + else: + f = open(path, "r", encoding="utf-8", newline="") + + with f as fh: + reader = csv.DictReader(fh) + + if validate: + cols = {c.strip().lower() for c in (reader.fieldnames or [])} + missing = _REQUIRED_COLS - cols + if missing: + raise ValueError(f"CSV missing required columns: {sorted(missing)}") + + return [_normalize_row(r) for r in reader] + + +def filter_restaurants(data, cuisine=None, neighborhood=None, price=None, min_rating=None, limit=None): + + # Error checking for empty or invalid data + if not isinstance(data, list): + raise TypeError("Data must be a list of dicts") + + if not data: + return [] + + results = [] + + # Filtering logic + for row in data: + clean = _normalize_row(row) + #Check cuisine match + if cuisine: + if cuisine.strip().lower() not in clean.get("_cuisines", []): + continue + #Check neighborhood match + if neighborhood: + if neighborhood.strip().lower() != row.get("neighborhood", "").lower(): + continue + #Check price match + if price: + if price.strip() != clean.get("price", ""): + continue + if min_rating: + try: + min_rating_val = float(min_rating) + except ValueError: + raise ValueError("min_rating must be a number") + if clean.get("rating", 0.0) < min_rating_val: + continue + + results.append(row) + #if its over the limit, break + if limit: + if len(results) >= limit: + break + return results + + +def top_n(data, n=5, sort_by="rating", descending=True): + if not isinstance(data, list): + raise TypeError("data must be a list of dicts") + + if not data: + return [] + + # normalize sort key to lower-case to match load_data normalization + sort_key = (sort_by or "").strip().lower() + if not sort_key: + raise ValueError("sort_by must be a non-empty string") + + # validate the key exists in at least one row; otherwise error for clarity + if all((sort_key not in row) for row in data if isinstance(row, dict)): + raise KeyError(f"sort_by key not found in data rows: '{sort_key}'") + + def key_func(row): + # Missing keys sort as None -> treated as smallest when descending=False, largest when descending=True + value = row.get(sort_key) + # Ensure consistent comparison for mixed types + if isinstance(value, (int, float)): + return value + return ("" if value is None else str(value).lower()) + + try: + sorted_rows = sorted( + (r for r in data if isinstance(r, dict)), + key=key_func, + reverse=bool(descending), + ) + except TypeError: + # Fallback: convert all keys to string for sorting if mixed incomparable types + sorted_rows = sorted( + (r for r in data if isinstance(r, dict)), + key=lambda r: str(r.get(sort_key, "")), + reverse=bool(descending), + ) + + if n is None: + return sorted_rows + if not isinstance(n, int) or n < 0: + raise ValueError("n must be a non-negative integer or None") + return sorted_rows[:n] + + +def sample_dish(cuisine=None, seed=None): + ''' + Return a random restaurant with its sample dish recommendation. + ''' + if seed is not None: + random.seed(seed) + + # Load the restaurant data + data = load_data() + + # Filter by cuisine if provided + if cuisine: + cuisine_lower = cuisine.strip().lower() + filtered = [ + row for row in data + if cuisine_lower in row.get("_cuisines", []) + ] + + # Filter out entries without a sample_dish + with_dishes = [ + row for row in filtered + if row.get("sample_dish", "").strip() + ] + + if not with_dishes: + # Get all available cuisines + all_cuisines = set() + for row in data: + all_cuisines.update(row.get("_cuisines", [])) + + # Suggest some alternatives (random 3-5 cuisines) + suggestions = random.sample(sorted(all_cuisines), min(5, len(all_cuisines))) + + return { + "error": f"No restaurants found for cuisine '{cuisine}'", + "suggestions": suggestions, + "message": "Maybe try these instead?" + } + else: + # No cuisine specified, use all restaurants + with_dishes = [ + row for row in data + if row.get("sample_dish", "").strip() + ] + + if not with_dishes: + return None + + return random.choice(with_dishes) + + +def format_card(row, style="ascii", width=60, show_dish=True): + name = str(row.get("name", "") or "").strip() + cuisine = str(row.get("cuisine", "") or "").strip() + neighborhood = str(row.get("neighborhood", "") or "").strip() + price = str(row.get("price", "") or "").strip() + + try: + rating = float(row.get("rating", 0.0)) + except (TypeError, ValueError): + rating = 0.0 + + sample = str(row.get("sample_dish", "") or "").strip() + + title_line = name if name else "(unknown)" + sub_line = f"{cuisine}, {neighborhood}".strip(", ").strip() + meta_line = f"Rating: {rating:.1f} Price: {price}".strip() + dish_line = f"Dish: {sample}" if (show_dish and sample) else "" + + if style.lower() == "markdown": + body = f"**{title_line}** — *{sub_line}*\n{meta_line}" + if dish_line: + body += f"\n{dish_line}" + return body + + def _wrap_line(line: str, inner_width: int): + if not line: + return [""] + words = line.split() + out, cur = [], "" + for w in words: + if not cur: + cur = w + elif len(cur) + 1 + len(w) <= inner_width: + cur += " " + w + else: + out.append(cur) + cur = w + if cur: + out.append(cur) + return out + + box_width = max(24, int(width) if isinstance(width, int) else 48) + inner = box_width - 2 + + logical_lines = [f"{title_line} ({sub_line})" if sub_line else title_line, meta_line, dish_line] + + wrapped = [] + for ln in logical_lines: + wrapped.extend(_wrap_line(ln, inner) if ln else [""]) + + top = "+" + "-" * (box_width - 2) + "+" + content = "\n".join("|" + (ln.ljust(inner)) + "|" for ln in wrapped if ln is not None) + bottom = top + + return f"{top}\n{content}\n{bottom}" + + +def cli(argv=None): + import argparse + # Simple command-line entrypoint: + # $ eatnyc -> prints top 5 by rating + # $ eatnyc -n 10 -> prints top 10 + # $ eatnyc --sort name --asc -> sort by name ascending + + parser = argparse.ArgumentParser(prog="eatnyc", description="NYC restaurant recommender") + parser.add_argument("-n", "--n", type=int, default=5, help="number of results") + parser.add_argument("--sort", default="rating", help="field to sort by (rating, name, price, etc.)") + parser.add_argument("--asc", action="store_true", help="sort ascending (default is descending)") + args = parser.parse_args(argv) + + data = load_data() + results = top_n(data, n=args.n, sort_by=args.sort, descending=not args.asc) + for r in results: + print(f"{r['name']} | {r['cuisine']} | {r['price']} | ★{r['rating']} | {r.get('sample_dish', '')}") diff --git a/src/eatnyc/data/nyc_restaurant_data.csv b/src/eatnyc/data/nyc_restaurant_data.csv new file mode 100644 index 0000000..d8be3fc --- /dev/null +++ b/src/eatnyc/data/nyc_restaurant_data.csv @@ -0,0 +1,51 @@ +name,cuisine,neighborhood,price,rating,sample_dish +Reyna,Mediterranean,Greenwich Village,$$$,4.5,Lamb Baklava +4 Charles Prime Rib,American,West Village,$$$$,4.5,Burger +César,French,Hudson Square,$$$$,4.0,Uni Toast +Torrisi,Italian,Nolita,$$$$,4.5,Tortellini Pomodoro +Gene's Restaurant,Italian,Greenwich Village,$$$,4.0,Vodka Pasta +Strip House,American,Greenwich Village,$$$$,4.8,Chocolate Cake +Da Andrea,Italian,Greenwich Village,$$,4.5,Carbonara +Village Taverna,Greek,Greenwich Village,$$$,4.8,Chicken Souvlaki +Il Cantinori,Tuscan,Greenwich Village,$$$$,4.8,Pasta Trio +Morandi,Italian ,West Village,$$,3.5,Lemon Pasta +Little Owl,Mediterranean,West Village,$$$,4.8,Meatball Sliders +Buvette,French,West Village,$$,4.7,Croque Madame +Mole,Mexican,West Village,$$,3.5,Sizzling Fajitas +ATLA,Mexican,NoHo,$$$,3.5,Pork Al Pastor Gringa +La Palapa,Mexican,East Village,$$,4.5,Burrito Bowl +Raoul's,French,SoHo,$$$,4.0,Steak Au Poivre +La Mercerie,French,SoHo,$$$,3.5,Crepe Complete +Cafe Zaffri,Syrian,Flatiron,$$$$,3.0,Lamb Wellington +Motek,Mediterranean,Flatiron,$$$,4.9,Chicken Schnitzel +Hen House,Lebanese,East Village,$,4.5,Lamb Birria +The Smith,American,East Village,$$,4.6,Mac and Cheese +Gjelina,American,NoHo,$$$,4.6,Pomodoro Pizza +Estela,American,Nolita,$$$$,4.7,Endive Salad +Shuka,Mediterranean,SoHo,$$,4.5,Fried Halloumi +The Corner Store,American,SoHo,$$$$,4.5,Samoa Sundae +Cho Dang Gol,Korean,Koreatown,$$,4.6,Cod Roe Omelette +HOJOKBAN NYC,Korean,Koreatown,$$$,4.6,Shin Ramen Fried Rice +Jongro BBQ,Korean,Koreatown,$$$,4.5,Beef Short Ribs Gobdol +BCD Tofu House,Korean,Koreatown,$$,4.3,Seafood Pancake +MUI,Korean,Koreatown,$$,4.5,Grilled Spicy Chicken +Samwoojung,Korean,Koreatown,$$,4.5,Soup Bulgogi +BANGIA,Korean,Koreatown,$$,4.3,Lychee Soju +Take 31,Korean,Koreatown,$$,4.4,Korean Fried Chicken +Izakaya MEW,Japanese,Chelsea,$$,4.5,Mentai Spaghetti +House of Joy,Chinese,West Village,$$,4.1,Egg Tart +Kimura,Japanese,East Village,$$,4.5,Nabe +Karakatta,Japanese,West Village,$$,4.5,Spicy Ginger Stamina Ramen +Kyuramen,Japanese,East Village,$$,4.6,Tonkotsu Ramen +Ootoya,Japanese,East Village,$$,4.2,Katsu Toji +Chow House,Chinese,West Village,$$,4.6,Braised Fish Filet W Spicy Broad Sauce +Lao Ma Spicy,Chinese,East Village,$$,4.1,Stirfried Spicy Pot +Pranakhon,Thai,East Village,$$,4.7,Short Ribs +Top Thai Greenwich,Thai,West Village,$$,4.6,Pad Thai Noodles +Raku,Japanese,West Village,$$,4.5,Kani Ankake +Grandpa Thai,Thai,East Village,$$,4.8,Pad Krapow Moo +Mountain House,Chinese,East Village,$$,4.6,Beef Sour Soup +OLIO E PIU,Italian,West Village,$$$,4.7,Spaghetti Alla Carbonara +Gen Korean BBQ House,Korean,East Village,$$,3.9,Gen Premium Steak +886,Taiwanese,East Village,$$,4.2,Honey Glazed Popcorn Chicken +ZIZI,Mediterranean,Chelsea,$$,4.5,Overnight Lamb \ No newline at end of file diff --git a/tests/test_filter_restaurants.py b/tests/test_filter_restaurants.py new file mode 100644 index 0000000..e13f7d0 --- /dev/null +++ b/tests/test_filter_restaurants.py @@ -0,0 +1,50 @@ +import pytest +from eatnyc import filter_restaurants + + +#Standard Sample Data +def _sample_data(): + return [ + {"name": "B", "cuisine": "X", "neighborhood": "N1", "price": "$$", "rating": 4.2, "sample_dish": "d1"}, + {"name": "A", "cuisine": "Y", "neighborhood": "N2", "price": "$$$", "rating": 4.8, "sample_dish": "d2"}, + {"name": "C", "cuisine": "Z", "neighborhood": "N3", "price": "$", "rating": 3.9, "sample_dish": "d3"}, + {"name": "D", "cuisine": "Y", "neighborhood": "N2", "price": "$$$", "rating": 4.8, "sample_dish": "d4"}, + ] + +#test case for cuisine filter +def test_filter_restaurants_cuisine(): + data = _sample_data() + result = filter_restaurants(data, cuisine="Y") + assert [r["cuisine"] for r in result] == ["Y", "Y"] + +#test case for neighborhood filter +def test_filter_restaurants_neighborhood(): + data = _sample_data() + result = filter_restaurants(data, neighborhood="N2") + assert [r["neighborhood"] for r in result] == ["N2", "N2"] + +#test case for price filter +def test_filter_restaurants_price(): + data = _sample_data() + result = filter_restaurants(data, price="$$$") + assert [r["price"] for r in result] == ["$$$", "$$$"] + +#test case for minimum rating filter +def test_filter_restaurants_min_rating(): + data = _sample_data() + result = filter_restaurants(data, min_rating=4.5) + assert [r["rating"] for r in result] == [4.8, 4.8] + +#test case for sorting by name +def test_filter_restaurants_limit(): + data = _sample_data() + result = filter_restaurants(data, limit=2) + assert len(result) == 2 +# test case for sorting by name +def test_filter_restaurants_no_matches(): + data = _sample_data() + result = filter_restaurants(data, cuisine="Nonexistent") + assert result == [] + + + diff --git a/tests/test_format_card.py b/tests/test_format_card.py new file mode 100644 index 0000000..e9a0d9c --- /dev/null +++ b/tests/test_format_card.py @@ -0,0 +1,57 @@ +import pytest +from eatnyc import format_card + +# Reusable sample restaurant row +@pytest.fixture +def row(): + return { + "name": "Joe's Pizza", + "cuisine": "italian", + "neighborhood": "Manhattan", + "price": "$", + "rating": 4.5, + "sample_dish": "Classic cheese slice", + } + +# Test ASCII rendering at multiple widths, including min-width clamp +@pytest.mark.parametrize( + "width, expected_width", + [ + (60, 60), # normal width + (40, 40), # medium width + (10, 24), # smaller than minimum -> should clamp to 24 + ], +) +def test_ascii_box_width(row, width, expected_width): + output = format_card(row, width=width) + top_line = output.splitlines()[0] + bottom_line = output.splitlines()[-1] + + assert len(top_line) == expected_width + assert top_line == bottom_line # top and bottom frame match + + # Each inner line must be within the width and framed by | | + for line in output.splitlines()[1:-1]: + assert line.startswith("|") and line.endswith("|") + assert len(line) <= expected_width + +# Test markdown format and dish toggle +@pytest.mark.parametrize("show_dish, expected_contains", [(True, True), (False, False)]) +def test_markdown_format(row, show_dish, expected_contains): + md = format_card(row, style="markdown", show_dish=show_dish) + + assert "**Joe's Pizza**" in md # bold title + assert "*italian, Manhattan*" in md # italic subtitle + assert "Rating: 4.5" in md + assert "Price: $" in md + + assert ("Dish:" in md) is expected_contains + +# Test error on invalid input type +def test_invalid_input_type_error(): + # format_card expects a dict; passing wrong type should raise + with pytest.raises(AttributeError): + format_card(None) + + with pytest.raises(AttributeError): + format_card(["not", "a", "dict"]) diff --git a/tests/test_load_data.py b/tests/test_load_data.py new file mode 100644 index 0000000..c1a4abe --- /dev/null +++ b/tests/test_load_data.py @@ -0,0 +1,49 @@ +import pytest +from eatnyc import load_data + +def test_load_data_returns_list_and_dicts(): + #load_data should return a nonempty list of dicts with expected keys + data = load_data() + assert isinstance(data, list) + assert data, "Dataset should not be empty" + first = data[0] + assert isinstance(first, dict) + for key in ["name", "cuisine", "neighborhood", "price", "rating", "sample_dish"]: + assert key in first + +def test_load_data_missing_file_raises(): + #specifying a nonexistent path should raise FileNotFoundError. + with pytest.raises(FileNotFoundError): + load_data(path="does_not_exist.csv") + +def test_load_data_missing_required_columns(tmp_path): + #if a CSV lacks required columns, load_data(validate=True) raises ValueError. + bad_csv = tmp_path / "bad.csv" + bad_csv.write_text("name,rating\nExample,4.5\n", encoding="utf-8") + + with pytest.raises(ValueError) as e: + load_data(path=str(bad_csv), validate=True) + + #must mention at least one of the other required columns + assert any(k in str(e.value) for k in ["cuisine","neighborhood","price","sample_dish"]) + +def test_load_data_normalizes_fields(tmp_path): + #normalization: rating -> float, cuisines tokenized, price trimmed. + csv_text = ( + "name,cuisine,neighborhood,price,rating,sample_dish\n" + "Foo,\"Italian/ Pizza ; Pasta\",SoHo, $$ ,4.7,Margherita\n" + ) + good_csv = tmp_path / "good.csv" + good_csv.write_text(csv_text, encoding="utf-8") + + data = load_data(path=str(good_csv), validate=True) + row = data[0] + + #rating parsed to float + assert isinstance(row["rating"], float) and row["rating"] == 4.7 + + #cuisines split & lowercased + assert all(c in row["_cuisines"] for c in ["italian", "pizza", "pasta"]) + + #price trimmed (e.g., "$$") + assert row["price"] == "$$" \ No newline at end of file diff --git a/tests/test_sample_dish.py b/tests/test_sample_dish.py new file mode 100644 index 0000000..a7ed58d --- /dev/null +++ b/tests/test_sample_dish.py @@ -0,0 +1,93 @@ +import pytest +from eatnyc.core import sample_dish, load_data + + +def test_sample_dish_returns_restaurant(): + """Test that sample_dish returns a restaurant dict""" + result = sample_dish(seed=42) + assert result is not None + assert isinstance(result, dict) + assert "name" in result + assert "sample_dish" in result + + +def test_sample_dish_with_cuisine_filter(): + """Test that sample_dish filters by cuisine correctly""" + result = sample_dish(cuisine="Italian", seed=42) + assert result is not None + assert isinstance(result, dict) + # Check that it's actually Italian cuisine + assert "italian" in result.get("_cuisines", []) + + +def test_sample_dish_has_dish(): + """Test that returned restaurant has a sample_dish""" + result = sample_dish(seed=42) + assert result is not None + dish = result.get("sample_dish", "").strip() + assert dish != "" + + +def test_sample_dish_reproducible_with_seed(): + """Test that same seed produces same result""" + result1 = sample_dish(cuisine="Italian", seed=123) + result2 = sample_dish(cuisine="Italian", seed=123) + + # Both should return same restaurant + assert result1.get("name") == result2.get("name") + + +def test_sample_dish_invalid_cuisine(): + """Test that invalid cuisine returns error with suggestions""" + result = sample_dish(cuisine="InvalidCuisine123", seed=42) + + assert isinstance(result, dict) + assert "error" in result + assert "suggestions" in result + assert "message" in result + assert result["message"] == "Maybe try these instead?" + assert isinstance(result["suggestions"], list) + assert len(result["suggestions"]) > 0 + + +def test_sample_dish_no_cuisine(): + """Test that no cuisine parameter returns any restaurant""" + result = sample_dish(seed=42) + assert result is not None + assert isinstance(result, dict) + assert "name" in result + + +def test_sample_dish_cuisine_case_insensitive(): + """Test that cuisine filter is case-insensitive""" + result1 = sample_dish(cuisine="italian", seed=42) + result2 = sample_dish(cuisine="ITALIAN", seed=42) + result3 = sample_dish(cuisine="Italian", seed=42) + + # All should return the same result with same seed + assert result1.get("name") == result2.get("name") + assert result2.get("name") == result3.get("name") + + +@pytest.mark.parametrize("cuisine", ["Mexican", "French", "American", "Greek"]) +def test_sample_dish_various_cuisines(cuisine): + """Test that sample_dish works with different cuisines""" + result = sample_dish(cuisine=cuisine, seed=42) + assert result is not None + assert isinstance(result, dict) + assert cuisine.lower() in result.get("_cuisines", []) + + +@pytest.fixture +def sample_data(): + """Fixture to load data once for multiple tests""" + return load_data() + + +def test_sample_dish_returns_from_actual_data(sample_data): + """Test that sample_dish returns data from the loaded dataset""" + result = sample_dish(seed=42) + assert result is not None + # Check that the result exists in the actual data + names = [row.get("name") for row in sample_data] + assert result.get("name") in names diff --git a/tests/test_top_n.py b/tests/test_top_n.py new file mode 100644 index 0000000..2ed6136 --- /dev/null +++ b/tests/test_top_n.py @@ -0,0 +1,50 @@ +import pytest +from eatnyc import top_n + + +def _sample_data(): + return [ + {"name": "B", "cuisine": "X", "neighborhood": "N1", "price": "$$", "rating": 4.2, "sample_dish": "d1"}, + {"name": "A", "cuisine": "Y", "neighborhood": "N2", "price": "$$$", "rating": 4.8, "sample_dish": "d2"}, + {"name": "C", "cuisine": "Z", "neighborhood": "N3", "price": "$", "rating": 3.9, "sample_dish": "d3"}, + {"name": "D", "cuisine": "Y", "neighborhood": "N2", "price": "$$$", "rating": 4.8, "sample_dish": "d4"}, + ] + + +def test_top_n_default_rating_desc(): + data = _sample_data() + result = top_n(data, n=2) + assert len(result) == 2 + # default: sort_by="rating", descending=True + assert [r["name"] for r in result] == ["A", "D"] + + +def test_top_n_sort_by_name_ascending_all(): + data = _sample_data() + result = top_n(data, n=None, sort_by="name", descending=False) + assert [r["name"] for r in result] == ["A", "B", "C", "D"] + + +def test_top_n_n_zero_returns_empty(): + data = _sample_data() + result = top_n(data, n=0) + assert result == [] + + +def test_top_n_invalid_params_raise(): + data = _sample_data() + # invalid n + try: + top_n(data, n=-1) + assert False, "expected ValueError" + except ValueError: + pass + + # missing key + try: + top_n(data, sort_by="does_not_exist") + assert False, "expected KeyError" + except KeyError: + pass + +