diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dd3b1da --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,64 @@ +name: bloomsay ci/cd + +on: + pull_request: + branches: [pipfile-experiment] + push: + branches: [pipfile-experiment] + tags: + - 'v*.*.*' + +jobs: + build-and-test: + name: build and test + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: check out repo + uses: actions/checkout@v4 + + - name: python ${{ matrix.python-version }} setup + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + + - name: install dependencies + run: | + python -m pip install pipenv + pipenv install --dev + + - name: build package + run: pipenv run python -m build + + - name: run pytests + run: pipenv run pytest + + - name: store distribution + uses: actions/upload-artifact@v4 + with: + name: python-package-dist-${{ matrix.python-version }} + path: dist/ + + publish-to-pypi: + name: publish to PyPi + needs: build-and-test + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + + permissions: + id-token: write + + steps: + - name: download dist files + uses: actions/download-artifact@v4 + with: + name: python-package-dist-3.12 + path: dist/ + + - name: publish bloomsay to PyPi + uses: pypa/gh-action-pypi-publish@release/v1 + diff --git a/.github/workflows/event-logger.yml b/.github/workflows/event-logger.yml index 31f231e..01c9a2b 100644 --- a/.github/workflows/event-logger.yml +++ b/.github/workflows/event-logger.yml @@ -35,20 +35,32 @@ jobs: - name: Log pull request opened if: github.event_name == 'pull_request' && github.event.action == 'opened' run: | + if [ -z "$COMMIT_LOG_API" ]; then + echo "COMMIT_LOG_API is not set; skipping logging step."; exit 0 + fi 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 - name: Log pull request closed and merged if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true run: | + if [ -z "$COMMIT_LOG_API" ]; then + echo "COMMIT_LOG_API is not set; skipping logging step."; exit 0 + fi 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 - name: Log pull request closed without merge if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == false run: | + if [ -z "$COMMIT_LOG_API" ]; then + echo "COMMIT_LOG_API is not set; skipping logging step."; exit 0 + fi 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 - name: Log push if: github.event_name == 'push' run: | + if [ -z "$COMMIT_LOG_API" ]; then + echo "COMMIT_LOG_API is not set; skipping logging step."; exit 0 + fi 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 diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..e1b5690 --- /dev/null +++ b/Pipfile @@ -0,0 +1,17 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +bloomsays = {file = ".", editable = true} + +[dev-packages] +build = "*" +pytest = "*" +coverage = "*" +pytest-cov = "*" +twine = "*" + +[requires] +python_version = "3" \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..2359d3c --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,93 @@ +{ + "_meta": { + "hash": { + "sha256": "168f311aa2eb245a1a229f50b4976607441b56ec9cf6555d67e833aaed3a955a" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "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" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + } + }, + "develop": { + "bloomsays": { + "editable": true, + "markers": "python_version >= '3.7'", + "path": "." + }, + "build": { + "hashes": [ + "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", + "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.3.0" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", + "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + } + } +} diff --git a/README.md b/README.md index 6022e0e..dc1520e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,402 @@ -# Python Package Exercise +# Bloomsays + +## What is Bloomsays? +Bloomsays is a fun python package with some of our favorite lines from Professor Bloomberg. + +## Instructions +pip install -e . + +run python: python + +## Usage +Ater installation, you can import and call the functions from the package +```python +from bloomsays.wisdom import avg, random_quote, coding_wisdom, study_tip, jokes + +#Get your average grade +avg(90, 80, 100) + +#Get a random quote from Professor Bloomberg! +random_quote() + +#Get some coding wisdom with your specified language +coding_wisdom("Python") + +# Get personalized study tips +wisdom.study_tip(hours_available=3, difficulty="hard") + +# Enjoy some programming humor +wisdom.jokes(2) + +``` + +## Functions +### `avg(*grades)` + +Calculate the average of your grades and display it with Professor Bloomberg's majestic ASCII art. + +**Parameters:** +- `*grades` (float): Variable number of grade values (integers or floats) + +**Returns:** +- `float`: The calculated average + +**Raises:** +- `ValueError`: If no grades are provided + +**Example:** +```python +from bloomsays import wisdom + +# Calculate average of multiple grades +result = wisdom.avg(90, 85, 95, 78, 92) +# Output: Displays "Your average grade is 88.00" in a speech bubble with Bloomberg ASCII art +# Returns: 88.0 + +# Works with decimals too +result = wisdom.avg(89.5, 92.3, 87.8) +# Returns: 89.87 +``` + +--- + +### `random_quote(n=1)` + +Display random inspirational (and occasionally intimidating) quotes from Professor Bloomberg's legendary syllabus and course communications. + +**Parameters:** +- `n` (int, optional): Number of quotes to display. Default is 1. Must be at least 1. + +**Returns:** +- `list`: List of the selected quote strings + +**Raises:** +- `ValueError`: If n is less than 1 + +**Example:** +```python +from bloomsays import wisdom + +# Get a single quote (default) +quotes = wisdom.random_quote() +# Output: One random Bloomberg quote with ASCII art +# Returns: ['Ask Bloombot!'] + +# Get multiple quotes at once +quotes = wisdom.random_quote(3) +# Output: Three random quotes in a single bubble with ASCII art +# Returns: ['everything is due at class time', 'Quizzes: 25%', 'Discord is our main source of communication'] +``` + +**Available Quotes:** +- "Everything is due at class time" +- "Ask Bloombot!" +- "Quizzes: 25%" +- "Exercises & Projects: 75%" +- "Discord is our main source of communication" +- "Read the instructions carefully" +- "Test your code before submitting" +- "Git commit early and often" +- "Merge conflicts are a learning opportunity" +- "The documentation is your friend" + +--- + +### `coding_wisdom(language="Python")` + +Receive programming wisdom from Professor Bloomberg tailored to your specific language. Because different languages have different philosophies! + +**Parameters:** +- `language` (str, optional): Programming language name. Default is "Python". + - Supported languages: "Python", "JavaScript", "Java", "C++" + - Any other language uses general programming wisdom + +**Returns:** +- `str`: The wisdom message (without the language prefix) + +**Example:** +```python +from bloomsays import wisdom + +# Get Python wisdom (default) +tip = wisdom.coding_wisdom() +# Output: "Python wisdom: Remember: readability counts!" with ASCII art +# Returns: "Remember: readability counts!" + +# Get JavaScript-specific wisdom +tip = wisdom.coding_wisdom("JavaScript") +# Output: "JavaScript wisdom: Always use const and let, never var" with ASCII art +# Returns: "Always use const and let, never var" + +# Get wisdom for any language +tip = wisdom.coding_wisdom("Rust") +# Output: "Rust wisdom: Write clean, readable code" with ASCII art +# Returns: "Write clean, readable code" +``` + +**Wisdom by Language:** +- **Python**: Focus on readability, list comprehensions, virtual environments, PEP 8, pytest +- **JavaScript**: Async/await, const/let, arrow functions, npm, console.log usage +- **Java**: OOP design, exceptions, interfaces, JVM, unit tests +- **C++**: Memory management, RAII, smart pointers, STL, compile warnings +- **Default**: General best practices for any language + +--- + +### `study_tip(hours_available=2, difficulty="medium")` + +Get personalized study advice from Professor Bloomberg based on your available time and the difficulty of your material. The advice adapts to your situation! + +**Parameters:** +- `hours_available` (float, optional): Number of hours you have to study. Default is 2. Must be non-negative. +- `difficulty` (str, optional): Difficulty level of the material. Options: "easy", "medium", or "hard". Default is "medium". Case-insensitive. + +**Returns:** +- `str`: A personalized study tip (base tip without the time advice) + +**Raises:** +- `ValueError`: If hours_available is negative + +**Example:** +```python +from bloomsays import wisdom + +# Default: 2 hours, medium difficulty +tip = wisdom.study_tip() +# Output: "You have decent time. Use it wisely!\nReview your notes thoroughly" with ASCII art +# Returns: "Review your notes thoroughly" + +# Cramming for hard material with little time +tip = wisdom.study_tip(hours_available=0.5, difficulty="hard") +# Output: "Time is tight! Focus on the most important concepts.\nSeek help during office hours" with ASCII art +# Returns: "Seek help during office hours" + +# Plenty of time for easy material +tip = wisdom.study_tip(hours_available=5, difficulty="easy") +# Output: "Great! You have plenty of time to master this.\nQuick review session should do it!" with ASCII art +# Returns: "Quick review session should do it!" + +# Case doesn't matter +tip = wisdom.study_tip(3, "HARD") +# Works the same as difficulty="hard" +``` + +**Time-Based Advice:** +- **< 1 hour**: "Time is tight! Focus on the most important concepts." +- **1-3 hours**: "You have decent time. Use it wisely!" +- **3+ hours**: "Great! You have plenty of time to master this." + +**Difficulty-Based Tips:** +- **Easy**: Quick reviews, key concepts, basic understanding +- **Medium**: Breaking into chunks, practice problems, explaining to others +- **Hard**: Starting early, multiple examples, office hours, study groups + +--- + +### `jokes(n=1)` + +Get random programming jokes to lighten the mood during those long debugging sessions. Laughter is the best debugger! + +**Parameters:** +- `n` (int, optional): Number of jokes to display. Default is 1. + +**Returns:** +- `list`: List of the selected joke strings + +**Example:** +```python +from bloomsays import wisdom + +# Get a single joke (default) +joke_list = wisdom.jokes() +# Output: One random programming joke with ASCII art +# Returns: ["I'd tell them a UDP joke but there's no guarantee that they would get it."] + +# Get multiple jokes +joke_list = wisdom.jokes(3) +# Output: Three random jokes in a single bubble with ASCII art +# Returns: ["!false -> It's funny 'cause it's true.", "Which body part does a programmer know best? -> ARM", ...] +``` + +**Sample Jokes:** +- "I was about to crack a joke on Ubuntu's text editor, but you might not gedit." +- "I'd tell them a UDP joke but there's no guarantee that they would get it." +- "When I wrote this, only God and I understood what I was doing. Now, God only knows." +- "#define TRUE FALSE //Happy debugging suckers" +- "Which body part does a programmer know best? -> ARM" +- "What do you call a busy waiter? -> A server." +- "!false -> It's funny 'cause it's true." + +--- + +## Example Program + +Want to see all functions in action? Check out our [example.py](https://github.com/swe-students-fall2025/3-python-package-team_orchid/blob/main/example.py) file! + +--- + +## Example Output + +``` + ______________ + | ask Bloombot | + ============== + \ + \ + + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@&%%%##(##&@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%#%%%%%%%%%#######%@&@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%&%%%###%%####(#%&&&%%%%&&@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@%&&&&&#((///((((////**////(#&&&&&&%@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@&&&&&&%#(////***************////(#@@&&&@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@&&&@&#(///***************,*****///(&@&&&@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@&&&&&#(///********,,,,,,,,,,,****///(&&&&&@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@&&&&%(////*****,*,*,,,,,,,,,,,,****//#&&@&@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&&&&&%(////*******,,,,,,,,,,********//(&&&&&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&@&&&%////**********,,,,,,,,,,******//(%&&&&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&@@&&%(///*******,,,,,,,,,,,,,,******//#&@&&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&&@@&%(///(#(####(/****,**//(%%%%##(///(&&&@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@&&&&(//%###%##%%###(/***/(((%&&&&%###((&@&//@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@(((#&&(//(#%#(*##,/(/(/***///(**#*/(((///&%#((/@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@/(//&&(//***//*////////****/*****/******/#(**/*@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@/(/(%&(//***/*******//**,,*/*******,,**//##(*/@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@/(#(&(///*********///*,,,,*//*********//%%(*#@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@///&%(//********/////****///*******///(&%//&@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@(/&&##((///**//((#&##%####(//***//(###&#/&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@(&&&&%#(/(%%###%%%##%##%%%((##(((##%&&&@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@&&&&%##%&%&&%###%##((###%%%%#%%%%%&&&@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@&&&&&&%&%%#//(////////////%&&&#&&&&@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@(%&@&&&&%#((((###%##((((((%&&&&@&%@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@,#%/#&&&&&&&&%##%%#%%%##(#%&&%&&@&@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@..(&//(&&&&&&&&&%%%%#%%(%%&&@&&@%(@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@....,///(%&&&&@&&&&%%%%%&&&&@@@&(/..@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@.......////((#%&&&&&&@&&&@&@&&@%(//*,../@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@............//(((((((##&&@@@@@&&#/////#,,......(@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@%.......... ...*///////(((((((//////*//&(.......... *@@@@@@@@@@@@ + @@@@@@@@@%. ..,..//**///////////****//%/*. ............ .&@@@@@@ + +``` + + +## 🛠️ For Contributors + +Want to contribute to bloomsays? We'd love your help! Here's how to set up your development environment: + +### Prerequisites + +- Python 3.11 or higher (3.11, 3.12, 3.13) +- pip and pipenv + +### Setup Instructions + +**1. Clone the repository:** +```bash +git clone https://github.com/swe-students-fall2025/3-python-package-team_orchid.git +cd 3-python-package-team_orchid +``` + +**2. Install pipenv (if you don't have it):** +```bash +pip install pipenv +``` + +**3. Install dependencies and create virtual environment:** +```bash +pipenv install --dev +``` + +This creates a virtual environment and installs all necessary packages including pytest. + +**4. Activate the virtual environment:** +```bash +pipenv shell +``` + +Your terminal prompt will change to show you're in the virtual environment: `(3-python-package-team_orchid)` + +**5. Install the package in editable mode:** +```bash +pip install -e . +``` + +This allows you to make changes to the code and test them immediately without reinstalling. + +### Running Tests + +Run the complete test suite with verbose output: +```bash +pytest tests/ -v +``` + +Run tests with coverage report: +```bash +pytest tests/ --cov=bloomsays --cov-report=html +``` + +Run specific test files: +```bash +# Test only the wisdom functions +pytest tests/test_wisdom.py -v + +# Test only the bubble functions +pytest tests/test_bubble.py -v +``` + +Run a specific test: +```bash +pytest tests/test_wisdom.py::Tests::test_avg_simple -v +``` + +### Testing Your Changes + +After making code changes, test them: +```bash +# 1. Run all tests +pytest tests/ -v + +# 2. Try the functions in Python +python -c "from bloomsays import wisdom; wisdom.random_quote()" + +# 3. Test the CLI (if applicable) +python -m bloomsays +``` + +### Building the Package + +Build distribution files locally: +```bash +# Install build tools (if not already installed) +pipenv install build twine --dev + +# Build the package +python -m build +``` + +This creates distribution files in the `dist/` directory: +- `bloomsays-0.1.0.tar.gz` (source distribution) +- `bloomsays-0.1.0-py3-none-any.whl` (wheel distribution) + +### Testing the Built Package + +Install and test your local build: +```bash +# Install from the wheel file +pip install dist/bloomsays-0.1.0-py3-none-any.whl + +# Test it +python -c "from bloomsays import wisdom; wisdom.jokes()" + +# Uninstall when done testing +pip uninstall bloomsays +``` + +## Contributors +- Luna Suzuki - [github](https://github.com/lunasuzuki) +- Kazi Hossain - [github](https://github.com/kazisean) +- Tawhid Zaman - [github](https://github.com/TawhidZGit) +- Jack Chen - [github](https://github.com/a247686991) +- Howard Appel - [github](https://github.com/hna2019) + -An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. diff --git a/TODO b/TODO new file mode 100644 index 0000000..ec5e02c --- /dev/null +++ b/TODO @@ -0,0 +1,21 @@ +[x] At least four functions that accept arguments which influence their behavior +[x] The package must be distributed in the PyPI repository and installable via pip. +[x] Use pipenv to manage the package dependencies and virtual environments with a Pipfile. +[x] Use pytest to test this should be no fewer than three tests per package function. +[x] Use build to create the package artifacts. +[x] Use twine to upload the package to PyPI. +[x] Use GitHub Actions to build your package and run your tests on two different recent versions of Python with every pull request to the main branch of your GitHub repository. +[] delete the feature branch +[] Create an example program that uses all functions of your package and demonstrates its complete functionality. +[] beautiful read-me file on + [] description + [] clear instructions + [] code examples + [] include doc for all the functions and how to use + import + [] how to contribute + build + test + run + [] Include a badge at the top of the README.md file showing the result of the latest build/test workflow run. + [] Include the names of all teammates as links to their GitHub profiles in the README.md file. + [] Include a link to your package's page on the PyPI website. + + +random other notes please push to cheese shop using twine before final submission \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b5cc2de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bloomsays" +description = "A package that gives quotes from the professor" +version = "0.2.1" +authors = [ + { name="Howard Appel", email="hna2019@nyu.edu" }, + { name="Kazi Hossain", email="keh8423@nyu.edu" }, + { name="Luna Suzuki", email="las9963@nyu.edu" }, + { name="Tawhid Zaman", email="tnz8738@nyu.edu" }, + { name="Jack Chen", email="jc11462@nyu.edu"} +] +license = { file = "LICENSE" } +readme = "README.md" +keywords = ["python", "package", "build", "lighthearted"] +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "Intended Audience :: Education", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", +] + +[project.optional-dependencies] +dev = ["pytest"] + +[project.urls] +Homepage = "https://github.com/swe-students-fall2025/3-python-package-team_orchid" +Repository = "https://github.com/swe-students-fall2025/3-python-package-team_orchid.git" + + +[project.scripts] +bloomsays = "bloomsays.__main__:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + diff --git a/src/bloomsays/__init__.py b/src/bloomsays/__init__.py new file mode 100644 index 0000000..1c2be9c --- /dev/null +++ b/src/bloomsays/__init__.py @@ -0,0 +1,8 @@ +from .bubble import wrap_text, make_bubble + +from . import wisdom +__all__ = [ + "wrap_text", + "make_bubble", + "wisdom", +] diff --git a/src/bloomsays/__main__.py b/src/bloomsays/__main__.py new file mode 100644 index 0000000..6c42b6f --- /dev/null +++ b/src/bloomsays/__main__.py @@ -0,0 +1,47 @@ +import sys +from bloomsays import wisdom + +def main(): + args = sys.argv[1:] + if not args: + print(f"Usage: \n bloomsays randomQuote [n] \n bloomsays joke [n] \n bloomsays codingWisdom [Python/JavaScript/Java/C++] \n bloomsays avg num1 num2 ...") + return + + command = args[0] + + if command == "randomQuote": + n = int(args[1]) if len(args) > 1 else 1 + wisdom.random_quote(n) + elif command == 'joke': + n = int(args[1]) if len(args) > 1 else 1 + wisdom.jokes(n) + elif command == "avg": + if len(args) < 2: + print("Usage: bloomsays avg num1 num2 ...") + return + try: + numbers = [float(x) for x in args[1:]] + except ValueError: + print("All arguments for avg must be numbers.") + return + wisdom.avg(*numbers) + elif command == "codingWisdom": + language = args[1] if len(args) > 1 else "Python" + wisdom.coding_wisdom(language) + elif command == "studyTip": + if len(args) < 3: + print("Usage: bloomsays studyTip numQuestions difficulty") + return + try: + num_questions = int(args[1]) + difficulty = args[2] + except ValueError: + print("numQuestions must be an integer.") + return + wisdom.study_tip(num_questions, difficulty) + else: + print(f"Unknown command: {command}") + print("Usage: bloomsays randomQuote [n] | bloomsays avg num1 num2 ...") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/bloomsays/bubble.py b/src/bloomsays/bubble.py new file mode 100644 index 0000000..0826c7d --- /dev/null +++ b/src/bloomsays/bubble.py @@ -0,0 +1,66 @@ +from __future__ import annotations +from typing import List + + +def wrap_text(text: str, width: int) -> List[str]: + """ + Pure text wrapper (no printing). + - Splits on whitespace. + - If a single word is longer than width, it is put on its own line (no hyphenation). + """ + if width is None or width < 1: + raise ValueError("width must be >= 1") + + words = text.split() + if not words: + return [""] + + lines: List[str] = [] + cur = words[0] + for w in words[1:]: + if len(cur) + 1 + len(w) <= width: + cur += " " + w + else: + lines.append(cur) + cur = w + lines.append(cur) + return lines + + +def make_bubble(text: str, width: int | None = None) -> str: + """ + Build a speech bubble as a single string. + - If width is provided, wrap the text to that width. + - Supports multi-line input already containing '\n' (each line is treated as a paragraph). + """ + if text is None: + raise ValueError("text must be a string") + + # split paragraphs first + paragraphs = text.split("\n") + if width is not None: + if width < 1: + raise ValueError("width must be >= 1") + lines = [] + for p in paragraphs: + if p.strip() == "": + lines.append("") # preserve empty line + else: + lines.extend(wrap_text(p, width)) + else: + lines = paragraphs + + # compute max visible width + maxw = max((len(line) for line in lines), default=0) + + top = " " + "_" * (maxw + 2) + body = "\n".join(f"| {line.ljust(maxw)} |" for line in lines) + bottom = " " + "=" * (maxw + 2) + tail = " \\\n \\" + + return f"{top}\n {body}\n{bottom}\n{tail}" + +#test - run: python3 -m bloomsays.bubble +if __name__ == "__main__": + print(make_bubble("Ask Bloombot!")) + diff --git a/src/bloomsays/wisdom.py b/src/bloomsays/wisdom.py new file mode 100644 index 0000000..59b86eb --- /dev/null +++ b/src/bloomsays/wisdom.py @@ -0,0 +1,179 @@ +import random +import textwrap +from pathlib import Path +from .bubble import make_bubble + +allJokes = [ + "I was about to crack a joke on Ubuntu’s text editor, but you might not gedit.", + "I’d tell them a UDP joke but there’s no guarantee that they would get it.", + "When I wrote this, only God and I understood what I was doing. Now, God only knows.", + "#define TRUE FALSE //Happy debugging suckers", + "Which body part does a programmer know best? -> ARM", + "What do you call a busy waiter? -> A server.", + "What do you call an idle server? -> A waiter", + "!false -> It's funny 'cause it's true." + ] +# source : https://zriyansh.medium.com/top-programming-jokes-that-will-make-your-day-or-night-6d986b338f2d +# https://github.com/wesbos/dad-jokes + + +ascii_art = r""" + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@&%%%##(##&@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%#%%%%%%%%%#######%@&@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%&%%%###%%####(#%&&&%%%%&&@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@%&&&&&#((///((((////**////(#&&&&&&%@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@&&&&&&%#(////***************////(#@@&&&@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@&&&@&#(///***************,*****///(&@&&&@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@&&&&&#(///********,,,,,,,,,,,****///(&&&&&@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@&&&&%(////*****,*,*,,,,,,,,,,,,****//#&&@&@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&&&&&%(////*******,,,,,,,,,,********//(&&&&&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&@&&&%////**********,,,,,,,,,,******//(%&&&&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&@@&&%(///*******,,,,,,,,,,,,,,******//#&@&&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@&&@@&%(///(#(####(/****,**//(%%%%##(///(&&&@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@&&&&(//%###%##%%###(/***/(((%&&&&%###((&@&//@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@(((#&&(//(#%#(*##,/(/(/***///(**#*/(((///&%#((/@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@/(//&&(//***//*////////****/*****/******/#(**/*@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@/(/(%&(//***/*******//**,,*/*******,,**//##(*/@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@/(#(&(///*********///*,,,,*//*********//%%(*#@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@///&%(//********/////****///*******///(&%//&@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@(/&&##((///**//((#&##%####(//***//(###&#/&@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@(&&&&%#(/(%%###%%%##%##%%%((##(((##%&&&@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@&&&&%##%&%&&%###%##((###%%%%#%%%%%&&&@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@&&&&&&%&%%#//(////////////%&&&#&&&&@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@(%&@&&&&%#((((###%##((((((%&&&&@&%@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@,#%/#&&&&&&&&%##%%#%%%##(#%&&%&&@&@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@..(&//(&&&&&&&&&%%%%#%%(%%&&@&&@%(@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@....,///(%&&&&@&&&&%%%%%&&&&@@@&(/..@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@.......////((#%&&&&&&@&&&@&@&&@%(//*,../@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@............//(((((((##&&@@@@@&&#/////#,,......(@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@%.......... ...*///////(((((((//////*//&(.......... *@@@@@@@@@@@@ + @@@@@@@@@%. ..,..//**///////////****//%/*. ............ .&@@@@@@ + """ + + +def avg(*grades): + + average = sum(grades)/ len(grades) + message = f"Your average grade is {average:.2f}" + + bubble = make_bubble(message) + print(f"{bubble}\n{ascii_art}") + + return average + +def random_quote(n=1): + profLines = ["everything is due at class time", "ask Bloombot", "Quizzes: 25%", "Exercises & Projects: 75%", "Discord is our main source of communitcation"] + selected_quotes = random.choices(profLines, k=n) + + bubble_text = "\n".join(selected_quotes) + + bubble = make_bubble(bubble_text) + print(f"{bubble}\n{ascii_art}") + + return selected_quotes + +def coding_wisdom(language="Python"): + wisdom_dict = { + "Python": [ + "Remember: readability counts!", + "Use list comprehensions wisely", + "Virtual environments are your friend", + "PEP 8 is the style guide to follow", + "Test your code with pytest" + ], + "JavaScript": [ + "Async/await makes life easier", + "Always use const and let, never var", + "Arrow functions are your friend", + "npm install is just the beginning", + "Console.log is for debugging only" + ], + "Java": [ + "Object-oriented design matters", + "Exceptions should be exceptional", + "Use interfaces wisely", + "The JVM is powerful but watch memory", + "Unit tests save production bugs" + ], + "C++": [ + "Manage your memory carefully", + "RAII is your best friend", + "Smart pointers over raw pointers", + "The STL is incredibly powerful", + "Compile warnings are errors in disguise" + ], + "default": [ + "Write clean, readable code", + "Test early, test often", + "Documentation is never optional", + "Version control is essential", + "Code reviews make better developers" + ] + } + + wisdom_list = wisdom_dict.get(language, wisdom_dict["default"]) + message = random.choice(wisdom_list) + full_message = f"{language} wisdom: {message}" + + bubble = make_bubble(full_message) + print(f"{bubble}\n{ascii_art}") + + return message + +def jokes (n=1): + randomSelect = random.choices(allJokes, k=n) + + bubble_text = "\n".join(randomSelect) + + bubble = make_bubble(bubble_text) + print(f"{bubble}\n{ascii_art}") + + return randomSelect + +def study_tip(hours_available=2, difficulty="medium"): + if hours_available < 0: + raise ValueError("Hours must be non-negative") + + difficulty = difficulty.lower() + if difficulty not in ["easy", "medium", "hard"]: + difficulty = "medium" + + tips = { + "easy": [ + "Quick review session should do it!", + "Focus on the key concepts", + "Practice a few examples", + "Make sure you understand the basics" + ], + "medium": [ + "Break it into manageable chunks", + "Practice problems are essential", + "Review your notes thoroughly", + "Try explaining it to someone else" + ], + "hard": [ + "Start early, don't cram!", + "Work through multiple examples", + "Seek help during office hours", + "Form a study group if possible", + "Break down complex problems step by step" + ] + } + + base_tip = random.choice(tips[difficulty]) + + if hours_available < 1: + time_advice = "Time is tight! Focus on the most important concepts." + elif hours_available < 3: + time_advice = "You have decent time. Use it wisely!" + else: + time_advice = "Great! You have plenty of time to master this." + + message = f"{time_advice}\n{base_tip}" + + bubble = make_bubble(message) + print(f"{bubble}\n{ascii_art}") + + return base_tip diff --git a/src/example.py b/src/example.py new file mode 100644 index 0000000..dfe5d5d --- /dev/null +++ b/src/example.py @@ -0,0 +1,52 @@ +from bloomsays import wisdom + +def main(): + + # average grade function + print("\n1. Calculating the average of grades :") + wisdom.avg(85, 92, 78, 95) + + # getting a random quote function + print("\n Getting a random quote from professor :") + wisdom.random_quote() + + print("\n Getting 2 random quotes from professor :") + wisdom.random_quote(n=2) + + # getting coding wisdoms + print("\n Getting some Python coding wisdom:") + wisdom.coding_wisdom(language="Python") + + print("\n Getting some JavaScript coding wisdom:") + wisdom.coding_wisdom(language="JavaScript") + + print("\n Getting some Java coding wisdom:") + wisdom.coding_wisdom(language="Java") + + print("\n Getting some C++ coding wisdom:") + wisdom.coding_wisdom(language="C++") + + print("\n Getting some random coding wisdom:") + wisdom.coding_wisdom() + + + # getting funny jokes + print("\n Here's a funny joke:") + wisdom.jokes() + + print("\n Here are 3 random funny jokes:") + wisdom.jokes(n=3) + + # study tips + print("\n Getting a study random tip: ") + wisdom.study_tip() + + print("\n Getting a study tip for an easy task with 5 hours available:") + wisdom.study_tip(hours_available=5, difficulty="easy") + + print("\n Getting a study tip for a hard task with 1 hour available:") + wisdom.study_tip(hours_available=1, difficulty="hard") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_bubble.py b/tests/test_bubble.py new file mode 100644 index 0000000..e5a98a8 --- /dev/null +++ b/tests/test_bubble.py @@ -0,0 +1,103 @@ +import pytest + +from bloomsays.bubble import make_bubble, wrap_text + + +def _assert_bubble_matches(out: str, expected_lines: list[str]): + parts = out.splitlines() + # top + maxw = max((len(l) for l in expected_lines), default=0) + assert parts[0] == " " + "_" * (maxw + 2) + + # body + body = parts[1 : 1 + len(expected_lines)] + assert len(body) == len(expected_lines) + for idx, (got, expect) in enumerate(zip(body, expected_lines)): + # the first body line is prefixed with a single space in the output + if idx == 0: + assert got.startswith(" | ") + inner = got[3:-2] + else: + # subsequent lines start with '|' directly + assert got.startswith("| ") + inner = got[2:-2] + assert got.endswith(" |") + assert inner.strip() == expect + + # bottom + bottom = parts[1 + len(expected_lines)] + assert bottom == " " + "=" * (maxw + 2) + + # tail should be two lines that each end with a backslash + assert parts[-2].endswith("\\") + assert parts[-1].endswith("\\") + + +def test_simple_bubble_no_width(): + out = make_bubble("Hi") + _assert_bubble_matches(out, ["Hi"]) + + +def test_wrap_with_width(): + out = make_bubble("one two three", width=6) + # wrapped into three lines + _assert_bubble_matches(out, ["one", "two", "three"]) + + +def test_preserve_empty_line(): + out = make_bubble("a\n\nb") + # empty paragraph should be preserved as an empty line + _assert_bubble_matches(out, ["a", "", "b"]) + + +def test_text_none_raises(): + with pytest.raises(ValueError): + make_bubble(None) + + +# Tests for wrap_text function +def test_wrap_text_simple(): + result = wrap_text("hello world", 10) + assert result == ["hello", "world"] + + +def test_wrap_text_fits_on_one_line(): + result = wrap_text("hello", 10) + assert result == ["hello"] + + +def test_wrap_text_multiple_words_fit(): + result = wrap_text("one two three four", 15) + assert result == ["one two three", "four"] + + +def test_wrap_text_long_word_exceeds_width(): + # A single word longer than width should be on its own line + result = wrap_text("short verylongword short", 10) + assert result == ["short", "verylongword", "short"] + + +def test_wrap_text_empty_string(): + result = wrap_text("", 10) + assert result == [""] + + +def test_wrap_text_width_one(): + result = wrap_text("a b c", 1) + assert result == ["a", "b", "c"] + + +def test_wrap_text_invalid_width_zero(): + with pytest.raises(ValueError, match="width must be >= 1"): + wrap_text("text", 0) + + +def test_wrap_text_invalid_width_negative(): + with pytest.raises(ValueError, match="width must be >= 1"): + wrap_text("text", -5) + + +def test_wrap_text_invalid_width_none(): + with pytest.raises(ValueError, match="width must be >= 1"): + wrap_text("text", None) + diff --git a/tests/test_wisdom.py b/tests/test_wisdom.py new file mode 100644 index 0000000..80caa11 --- /dev/null +++ b/tests/test_wisdom.py @@ -0,0 +1,173 @@ +import pytest +from bloomsays import wisdom + +class Tests: + + def test_avg_simple(self, capsys): + wisdom.avg(97, 76, 67) + captured = capsys.readouterr() + assert "Your average grade is 80.00" in captured.out + assert "____" in captured.out + assert "@@@@" in captured.out + + def test_avg_identical_numbers(self, capsys): + wisdom.avg(67, 67, 67) + captured = capsys.readouterr() + assert "Your average grade is 67.00" in captured.out + assert "____" in captured.out + assert "@@@@" in captured.out + + def test_avg_random_floats(self, capsys): + wisdom.avg(5.5, 7.3, 8.2) + captured = capsys.readouterr() + assert "Your average grade is 7.00" in captured.out + assert "____" in captured.out + assert "@@@@" in captured.out + + def test_random_quote_runs(self, capsys): + wisdom.random_quote(3) + captured = capsys.readouterr() + assert "____" in captured.out + assert "@@@@" in captured.out + + def test_random_quote_default(self, capsys): + wisdom.random_quote() + captured = capsys.readouterr() + assert any("|" in line for line in captured.out.splitlines()) + assert "@@@@" in captured.out + + def test_random_quote_multiple_quotes_in_bubble(self, capsys): + wisdom.random_quote(2) + captured = capsys.readouterr() + lines = captured.out.splitlines() + bubble_lines = [line for line in lines if "|" in line and "Your" not in line and "wisdom" not in line and "bloomsays" not in line] + assert len(bubble_lines) >= 2 + assert "@@@@" in captured.out + + def test_coding_wisdom_default(self, capsys): + message = wisdom.coding_wisdom() + captured = capsys.readouterr() + assert isinstance(message, str) + assert "Python wisdom:" in captured.out + assert "@@@" in captured.out + + def test_coding_wisdom_javascript(self, capsys): + message = wisdom.coding_wisdom("JavaScript") + captured = capsys.readouterr() + assert isinstance(message, str) + assert "JavaScript wisdom:" in captured.out + assert "@@@" in captured.out + + def test_coding_wisdom_java(self, capsys): + message = wisdom.coding_wisdom("Java") + captured = capsys.readouterr() + assert isinstance(message, str) + assert "Java wisdom:" in captured.out + assert "@@@" in captured.out + + def test_coding_wisdom_cpp(self, capsys): + message = wisdom.coding_wisdom("C++") + captured = capsys.readouterr() + assert isinstance(message, str) + assert "C++ wisdom:" in captured.out + assert "@@@" in captured.out + + def test_coding_wisdom_unknown_language(self, capsys): + message = wisdom.coding_wisdom("COBOL") + captured = capsys.readouterr() + assert isinstance(message, str) + assert "COBOL wisdom:" in captured.out + assert "@@@" in captured.out + + def test_coding_wisdom_returns_string(self): + message = wisdom.coding_wisdom("Python") + assert isinstance(message, str) + assert len(message) > 0 + + def test_jokes_default(self, capsys): + getJoke = wisdom.jokes() + getReturn = capsys.readouterr() + + assert isinstance(getJoke, list) + assert len(getJoke) == 1 + assert getJoke[0] in wisdom.allJokes + assert "____" in getReturn.out + assert "@@@@" in getReturn.out + + def test_joke_true_value(self): + numJokes = 2 + output = wisdom.jokes(n=2) + + assert isinstance(output, list) + assert len(output) == numJokes + assert all(isinstance(i, str) for i in output) + + def test_jokes_multiple (self, capsys): + numJokes = 3 + getJokes = wisdom.jokes(n=numJokes) + getReturn = capsys.readouterr() + + assert isinstance(getJokes, list) + assert len(getJokes) == numJokes + for joke in getJokes: + assert joke in wisdom.allJokes + + assert "____" in getReturn.out + assert "@@@@" in getReturn.out + + numJokesLine = [line for line in getReturn.out.splitlines() if "|" in line] + assert len(numJokesLine) >= numJokes + + def test_study_tip_default(self, capsys): + tip = wisdom.study_tip() + captured = capsys.readouterr() + assert isinstance(tip, str) + assert "@@@" in captured.out + assert "|" in captured.out + + def test_study_tip_easy(self, capsys): + tip = wisdom.study_tip(2, "easy") + captured = capsys.readouterr() + assert isinstance(tip, str) + assert "@@@" in captured.out + + def test_study_tip_medium(self, capsys): + tip = wisdom.study_tip(3, "medium") + captured = capsys.readouterr() + assert isinstance(tip, str) + assert "@@@" in captured.out + + def test_study_tip_hard(self, capsys): + tip = wisdom.study_tip(5, "hard") + captured = capsys.readouterr() + assert isinstance(tip, str) + assert "@@@" in captured.out + + def test_study_tip_short_time(self, capsys): + tip = wisdom.study_tip(0.5, "hard") + captured = capsys.readouterr() + assert "Time is tight" in captured.out + + def test_study_tip_long_time(self, capsys): + tip = wisdom.study_tip(10, "easy") + captured = capsys.readouterr() + assert "plenty of time" in captured.out + + def test_study_tip_negative_hours(self): + with pytest.raises(ValueError, match="Hours must be non-negative"): + wisdom.study_tip(-1, "medium") + + def test_study_tip_invalid_difficulty(self, capsys): + tip = wisdom.study_tip(2, "impossible") + captured = capsys.readouterr() + assert isinstance(tip, str) + assert "@@@" in captured.out + + def test_study_tip_case_insensitive(self, capsys): + tip = wisdom.study_tip(2, "HARD") + captured = capsys.readouterr() + assert isinstance(tip, str) + assert "@@@" in captured.out + + +