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..842a64d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,321 @@ -# Python Package Exercise +[![CI / CD](https://github.com/swe-students-fall2025/3-python-package-team_pioneer/actions/workflows/ci.yml/badge.svg)](https://github.com/swe-students-fall2025/3-python-package-team_pioneer/actions/workflows/ci.yml) -An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. +# pyPet 🐾 + +**A tiny virtual pet that lives in your terminal** + +pyPet is a delightful Python package that lets you create and care for a virtual pet right in your terminal. Feed your pet, play games with them, and watch their mood change based on how well you care for them. Perfect for developers who want a little companionship while coding! + +📦 **[View on PyPI](https://pypi.org/project/pypet/)** + +--- + +## 👥 Team Members + +- [Connor Lee](https://github.com/Connorlee487) +- [Lanxi](https://github.com/player1notfound) +- [Alex](https://github.com/axie22) +- 4th...? + +--- + +## 🚀 Quick Start + +### Installation + +Install pyPet from PyPI: + +```bash +pip install pypet +``` + +Or install from TestPyPI: + +```bash +pip install -i https://test.pypi.org/simple/ pypet==0.1.2 +``` + +### Basic Usage + +```python +from pypet import create_pet, feed, play, status + +# Create a new pet +pet = create_pet("Mochi", "otter", mood="happy", hunger=25, energy=85) + +# Feed your pet +feed(pet, "fish", portion=20, treat=False) + +# Play with your pet +play(pet, "fetch", energy=10, reward=True) + +# Check on your pet +print(status(pet, color=True, verbose=True, ascii_art=True)) +``` + +--- + +## 📚 Function Documentation + +### `create_pet(name: str, species: str, mood: str = "neutral", hunger: int = 50, energy: int | None = None)` + +Creates and returns a new virtual pet with the specified attributes. + +**Parameters:** +- `name` (str): The pet's display name. Must be a non-empty string after trimming. +- `species` (str): The pet's species. Must be one of: `cat`, `dog`, `otter`, `capybara`, or `duck` (case-insensitive). +- `mood` (str, optional): The pet's initial mood. Must be one of: `happy`, `neutral`, `grumpy`, `sleepy`, `hungry`, or `sad`. Defaults to `"neutral"`. +- `hunger` (int, optional): Initial hunger level (0-100, where 0 = not hungry, 100 = very hungry). Defaults to `50`. +- `energy` (int, optional): Initial energy level (0-100, where 0 = exhausted, 100 = full energy). If not provided, defaults to `70`. + +**Returns:** +- `Pet`: A dictionary representing the pet with the following keys: + - `id`: Unique identifier (UUID) + - `name`: Pet's name + - `species`: Pet's species + - `mood`: Current mood + - `hunger`: Current hunger level (0-100) + - `energy`: Current energy level (0-100) + - `happiness`: Current happiness level (0-100, automatically calculated) + - `created_at`: Timestamp of creation + - `last_interaction_at`: Timestamp of last interaction + - `ascii_key`: Key for ASCII art representation + +**Raises:** +- `ValueError`: If name, species, or mood are invalid types or contain unsupported values. + +**Example:** +```python +from pypet import create_pet + +pet = create_pet("Fluffy", "cat", mood="happy", hunger=30, energy=90) +print(f"Created {pet['name']} the {pet['species']}") +``` + +--- + +### `feed(pet: dict, food: str, portion: int, treat: bool)` + +Feeds your pet, reducing their hunger and potentially improving their mood. The function also simulates time passing (30 minutes) and updates the pet's mood based on their new hunger and happiness levels. + +**Parameters:** +- `pet` (dict): A pet dictionary created by `create_pet()`. +- `food` (str): Type of food to give. Supported foods include: `kibble`, `fish`, `carrot`, `steak`, `cookie`. Each food has different hunger-reduction effects. +- `portion` (int): Portion size (0-100). Larger portions reduce more hunger. +- `treat` (bool): Whether the food is a special treat. If `True`, provides an additional hunger reduction and happiness boost. + +**Returns:** +- `dict`: The updated pet dictionary with modified hunger, happiness, mood, and `last_interaction_at` timestamp. + +**Raises:** +- `ValueError`: If pet is not a valid dictionary, food is not a string, or portion is not an integer between 0 and 100. + +**Example:** +```python +from pypet import create_pet, feed + +pet = create_pet("Buddy", "dog", hunger=80) +pet = feed(pet, "steak", portion=25, treat=True) +print(f"Hunger after feeding: {pet['hunger']}") +``` + +--- + +### `play(pet: dict, game: str, energy: int, reward: bool)` + +Plays a game with your pet, which increases happiness but consumes energy. Different games may provide bonus happiness for certain species (e.g., dogs love "fetch", cats enjoy "laser" or "string" games). + +**Parameters:** +- `pet` (dict): A pet dictionary created by `create_pet()`. +- `game` (str): Name of the game to play (e.g., `"fetch"`, `"tug"`, `"chase"`, `"hide-n-seek"`, `"laser"`, `"string"`, `"swim"`, `"relax"`). +- `energy` (int): Amount of energy to consume (must be a positive integer). If the pet doesn't have enough energy, they won't be able to play fully and may receive a small happiness penalty. +- `reward` (bool): Whether to give a reward after playing. If `True`, provides an additional happiness boost. + +**Returns:** +- `dict`: The updated pet dictionary with modified energy, happiness, mood, and `last_interaction_at` timestamp. + +**Raises:** +- `ValueError`: If energy is not a positive integer. + +**Example:** +```python +from pypet import create_pet, play + +pet = create_pet("Max", "dog", energy=50) +pet = play(pet, "fetch", energy=15, reward=True) +print(f"Energy after playing: {pet['energy']}, Happiness: {pet['happiness']}") +``` + +--- + +### `status(pet: dict, color: bool = False, verbose: bool = False, ascii_art: bool = False)` + +Returns a formatted string displaying your pet's current status and mood. + +**Parameters:** +- `pet` (dict): A pet dictionary created by `create_pet()`. +- `color` (bool, optional): If `True`, the mood will be displayed in color (green for happy, yellow for neutral, red for grumpy, blue for sleepy, magenta for hungry, gray for sad). Defaults to `False`. +- `verbose` (bool, optional): If `True`, displays detailed information including all pet attributes (id, version, hunger, energy, happiness, timestamps, etc.). Defaults to `False`. +- `ascii_art` (bool, optional): If `True`, displays ASCII art representing the pet's species. Defaults to `False`. + +**Returns:** +- `str`: A formatted string showing the pet's status. + +**Raises:** +- `ValueError`: If pet is not a dictionary. + +**Example:** +```python +from pypet import create_pet, status + +pet = create_pet("Mochi", "otter", mood="happy") +print(status(pet, color=True, verbose=True, ascii_art=True)) +``` + +--- + +## 💡 Example Program + +We've included an interactive demo program that showcases all the functions. After installing pyPet, you can run it with: + +```bash +pypet-demo +``` + +Or run it directly: + +```bash +python -m pypet.examples.demo +``` + +The demo program allows you to: +- Create a custom pet with your choice of name, species, and starting mood +- Feed your pet with different foods +- Play various games with your pet +- Check your pet's status with colorful, detailed output +- Rename your pet or change their mood + +**View the full example code:** [`src/pypet/examples/demo.py`](src/pypet/examples/demo.py) + +--- + +## 🛠️ For Contributors + +Interested in contributing to pyPet? Here's how to set up your development environment: + +### Prerequisites + +- Python 3.8 or higher +- pipenv (for dependency management) + +### Setup Steps + +1. **Clone the repository:** + ```bash + git clone https://github.com/swe-students-fall2025/3-python-package-team_pioneer.git + cd 3-python-package-team_pioneer + ``` + +2. **Set up a virtual environment and install dependencies:** + ```bash + pipenv install --dev + ``` + +3. **Install the package in editable mode:** + ```bash + pipenv run pip install -e . + ``` + +4. **Run the tests:** + ```bash + pipenv run pytest -q + ``` + + To run tests with more verbose output: + ```bash + pipenv run pytest -v + ``` + +5. **Build the package:** + ```bash + pipenv run python -m build + ``` + + This creates distribution files in the `dist/` directory. + +6. **Test the build locally:** + ```bash + pipenv run pip install dist/pypet-*.whl + ``` + +### Running Tests + +The test suite uses pytest and includes tests for all core functions. Run all tests with: + +```bash +pipenv run pytest -q +``` + +Or run specific test files: + +```bash +pipenv run pytest tests/test_create_pet.py +pipenv run pytest tests/test_feed_helper.py +pipenv run pytest tests/test_play.py +pipenv run pytest tests/test_status.py +``` + +### Project Structure + +``` +3-python-package-team_pioneer/ +├── src/ +│ └── pypet/ +│ ├── __init__.py # Package exports +│ ├── pet.py # Core pet functions +│ ├── main.py # Entry point +│ └── examples/ +│ └── demo.py # Interactive demo +├── tests/ # Test suite +│ ├── test_create_pet.py +│ ├── test_feed_helper.py +│ ├── test_play.py +│ └── test_status.py +├── pyproject.toml # Package configuration +├── Pipfile # Dependency management +└── README.md # This file +``` + +### Uploading to PyPI (for maintainers) + +To upload a new version to TestPyPI: + +```bash +pipenv run twine upload --repository testpypi dist/* +``` + +To upload to production PyPI: + +```bash +pipenv run twine upload dist/* +``` + +--- + +## 📄 License + +This project is licensed under the GNU General Public License v3 (GPLv3). See the [LICENSE](LICENSE) file for details. + +--- + +## 🔗 Links + +- **PyPI Package:** https://pypi.org/project/pypet/ +- **GitHub Repository:** https://github.com/swe-students-fall2025/3-python-package-team_pioneer +- **Issue Tracker:** https://github.com/swe-students-fall2025/3-python-package-team_pioneer/issues + +--- + +## 🙏 Acknowledgments + +Thanks for using pyPet! We hope your virtual pet brings a smile to your terminal. 🐾 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..65fba28 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[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.2" +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"] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["tests*", "examples*"] + +[project.scripts] +pypet-demo = "pypet.examples.demo:main" + 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/pypet/__init__.py b/src/pypet/__init__.py new file mode 100644 index 0000000..9534684 --- /dev/null +++ b/src/pypet/__init__.py @@ -0,0 +1,39 @@ +""" +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, + feed, + play, + status, + ALLOWED_SPECIES, + ALLOWED_MOODS, + BOUNDS, + Pet, + update_mood, + time_passes, + describe_pet, +) + +__all__ = [ + "create_pet", + "feed", + "play", + "status", + "ALLOWED_SPECIES", + "ALLOWED_MOODS", + "BOUNDS", + "Pet", + "update_mood", + "time_passes", + "describe_pet", +] + +__version__ = "0.1.2" diff --git a/src/pypet/examples/__init__.py b/src/pypet/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pypet/examples/demo.py b/src/pypet/examples/demo.py new file mode 100644 index 0000000..c304119 --- /dev/null +++ b/src/pypet/examples/demo.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Interactive pyPet demo + +Run: + pipenv run python src/demo.py +""" + +from __future__ import annotations + +import time +from typing import Optional + +from pypet import create_pet, play, status, feed, ALLOWED_SPECIES, ALLOWED_MOODS + + +def clear() -> None: + print("\033[2J\033[H", end="") + +def pause(msg: str = "Press Enter to continue...") -> None: + try: + input(msg) + except EOFError: + pass + +def choose(prompt: str, choices: list[str], default: Optional[str] = None) -> str: + """Simple numbered picker.""" + while True: + print(prompt) + for i, c in enumerate(choices, 1): + print(f" {i}. {c}") + if default: + print(f"[Enter for default: {default}]") + sel = input("> ").strip() + if not sel and default: + return default + if sel.isdigit() and 1 <= int(sel) <= len(choices): + return choices[int(sel) - 1] + print("Invalid choice. Try again.\n") + +def spinner(text: str, seconds: float = 1.2) -> None: + glyphs = "|/-\\" + end = time.time() + seconds + i = 0 + print(text, end=" ", flush=True) + while time.time() < end: + print(glyphs[i % len(glyphs)], end="\r", flush=True) + time.sleep(0.08) + i += 1 + print(" " * 20, end="\r") + +def main() -> int: + clear() + print("🐾 Welcome to pyPet\n") + + name = input("Name your pet: ").strip() or "Mochi" + species = choose("Pick a species:", sorted(ALLOWED_SPECIES), default="otter") + mood = choose("Pick a starting mood:", sorted(ALLOWED_MOODS), default="neutral") + + pet = create_pet(name=name, species=species, mood=mood, hunger=40, energy=80) + + while True: + clear() + print("=== pyPet ===\n") + print(status(pet, color=True, verbose=True, ascii_art=True)) # uses your package’s status() + print("\nActions:") + + menu = [ + ("p", "Play"), + ("f", "Feed"), + ("r", "Rename pet"), + ("m", "Change mood"), + ("s", "Show status"), + ("q", "Quit") + ] + + print(" " + " ".join(f"[{k}] {label}" for k, label in menu)) + choice = input("\n> ").strip().lower() + + if choice == "q": + print("\nBye!") + return 0 + + elif choice == "s": + print() + print(status(pet, color=True, verbose=True, ascii_art=True)) + pause() + continue + + elif choice == "r": + new_name = input("New name: ").strip() + if new_name: + pet["name"] = new_name + continue + + elif choice == "m": + new_mood = choose("New mood:", sorted(ALLOWED_MOODS), default=pet.get("mood", "neutral")) + pet["mood"] = new_mood + + pet["ascii_key"] = f"{pet['species']}:{pet['mood']}" + continue + + elif choice == "p": + game = choose("Game:", ["fetch", "tug", "chase", "hide-n-seek"], default="fetch") + try: + energy = int(input("How energetic is the play? (1–30, default 10): ").strip() or "10") + except ValueError: + energy = 10 + reward = input("Give a reward after? [y/N]: ").strip().lower().startswith("y") + spinner("Playing...") + play(pet, game, energy, reward) + print("Done!") + pause() + continue + + elif choice == "f": + food = choose("Food:", ["kibble", "fish", "berries", "treat"], default="kibble") + try: + portion = int(input("Portion size (1–30, default 8): ").strip() or "8") + except ValueError: + portion = 8 + treat = input("Is it a special treat? [y/N]: ").strip().lower().startswith("y") + spinner("Feeding...") + feed(pet, food, portion, treat) + print("Yum!") + pause() + continue + + else: + print("Unknown option.") + time.sleep(0.6) + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("\nInterrupted. See you next time! 🐾") + raise SystemExit(130) + 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..20394df --- /dev/null +++ b/src/pypet/pet.py @@ -0,0 +1,359 @@ +from __future__ import annotations +from datetime import datetime +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" + + pet["energy"] = new_energy + pet["happiness"] = new_happiness + pet["mood"] = new_mood + pet['ascii_key'] = f"{species}:{new_mood}" + pet["last_interaction_at"] = time() + + return 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." + ) 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..b1e4984 --- /dev/null +++ b/tests/test_play.py @@ -0,0 +1,203 @@ +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 + + +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_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