diff --git a/.github/workflows/Pipfile b/.github/workflows/Pipfile index f5d05e5..9b6bc31 100644 --- a/.github/workflows/Pipfile +++ b/.github/workflows/Pipfile @@ -10,6 +10,9 @@ pytz = "*" python-dateutil = "*" [dev-packages] +pytest = "*" +build = "*" +twine = "*" [requires] python_version = "3" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6244933 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + pull_request: + branches: [ pipfile-experiment ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install build + test tooling + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + # build tools + python -m pip install build + + - name: Run tests + run: | + pytest -q + + - name: Build package (sdist & wheel) + run: | + python -m build + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: pypet-dist-${{ matrix.python-version }} + path: dist/* \ No newline at end of file diff --git a/.github/workflows/event-logger.yml b/.github/workflows/event-logger.yml index 31f231e..2e26b07 100644 --- a/.github/workflows/event-logger.yml +++ b/.github/workflows/event-logger.yml @@ -1,54 +1,133 @@ name: log github events + on: push: - branches: [main, master, pipfile-experiment] + branches: [ main, master, pipfile-experiment ] pull_request: - types: [opened, closed] - branches: [main, master, pipfile-experiment] + types: [ opened, closed ] + branches: [ main, master, pipfile-experiment ] + workflow_dispatch: {} # allow manual runs so the badge can show status any time + +permissions: + contents: read + +concurrency: + group: event-logger-${{ github.ref }} + cancel-in-progress: true + jobs: log: runs-on: ubuntu-latest env: - PIPENV_PIPFILE: .github/workflows/Pipfile # so this script doesn't use the Pipfile in the root directory + # keep using the workflow-local Pipfile so we don't pollute the project env + PIPENV_PIPFILE: .github/workflows/Pipfile COMMIT_LOG_API: ${{ secrets.COMMIT_LOG_API }} - GITHUB_LOGIN: ${{ github.actor }} # github login also available in github.triggering_actor, github.event.sender.login + GITHUB_LOGIN: ${{ github.actor }} COMMITS: ${{ toJSON(github.event.commits) }} - REPOSITORY_URL: ${{ github.repositoryUrl }} + REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }} EVENT_TYPE: ${{ github.event_name }} EVENT_ACTION: ${{ github.event.action }} PR_MERGED: ${{ github.event.pull_request.merged }} - PR_CREATED_AT: ${{ github.event.pull_request.created_at}} - PR_CLOSED_AT: ${{ github.event.pull_request.closed_at}} + PR_CREATED_AT: ${{ github.event.pull_request.created_at }} + PR_CLOSED_AT: ${{ github.event.pull_request.closed_at }} + steps: - - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 with: - fetch-depth: 0 # this is important so git fetches all history.. the actions/checkout by default fetches all history as one commit which throws off stats - - uses: actions/setup-python@v3 + fetch-depth: 0 # needed for accurate stats + + - name: Setup Python + uses: actions/setup-python@v5 with: - python-version: "^3.9" - - name: Install dependencies + python-version: "3.10" + cache: pip + + - name: Install dependencies (pipenv in workflow scope) run: | + set -euo pipefail python -m pip install --upgrade pip - pip install --user pipenv - pipenv --python $(which python) + python -m pip install pipenv + pipenv --python "$(which python)" pipenv install + + # --- helpful diagnostics --- + - name: Dump GitHub context + run: | + set -euo pipefail + echo "Event: ${{ github.event_name }}" + echo "Action: ${{ github.event.action }}" + echo "Ref: ${{ github.ref }}" + echo "SHA: ${{ github.sha }}" + echo "Actor: ${{ github.actor }}" + echo "Repo: ${{ github.repository }}" + echo '${{ toJson(github.event) }}' > event.json + cat event.json | head -c 2000 || true + + # --- event-specific logging --- - name: Log pull request opened if: github.event_name == 'pull_request' && github.event.action == 'opened' run: | - pipenv run gitcommitlogger -r $(echo $REPOSITORY_URL) -t pull_request_opened -d $(echo $PR_CREATED_AT) -un $(echo $GITHUB_LOGIN) -o commit_stats.csv -u $(echo $COMMIT_LOG_API) -v + set -euo pipefail + pipenv run gitcommitlogger \ + -r "${REPOSITORY_URL}" \ + -t pull_request_opened \ + -d "${PR_CREATED_AT}" \ + -un "${GITHUB_LOGIN}" \ + -o commit_stats.csv \ + -u "${COMMIT_LOG_API}" \ + -v + - name: Log pull request closed and merged if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true run: | - echo $COMMITS > commits.json - cat commits.json # debugging - pipenv run gitcommitlogger -r $(echo $REPOSITORY_URL) -t pull_request_merged -d $(echo $PR_CLOSED_AT) -un $(echo $GITHUB_LOGIN) -i commits.json -o commit_stats.csv -u $(echo $COMMIT_LOG_API) -v + set -euo pipefail + echo '${{ toJson(github.event.pull_request) }}' > pr.json + echo "${COMMITS}" > commits.json + pipenv run gitcommitlogger \ + -r "${REPOSITORY_URL}" \ + -t pull_request_merged \ + -d "${PR_CLOSED_AT}" \ + -un "${GITHUB_LOGIN}" \ + -i commits.json \ + -o commit_stats.csv \ + -u "${COMMIT_LOG_API}" \ + -v + - name: Log pull request closed without merge if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == false run: | - pipenv run gitcommitlogger -r $(echo $REPOSITORY_URL) -t pull_request_closed -d $(echo $PR_CLOSED_AT) -un $(echo $GITHUB_LOGIN) -o commit_stats.csv -u $(echo $COMMIT_LOG_API) -v + set -euo pipefail + pipenv run gitcommitlogger \ + -r "${REPOSITORY_URL}" \ + -t pull_request_closed \ + -d "${PR_CLOSED_AT}" \ + -un "${GITHUB_LOGIN}" \ + -o commit_stats.csv \ + -u "${COMMIT_LOG_API}" \ + -v + - name: Log push if: github.event_name == 'push' run: | - echo $COMMITS > commits.json - cat commits.json # debugging - pipenv run gitcommitlogger -r $(echo $REPOSITORY_URL) -t $(echo $EVENT_TYPE) -i commits.json -o commit_stats.csv -u $(echo $COMMIT_LOG_API) -v + set -euo pipefail + echo "${COMMITS}" > commits.json + pipenv run gitcommitlogger \ + -r "${REPOSITORY_URL}" \ + -t "${EVENT_TYPE}" \ + -i commits.json \ + -o commit_stats.csv \ + -u "${COMMIT_LOG_API}" \ + -v + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: event-logger-${{ github.run_number }} + path: | + event.json + pr.json + commits.json + commit_stats.csv + if-no-files-found: ignore diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..b9f8677 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +pytest-cov = "*" +codecov = "*" +coveralls = "*" + +[requires] +python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..e0c1a5b --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,305 @@ +{ + "_meta": { + "hash": { + "sha256": "ee01805fb13888671ff48eaeb0cae8c61bfc13466557c470afb2774abb142a7e" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.13" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": {}, + "develop": { + "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" + }, + "codecov": { + "hashes": [ + "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", + "sha256:7d2b16c1153d01579a89a94ff14f9dbeb63634ee79e18c11036f34e7de66cbc9", + "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.1.13" + }, + "coverage": { + "extras": [ + "toml" + ], + "hashes": [ + "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79", + "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a", + "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f", + "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a", + "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa", + "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398", + "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba", + "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d", + "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf", + "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b", + "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518", + "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d", + "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795", + "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2", + "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e", + "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32", + "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745", + "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b", + "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e", + "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d", + "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f", + "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660", + "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62", + "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6", + "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04", + "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c", + "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5", + "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef", + "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc", + "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae", + "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578", + "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466", + "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4", + "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91", + "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0", + "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4", + "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b", + "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe", + "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b", + "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75", + "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b", + "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c", + "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72", + "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b", + "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f", + "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e", + "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53", + "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3", + "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84", + "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987" + ], + "markers": "python_version >= '3.7'", + "version": "==6.5.0" + }, + "coveralls": { + "hashes": [ + "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea", + "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026" + ], + "index": "pypi", + "markers": "python_version >= '3.5'", + "version": "==3.3.1" + }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, + "idna": { + "hashes": [ + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + ], + "markers": "python_version >= '3.8'", + "version": "==3.11" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "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" + }, + "pytest": { + "hashes": [ + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + }, + "pytest-cov": { + "hashes": [ + "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", + "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==7.0.0" + }, + "requests": { + "hashes": [ + "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", + "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" + ], + "markers": "python_version >= '3.9'", + "version": "==2.32.5" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + } + } +} diff --git a/README.md b/README.md index 6022e0e..a3f4115 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ # Python Package Exercise +[![log github events](https://github.com/swe-students-fall2025/3-python-package-team_pioneer/actions/workflows/event-logger.yml/badge.svg?branch=pipfile-experiment)](https://github.com/swe-students-fall2025/3-python-package-team_pioneer/actions/workflows/event-logger.yml) + An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. + +Group Members + +- [Connor Lee](https://github.com/Connorlee487) +- [Lanxi](https://github.com/player1notfound) +- [Alex](https://github.com/axie22) +- 4th ? + +## `pyPet` + +**Theme:** A tiny virtual pet in your terminal. + +**Concept:** +`pyPet` creates a small, stateful pet that lives in your terminal. You can feed it, play with it, and check on its mood. + +**Core Functions** + +- `create_pet(name: str, species: str, mood: str, hunger: int)` + - Initializes a new pet with starting attributes. +- `feed(pet: dict, food: str, portion: int, treat: bool)` + - Reduces hunger, may improve mood based on treat type. +- `play(pet: dict, game: str, energy: int, reward: bool)` + - Boosts happiness and decreases energy over time. +- `status(pet: dict, color: bool, verbose: bool, ascii_art: bool)` + - Prints the current pet’s stats and ASCII representation. + +### Instructions for Running & Testing + +``` bash +pipenv install --dev +pipenv run pip install -e . +pipenv run pytest -q +``` + +Build + +``` bash +pipenv run python -m build +``` + +Upload to TestPyPi + +``` bash +pipenv run twine upload --repository testpypi dist/* +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8719c2b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pypet" +description = "A tiny virtual pet that lives in your terminal." +version = "0.1.0" +authors = [ + { name = "Alexander Xie", email = "alexxie9667@gmail.com" }, +] +license = { file = "LICENSE" } +readme = "README.md" +keywords = ["virtual pet", "terminal", "game", "python"] +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", +] + +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest"] + +[project.urls] +"Homepage" = "https://github.com/swe-students-fall2025/3-python-package-team_pioneer" +"Repository" = "https://github.com/swe-students-fall2025/3-python-package-team_pioneer.git" +"Bug Tracker" = "https://github.com/swe-students-fall2025/3-python-package-team_pioneer/issues" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bcaa5c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pipenv +twine +build +pytest diff --git a/src/demo.py b/src/demo.py new file mode 100644 index 0000000..19ae5cb --- /dev/null +++ b/src/demo.py @@ -0,0 +1,16 @@ +from pypet import create_pet,status,play +import time + + +pet = create_pet("Mochi", "otter") +play(pet, "fetch", 10, True) +print(status(pet, color=True, verbose=True, ascii_art=True)) + +pet = create_pet("Mochi", "cat") +play(pet, "fetch", 10, True) +print(status(pet, color=True, verbose=True, ascii_art=True)) + + + + + diff --git a/src/pypet/__init__.py b/src/pypet/__init__.py new file mode 100644 index 0000000..5e67a1d --- /dev/null +++ b/src/pypet/__init__.py @@ -0,0 +1,31 @@ +""" +pyPet + +APIs: +- create_pet() +- feed(fill in args when done) +- play(fill in args when done) +- status(fill in args when done) +""" + +from .pet import ( + create_pet, + play, + status, + ALLOWED_SPECIES, + ALLOWED_MOODS, + BOUNDS, + Pet, +) + +__all__ = [ + "create_pet", + "status", + "play", + "ALLOWED_SPECIES", + "ALLOWED_MOODS", + "BOUNDS", + "Pet", +] + +__version__ = "0.1.0" diff --git a/src/pypet/main.py b/src/pypet/main.py new file mode 100644 index 0000000..9fda63f --- /dev/null +++ b/src/pypet/main.py @@ -0,0 +1,11 @@ +import pypet.pet as pet + + +def main(): + """ + For pypet + """ + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/pypet/pet.py b/src/pypet/pet.py new file mode 100644 index 0000000..34767a3 --- /dev/null +++ b/src/pypet/pet.py @@ -0,0 +1,362 @@ +from __future__ import annotations +from datetime import datetime +from dataclasses import dataclass +from time import time +from typing import TypedDict, Dict +from uuid import uuid4 + +ANIMAL_ART = { + "cat": r""" + /\_/\ +( o.o ) + > ^ < +""", + "dog": r""" + / \__ + ( @\___ + / O +/ (_____/ +/_____/ U +""", + "otter": r""" + (\_._/) + ( o o ) + > ^ < +""", + "capybara": r""" + ( \_______/ ) + ( o o ) + ( - ) + """, + "duck": r""" +<(o )___ + ( ._> / + `---' +""" +} + +ALLOWED_SPECIES = {"cat", "dog", "otter", "capybara", "duck"} +ALLOWED_MOODS = {"happy", "neutral", "grumpy", "sleepy", "hungry", "sad"} +BOUNDS: Dict[str, tuple[int, int]] = { + "hunger": (0, 100), # 0 = not hungry, 100 = hungry + "energy": (0, 100), # 0 = empty, 100 = full + "happiness": (0, 100), # 0 = sad, 100 = happy +} + + +class Pet(TypedDict, total=False): + """ + Schema for a pet instance. + + Keys: + id: unique identifier + version: schema version + name: non-empty name + species: lowercase species key in ALLOWED_SPECIES + mood: lowercase mood key in ALLOWED_MOODS + hunger: 0-100 (int) + energy: 0-100 (int) + happiness: 0-100 (int) + created_at: timestamp + last_interaction_at: timestamp + ascii_key: f"{species}:{mood}" + """ + id: str + version: int + name: str + species: str + mood: str + hunger: int + energy: int + happiness: int + created_at: float + last_interaction_at: float + ascii_key: str + +def clamp(x: int, lo: int, hi: int) -> int: + """Clamp integer x to [lo, hi]""" + try: + xi = int(x) + except (TypeError, ValueError): + raise ValueError(f"value {x!r} must be an integer") + return max(lo, min(hi, xi)) + + +def baseline_happiness(hunger: int) -> int: + """ + Compute a baseline happiness from hunger + happiness = clamp(80 - hunger // 2, 0, 100) + """ + lo, hi = BOUNDS["happiness"] + return clamp(80 - clamp(hunger, *BOUNDS["hunger"]) // 2, lo, hi) + +def create_pet( + name: str, + species: str, + mood: str = "neutral", + hunger: int = 50, + energy: int | None = None, +) -> Pet: + """ + Create and return a new pet dict + + Args: + name: Pet's display name. Must be a non-empty string after trimming. + species: One of ALLOWED_SPECIES (normalized to lowercase). + mood: One of ALLOWED_MOODS (normalized to lowercase). Default "neutral". + hunger: Integer 0-100 (0=not hungry, 100=hungry) + energy: Optional integer 0-100 (default 70 if omitted) + + Returns: + Pet: a new pet object adhering to the schema defined by `Pet`. + + Raises: + ValueError: on invalid name/species/mood types or unsupported values. + """ + + if not isinstance(name, str): + raise ValueError("name must be a string") + name = name.strip() + if not name: + raise ValueError("name must be non-empty") + + if not isinstance(species, str): + raise ValueError("species must be a string") + species = species.strip().lower() + if species not in ALLOWED_SPECIES: + raise ValueError(f"unsupported species: {species!r}. Allowed: {sorted(ALLOWED_SPECIES)}") + + if not isinstance(mood, str): + raise ValueError("mood must be a string") + mood = mood.strip().lower() + if mood not in ALLOWED_MOODS: + raise ValueError(f"unsupported mood: {mood!r}. Allowed: {sorted(ALLOWED_MOODS)}") + + + hunger = clamp(hunger, *BOUNDS["hunger"]) + energy = clamp(energy if energy is not None else 70, *BOUNDS["energy"]) + happiness = baseline_happiness(hunger) + + pet: Pet = { + "id": str(uuid4()), + "version": 1, + "name": name, + "species": species, + "mood": mood, + "hunger": hunger, + "energy": energy, + "happiness": happiness, + "created_at": time(), + "last_interaction_at": time(), + "ascii_key": f"{species}:{mood}" + } + return pet + + +def feed(pet: dict, food: str, portion: int, treat: bool): + """ + Feed the pet to reduce hunger and improve happiness slightly. + Also updates mood and timestamps. + """ + if not isinstance(pet, dict): + raise ValueError("pet must be a dict created by create_pet()") + if "hunger" not in pet or "happiness" not in pet: + raise ValueError("invalid pet schema") + + if not isinstance(food, str): + raise ValueError("food must be a string") + if not isinstance(portion, int) or not (0 <= portion <= 100): + raise ValueError("portion must be between 0 and 100") + + # Time since last interaction + pet = time_passes(pet, seconds=1800) + + FOOD_EFFECTS = { + "kibble": 15, + "fish": 25, + "carrot": 10, + "steak": 30, + "cookie": 20, + } + + base_effect = FOOD_EFFECTS.get(food.lower(), 10) + hunger_delta = base_effect * (portion / 100) + if treat: + hunger_delta += 5 + + lo, hi = BOUNDS["hunger"] + pet["hunger"] = clamp(pet["hunger"] - hunger_delta, lo, hi) + + # Increase happiness based on fullness + fullness_bonus = (100 - pet["hunger"]) / 15 + pet["happiness"] = clamp( + pet["happiness"] + fullness_bonus + (5 if treat else 0), + *BOUNDS["happiness"], + ) + + # Update last interaction + pet["last_interaction_at"] = time() + + # Recalculate mood + pet = update_mood(pet) + + return pet + +def play(pet: dict, game: str, energy: int, reward: bool): + """ + Play a game with the pet, updating its energy and happiness. + + Args: + pet: Pet dict to play with + game: Name of the game being played + energy: Amount of energy to deduct (must be positive) + reward: Whether to give a treat/happy boost + + Returns: + dict: Updated pet dict with modified energy, happiness, and last_interaction_at + + Raises: + ValueError: if energy is not a positive integer + """ + if not isinstance(energy, int) or energy <= 0: + raise ValueError("energy must be a positive integer") + + # Get current stats + current_energy = pet.get("energy", 0) + current_happiness = pet.get("happiness", 0) + species = pet.get("species", "").lower() + + # Check if pet has enough energy to play + if current_energy < energy: + # Not enough energy - small happiness penalty + new_energy = 0 + new_happiness = clamp(current_happiness - 2, *BOUNDS["happiness"]) + else: + # Deduct energy + new_energy = clamp(current_energy - energy, *BOUNDS["energy"]) + + # Base happiness boost + happiness_boost = 10 if reward else 5 + + # Species-specific multipliers for certain games + species_boost = 0 + if species == "dog" and "fetch" in game.lower(): + species_boost = 3 + elif species == "cat" and ("laser" in game.lower() or "string" in game.lower()): + species_boost = 3 + elif species == "duck" and ("water" in game.lower() or "swim" in game.lower()): + species_boost = 3 + elif species == "otter" and ("water" in game.lower() or "swim" in game.lower()): + species_boost = 4 + elif species == "capybara" and "relax" in game.lower(): + species_boost = 2 + + new_happiness = clamp(current_happiness + happiness_boost + species_boost, *BOUNDS["happiness"]) + + # Update mood based on new stats + new_mood = pet.get("mood", "neutral") + if new_energy < 20: + new_mood = "sleepy" + elif new_happiness >= 80: + new_mood = "happy" + elif new_happiness < 30: + new_mood = "sad" + elif new_happiness < 50: + new_mood = "grumpy" + else: + new_mood = "neutral" + + # Create updated pet + updated_pet = pet.copy() + updated_pet["energy"] = new_energy + updated_pet["happiness"] = new_happiness + updated_pet["mood"] = new_mood + updated_pet["last_interaction_at"] = time() + updated_pet["ascii_key"] = f"{species}:{new_mood}" + + return updated_pet + +def status(pet: dict, color: bool = False, verbose: bool = False, ascii_art: bool = False): + if not isinstance(pet, dict): + raise ValueError("pet must be a dictionary") + + pet["last_interaction_at"] = time() + + name = pet.get("name", "Unknown") + species = pet.get("species", "unknown") + mood = pet.get("mood", "neutral") + + if color: + colors = { + "happy": "\033[92m", # green + "neutral": "\033[93m", # yellow + "grumpy": "\033[91m", # red + "sleepy": "\033[94m", # blue + "hungry": "\033[95m", # magenta + "sad": "\033[90m", # gray + } + end = "\033[0m" + mood_text = f"{colors.get(mood, '')}{mood}{end}" + else: + mood_text = mood + + summary = "\n" + "-" * 40 + "\n" + summary += f"{name} the {species} looks {mood_text}.\n" + summary += "-" * 40 + + if verbose: + summary += "\n" + for key, value in pet.items(): + if key in {"name", "species", "mood"}: + continue + # Format timestamps + if key in {"created_at", "last_interaction_at"}: + value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S") + label = "Created at:" if key == "created_at" else "Last interaction:" + else: + # Capitalize first letter for keys + label = key.capitalize() + ":" + summary += f"{label} {value}\n" + summary = summary.strip() + summary += "\n" + "-" * 40 + + + if ascii_art: + art=ANIMAL_ART.get(species, "(•ᴗ•)") + summary+= "\n"+art.strip()+"\n\n" + + return summary + +def update_mood(pet: Pet) -> Pet: + """Recalculate mood based on hunger, energy, and happiness.""" + hunger, energy, happiness = pet["hunger"], pet["energy"], pet["happiness"] + + if hunger > 80: + pet["mood"] = "hungry" + elif energy < 20: + pet["mood"] = "sleepy" + elif happiness > 70: + pet["mood"] = "happy" + elif happiness < 30: + pet["mood"] = "sad" + else: + pet["mood"] = "neutral" + + pet["ascii_key"] = f"{pet['species']}:{pet['mood']}" + return pet + +def time_passes(pet: Pet, seconds: int = 3600) -> Pet: + """Simulate time passing: hunger increases and happiness decreases.""" + decay_factor = seconds / 3600 + pet["hunger"] = clamp(pet["hunger"] + 5 * decay_factor, *BOUNDS["hunger"]) + pet["happiness"] = clamp(pet["happiness"] - 3 * decay_factor, *BOUNDS["happiness"]) + pet["last_interaction_at"] = time() + return update_mood(pet) + +def describe_pet(pet: Pet) -> str: + """Return a readable summary of the pet's state.""" + return ( + f"{pet['name']} the {pet['species']} looks {pet['mood']}! " + f"Hunger: {pet['hunger']:.1f}/100, Energy: {pet['energy']}/100, " + f"Happiness: {pet['happiness']:.1f}/100." + ) \ No newline at end of file diff --git a/tests/test_create_pet.py b/tests/test_create_pet.py new file mode 100644 index 0000000..a1b9378 --- /dev/null +++ b/tests/test_create_pet.py @@ -0,0 +1,36 @@ +from pypet import create_pet, ALLOWED_SPECIES, ALLOWED_MOODS + +def test_create_pet_happy_path(): + pet = create_pet("Mochi", "Otter", mood="Happy", hunger=10, energy=90) + assert pet["name"] == "Mochi" + assert pet["species"] == "otter" + assert pet["mood"] == "happy" + assert 0 <= pet["hunger"] <= 100 + assert 0 <= pet["energy"] <= 100 + assert 0 <= pet["happiness"] <= 100 + assert pet["ascii_key"] == "otter:happy" + +def test_create_pet_validation_errors(): + for bad in ("", " "): + try: + create_pet(bad, "cat") + assert False, "expected ValueError for empty name" + except ValueError: + pass + try: + create_pet("Ok", "dragon") + assert False, "expected ValueError for species" + except ValueError: + pass + try: + create_pet("Ok", "cat", mood="ecstatic") + assert False, "expected ValueError for mood" + except ValueError: + pass + +def test_defaults_and_normalization(): + pet = create_pet(" PIP ", "DoG") + assert pet["name"] == "PIP" + assert pet["species"] == "dog" + assert pet["mood"] == "neutral" + assert pet["energy"] == 70 # default diff --git a/tests/test_feed_helper.py b/tests/test_feed_helper.py new file mode 100644 index 0000000..be9f1ea --- /dev/null +++ b/tests/test_feed_helper.py @@ -0,0 +1,93 @@ +from pypet.pet import create_pet, feed, update_mood, time_passes, describe_pet + + +def test_feed_reduces_hunger(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=80) + updated_pet = feed(pet, "fish", 30, treat=False) + + assert 0 <= updated_pet["hunger"] < 80, "Hunger did not decrease as expected" + assert isinstance(updated_pet["hunger"], (int, float)) + + +def test_feed_increases_happiness(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=70) + before = pet["happiness"] + updated_pet = feed(pet, "fish", 40, treat=False) + + assert updated_pet["happiness"] >= before, "Happiness should not decrease after feeding" + assert updated_pet["happiness"] <= 100, "Happiness should not exceed 100" + + +def test_feed_treat_makes_pet_happy(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=60) + before_happiness = pet["happiness"] + + updated_pet = feed(pet, "fish", 20, treat=True) + + assert updated_pet["happiness"] > before_happiness, "Treat should boost happiness" + + assert updated_pet["mood"] in {"happy", "content", "neutral"}, ( + f"Mood after treat should be positive, got {updated_pet['mood']}" + ) + + + +def test_update_mood_adjusts_based_on_hunger_energy_happiness(): + + pet = { + "name": "Mochi", + "species": "otter", + "mood": "neutral", + "hunger": 90, + "energy": 50, + "happiness": 50, + } + pet = update_mood(pet) + + assert pet["mood"] in {"hungry", "sleepy", "happy", "sad", "neutral"}, "Invalid mood after update" + assert "ascii_key" in pet, "ascii_key not updated" + + +def test_time_passes_increases_hunger_and_updates_mood(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=30) + before_hunger = pet["hunger"] + updated_pet = time_passes(pet, seconds=7200) # 2 hours later + + assert updated_pet["hunger"] > before_hunger, "Hunger should increase over time" + assert updated_pet["mood"] in {"hungry", "neutral", "sleepy", "sad", "happy"} + + +def test_describe_pet_returns_readable_summary(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=40) + desc = describe_pet(pet) + + assert isinstance(desc, str), "Description should be a string" + assert "Mochi" in desc and "otter" in desc, "Missing pet info in description" + assert any(word in desc for word in ["happy", "neutral", "hungry", "sleepy", "sad"]), "Mood not in description" + + +def test_feed_does_not_exceed_bounds(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=5) + updated_pet = feed(pet, "steak", 80, treat=True) + + assert 0 <= updated_pet["hunger"] <= 100, "Hunger out of bounds" + assert 0 <= updated_pet["happiness"] <= 100, "Happiness out of bounds" + + +def test_multiple_interactions_keep_state_consistent(): + + pet = create_pet("Mochi", "otter", "neutral", hunger=70) + pet = feed(pet, "fish", 30, treat=False) + pet = time_passes(pet, seconds=3600) + pet = feed(pet, "cookie", 50, treat=True) + + assert 0 <= pet["hunger"] <= 100 + assert 0 <= pet["energy"] <= 100 + assert 0 <= pet["happiness"] <= 100 + assert isinstance(pet["mood"], str) diff --git a/tests/test_play.py b/tests/test_play.py new file mode 100644 index 0000000..4d579ef --- /dev/null +++ b/tests/test_play.py @@ -0,0 +1,223 @@ +from pypet import create_pet +from pypet import play + +def test_play_basic_happy_path(): + """Test basic play functionality with reward""" + pet = create_pet("Buddy", "dog", energy=80, hunger=20) + initial_energy = pet["energy"] + initial_happiness = pet["happiness"] + + updated_pet = play(pet, "fetch", energy=30, reward=True) + + # Check energy decreased by expected amount + assert updated_pet["energy"] == initial_energy - 30 + assert updated_pet["energy"] == 50 + + # Check happiness increased (base + reward + species bonus) + # reward=True gives 10 base + 3 species bonus for dog+fetch = 13 total + assert updated_pet["happiness"] > initial_happiness + assert updated_pet["happiness"] == initial_happiness + 13 + + # Check last_interaction_at was updated + assert updated_pet["last_interaction_at"] > pet["last_interaction_at"] + + +def test_play_without_reward(): + """Test play without giving reward""" + pet = create_pet("Whiskers", "cat", energy=60, hunger=30) + initial_energy = pet["energy"] + initial_happiness = pet["happiness"] + + updated_pet = play(pet, "laser pointer", energy=20, reward=False) + + # Check energy decreased + assert updated_pet["energy"] == initial_energy - 20 + assert updated_pet["energy"] == 40 + + # Check happiness increased (base + species bonus, no reward) + assert updated_pet["happiness"] > initial_happiness + assert updated_pet["happiness"] >= initial_happiness + 8 # 5 base + 3 species + + +def test_play_species_bonus_dog_fetch(): + """Test dog gets bonus for fetch game""" + pet = create_pet("Max", "dog", energy=100, hunger=10) + initial_happiness = pet["happiness"] + + updated_pet = play(pet, "Fetch the Ball", energy=10, reward=False) + + # Dog + fetch should give extra happiness + assert updated_pet["happiness"] >= initial_happiness + 8 # 5 base + 3 species + + +def test_play_species_bonus_cat_laser(): + """Test cat gets bonus for laser/string games""" + pet = create_pet("Shadow", "cat", energy=90, hunger=15) + initial_happiness = pet["happiness"] + + updated_pet = play(pet, "laser tag", energy=15, reward=False) + + # Cat + laser should give extra happiness + assert updated_pet["happiness"] >= initial_happiness + 8 # 5 base + 3 species + + +def test_play_species_bonus_otter_water(): + """Test otter gets bigger bonus for water games""" + pet = create_pet("Splash", "otter", energy=85, hunger=20) + initial_happiness = pet["happiness"] + + updated_pet = play(pet, "swim in pool", energy=25, reward=False) + + # Otter + water should give biggest bonus + assert updated_pet["happiness"] >= initial_happiness + 9 # 5 base + 4 species + + +def test_play_insufficient_energy(): + """Test play when pet doesn't have enough energy""" + pet = create_pet("Sleepy", "capybara", energy=10, hunger=40) + initial_happiness = pet["happiness"] + + # Try to use more energy than available + updated_pet = play(pet, "relax", energy=50, reward=True) + + # Energy should go to 0 + assert updated_pet["energy"] == 0 + + # Happiness should decrease slightly + assert updated_pet["happiness"] < initial_happiness + assert updated_pet["happiness"] == initial_happiness - 2 + + +def test_play_energy_clamping(): + """Test energy is clamped to 0-100""" + pet = create_pet("Active", "duck", energy=5, hunger=25) + + updated_pet = play(pet, "water play", energy=10, reward=False) + + # Should not go below 0 + assert updated_pet["energy"] >= 0 + + +def test_play_happiness_clamping(): + """Test happiness is clamped to 0-100""" + pet = create_pet("Joy", "dog", energy=80, hunger=5) + # Set very high happiness + pet["happiness"] = 95 + + updated_pet = play(pet, "fetch", energy=20, reward=True) + + # Should not exceed 100 + assert updated_pet["happiness"] <= 100 + + +def test_play_mood_changes_to_happy(): + """Test mood changes to happy when happiness is high""" + pet = create_pet("Cheerful", "cat", energy=90, hunger=10) + pet["happiness"] = 75 # Start close to happy + + updated_pet = play(pet, "laser", energy=10, reward=True) + + # Should become happy + assert updated_pet["mood"] == "happy" + assert updated_pet["ascii_key"] == "cat:happy" + + +def test_play_mood_changes_to_sleepy(): + """Test mood changes to sleepy when energy is low""" + pet = create_pet("Tired", "otter", energy=50, hunger=30) + + updated_pet = play(pet, "water games", energy=40, reward=False) + + # Should become sleepy + assert updated_pet["mood"] == "sleepy" + assert updated_pet["ascii_key"] == "otter:sleepy" + + +def test_play_mood_changes_to_sad(): + """Test mood changes to sad when happiness is low""" + pet = create_pet("Down", "duck", energy=60, hunger=50) + pet["happiness"] = 20 # Low happiness + + updated_pet = play(pet, "generic game", energy=10, reward=False) + + # Should become sad + assert updated_pet["mood"] == "sad" + assert updated_pet["ascii_key"] == "duck:sad" + + +def test_play_mood_changes_to_grumpy(): + """Test mood changes to grumpy when happiness is medium-low""" + pet = create_pet("Grouchy", "capybara", energy=70, hunger=45) + pet["happiness"] = 40 # Medium-low happiness + + updated_pet = play(pet, "relax", energy=30, reward=False) + + # Should become grumpy + assert updated_pet["mood"] == "grumpy" + assert updated_pet["ascii_key"] == "capybara:grumpy" + + +def test_play_invalid_energy(): + """Test play raises error for invalid energy values""" + pet = create_pet("Test", "dog", energy=50, hunger=30) + + # Test negative energy + try: + play(pet, "fetch", energy=-10, reward=False) + assert False, "expected ValueError for negative energy" + except ValueError: + pass + + # Test zero energy + try: + play(pet, "fetch", energy=0, reward=False) + assert False, "expected ValueError for zero energy" + except ValueError: + pass + + # Test non-integer energy + try: + play(pet, "fetch", energy="ten", reward=False) + assert False, "expected ValueError for non-integer energy" + except ValueError: + pass + + +def test_play_original_pet_not_modified(): + """Test that playing doesn't modify the original pet dict""" + pet = create_pet("Original", "dog", energy=80, hunger=25) + original_energy = pet["energy"] + original_happiness = pet["happiness"] + original_mood = pet["mood"] + + updated_pet = play(pet, "fetch", energy=30, reward=True) + + # Original pet should be unchanged + assert pet["energy"] == original_energy + assert pet["happiness"] == original_happiness + assert pet["mood"] == original_mood + + # Updated pet should be different + assert updated_pet["energy"] != pet["energy"] + + +def test_play_multiple_species_games(): + """Test all species with their preferred games""" + species_games = [ + ("dog", "fetch", 10), + ("cat", "string toy", 10), + ("otter", "swimming", 10), + ("duck", "water play", 10), + ("capybara", "relaxing", 10), + ] + + for species, game, expected_bonus in species_games: + pet = create_pet(f"{species.capitalize()}Test", species, energy=70, hunger=30) + initial_happiness = pet["happiness"] + + updated_pet = play(pet, game, energy=20, reward=False) + + # Each should get some bonus + assert updated_pet["happiness"] > initial_happiness + assert updated_pet["happiness"] >= initial_happiness + 5 # At least base boost + diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..7566549 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,28 @@ +from pypet import create_pet, status + +def test_status_basic_output(): + pet = create_pet("Mochi", "otter", mood="happy") + s = status(pet) + assert "Mochi" in s + assert "otter" in s + assert "happy" in s + +def test_status_verbose(): + pet = create_pet("Pip", "cat") + s = status(pet, verbose=True) + assert "Hunger" in s + assert "Energy" in s + assert "Happiness" in s + +def test_status_ascii_art(): + animals = ["cat", "dog", "otter", "capybara", "duck"] + for species in animals: + pet = create_pet("Test", species) + s = status(pet, ascii_art=True) + + assert "(" in s or "\\" in s or "_" in s + +def test_status_color(): + pet = create_pet("Mochi", "otter", mood="happy") + s = status(pet, color=True) + assert "\033[" in s \ No newline at end of file