From 38b0837d7699a3042d9c556d635660e19f69fe65 Mon Sep 17 00:00:00 2001 From: kaneryu Date: Tue, 14 Oct 2025 12:08:05 -0500 Subject: [PATCH 01/10] test: add tests also move everything into pyproject.toml (closes: 249) --- .github/workflows/publish-to-pypi.yml | 2 +- .github/workflows/test.yml | 33 +++ .vscode/settings.json | 4 +- DEVELOPMENT.md | 316 ++++++++++++++++++++++++ README.md | 6 + pyproject.toml | 97 +++++++- setup.cfg | 14 +- setup.py | 53 +--- tests/README.md | 170 +++++++++++++ tests/__init__.py | 1 + tests/conftest.py | 111 +++++++++ tests/test_baseclient.py | 341 ++++++++++++++++++++++++++ tests/test_exceptions.py | 84 +++++++ tests/test_payloads.py | 198 +++++++++++++++ tests/test_presence.py | 210 ++++++++++++++++ tests/test_types.py | 70 ++++++ tests/test_utils.py | 160 ++++++++++++ 17 files changed, 1811 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 DEVELOPMENT.md create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_baseclient.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_payloads.py create mode 100644 tests/test_presence.py create mode 100644 tests/test_types.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 4f45137..1a4f0e4 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -28,7 +28,7 @@ jobs: path: dist/ pypi-publish: - name: Build and publish + name: Publish runs-on: ubuntu-latest needs: deploy environment: release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b2833c9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-asyncio + + - name: Run tests + run: pytest -v diff --git a/.vscode/settings.json b/.vscode/settings.json index e0f66e3..44175b1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,8 @@ "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true - } + }, + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..06f7a11 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,316 @@ +# Development Guide + +This guide covers everything you need to know to contribute to pypresence. + +## Setting Up Development Environment + +1. **Clone the repository** + ```bash + git clone https://github.com/qwertyquerty/pypresence.git + cd pypresence + ``` + +2. **Create a virtual environment** + ```bash + python -m venv .venv + + # On Windows + .venv\Scripts\activate + + # On macOS/Linux + source .venv/bin/activate + ``` + +3. **Install in editable mode with development dependencies** + ```bash + pip install -e ".[dev]" + ``` + + This installs pypresence in editable mode along with all development tools: + - `pytest` - Testing framework + - `pytest-asyncio` - Async test support + - `pytest-mock` - Mocking utilities + - `pytest-cov` - Coverage reporting + - `black` - Code formatter + - `flake8` - Linter + - `mypy` - Type checker + - `isort` - Import sorter + - `sphinx` - Documentation generator + +## Running Tests + +Run all tests: +```bash +pytest +``` + +Run tests with verbose output: +```bash +pytest -v +``` + +Run specific test file: +```bash +pytest tests/test_presence.py +``` + +Run tests with coverage: +```bash +pytest --cov=pypresence --cov-report=html +``` + +Exclude manual tests (require Discord running): +```bash +pytest -m "not manual" +``` + +### Test Markers + +- `asyncio` - Async tests +- `integration` - Integration tests +- `manual` - Tests requiring manual setup (e.g., Discord running) + +## Code Quality + +**Format code with Black:** +```bash +black . +``` + +**Check code style with flake8:** +```bash +flake8 pypresence tests +``` + +**Sort imports with isort:** +```bash +isort . +``` + +**Type check with mypy:** +```bash +mypy pypresence +``` + +**Run all checks at once:** +```bash +black . && isort . && flake8 pypresence tests && mypy pypresence && pytest +``` + +## Project Structure + +``` +pypresence/ +├── pypresence/ # Main package +│ ├── __init__.py # Package initialization, version +│ ├── baseclient.py # Base RPC client +│ ├── client.py # Full RPC client (authorize, etc.) +│ ├── presence.py # Simple presence client +│ ├── payloads.py # Payload builders +│ ├── exceptions.py # Custom exceptions +│ ├── types.py # Type definitions +│ └── utils.py # Utility functions +├── tests/ # Test suite +│ ├── test_baseclient.py +│ ├── test_presence.py +│ ├── test_payloads.py +│ └── ... +├── examples/ # Example scripts +├── docs/ # Documentation +└── pyproject.toml # Project configuration +``` + +### Key Files + +- **`pypresence/__init__.py`** - Package entry point, defines `__version__` +- **`pypresence/baseclient.py`** - Core IPC communication with Discord +- **`pypresence/presence.py`** - Simple Rich Presence API +- **`pypresence/client.py`** - Full RPC client with authorization +- **`pyproject.toml`** - All project configuration (build, tools, dependencies) + +## Making Changes + +1. **Create a new branch** + ```bash + git checkout -b feature/your-feature-name + ``` + +2. **Make your changes** + - Write code following the existing style + - Add tests for new functionality + - Update documentation if needed + - Ensure type hints are included + +3. **Run tests and linters** + ```bash + pytest + black . + flake8 pypresence tests + mypy pypresence + ``` + +4. **Commit your changes** + ```bash + git add . + git commit -m "Description of your changes" + ``` + +5. **Push and create a Pull Request** + ```bash + git push origin feature/your-feature-name + ``` + +## Version Management + +The project uses a **single source of truth** for versioning. The version is defined only in `pypresence/__init__.py`: + +```python +__version__ = "4.5.2" +``` + +The `pyproject.toml` automatically reads this version using dynamic versioning: + +```toml +[project] +dynamic = ["version"] + +[tool.setuptools.dynamic] +version = {attr = "pypresence.__version__"} +``` + +**When releasing a new version**, only update the version in `pypresence/__init__.py`. The version will automatically propagate to: +- Package metadata +- Build artifacts +- PyPI uploads +- All imports + +## Building the Package + +Install build tools: +```bash +pip install build +``` + +Build source distribution and wheel: +```bash +python -m build +``` + +The built packages will be in the `dist/` directory: +- `pypresence-x.x.x.tar.gz` (source distribution) +- `pypresence-x.x.x-py3-none-any.whl` (wheel) + +## Continuous Integration + +The project uses GitHub Actions for CI/CD: + +### Test Workflow (`.github/workflows/test.yml`) +- Runs on every push and pull request +- Tests Python 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 +- Tests on Ubuntu, Windows, and macOS +- Ensures cross-platform compatibility + +### Lint Workflow (`.github/workflows/lint_python.yml`) +- Runs bandit, black, codespell, flake8, isort, mypy +- Checks code quality and security +- Some checks are non-blocking (|| true) + +### Publish Workflow (`.github/workflows/publish-to-pypi.yml`) +- Automatically publishes to PyPI on GitHub releases +- Uses trusted publishing (no API tokens needed) + +## Contributing Guidelines + +### Before Submitting a PR + +✅ All tests pass (`pytest`) +✅ Code is formatted (`black .`) +✅ Imports are sorted (`isort .`) +✅ No linting errors (`flake8 pypresence tests`) +✅ Type checking passes (`mypy pypresence`) +✅ New features have tests +✅ Documentation is updated if needed + +### Code Style + +- Follow PEP 8 (enforced by black and flake8) +- Use type hints for all functions +- Write docstrings for public APIs +- Keep functions focused and testable +- Prefer clarity over cleverness + +### Commit Messages + +This project follows [Conventional Commits](https://www.conventionalcommits.org/) specification. + +**Format:** +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +**Common types:** +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `style:` - Code style changes (formatting, missing semi-colons, etc.) +- `refactor:` - Code refactoring (no feature changes or bug fixes) +- `test:` - Adding or updating tests +- `chore:` - Maintenance tasks (dependencies, build process, etc.) +- `ci:` - CI/CD changes + +**Examples:** +``` +feat: add support for Discord activity buttons +fix: resolve connection timeout on Windows +docs: update async client documentation +test: add coverage for presence.update method +chore: update pytest to 8.0.0 +ci: add Python 3.13 to test matrix +``` + +**With scope:** +``` +feat(presence): add button support +fix(client): handle connection timeout properly +docs(readme): add development section +``` + +**Breaking changes:** +``` +feat!: remove deprecated sync_handler parameter + +BREAKING CHANGE: The sync_handler parameter has been removed. +Use error_handler instead. +``` + +### Pull Request Process + +1. **Fork** the repository +2. **Create a branch** for your feature +3. **Make your changes** with tests +4. **Run all checks** (tests, linters, type checker) +5. **Push** to your fork +6. **Open a Pull Request** with a clear description +7. **Respond to feedback** from maintainers + +## Additional Resources + +- [pypresence Documentation](https://qwertyquerty.github.io/pypresence/html/index.html) +- [Discord Rich Presence Documentation](https://discord.com/developers/docs/rich-presence/how-to) +- [Discord RPC Documentation](https://discord.com/developers/docs/topics/rpc) +- [Discord API Support Server](https://discord.gg/discord-api) +- [pyresence Discord Support Server](https://discord.gg/JF3kg77) + +## Getting Help + +- Open an issue on GitHub for bugs or feature requests +- Join the Discord server for questions +- Check existing issues and PRs before creating new ones + +## License + +pypresence is licensed under the MIT License. See [LICENSE](LICENSE) for details. diff --git a/README.md b/README.md index 8bf4d70..b88e87f 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ For the latest stable version: Examples can be found in the [examples](https://github.com/qwertyquerty/pypresence/tree/master/examples) directory, and you can contribute your own examples if you wish, just read [examples.md](https://github.com/qwertyquerty/pypresence/blob/master/examples/examples.md)! +---------- + +# Development + +Want to contribute? Check out the **[Development Guide](DEVELOPMENT.md)** for setup instructions, testing, code quality tools, and contribution guidelines. + ---------- Written by: [qwertyquerty](https://github.com/qwertyquerty) diff --git a/pyproject.toml b/pyproject.toml index bb654bc..0e16ab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,69 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pypresence" +dynamic = ["version"] +description = "Discord RPC client written in Python" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +authors = [ + {name = "qwertyquerty"} +] +keywords = ["discord", "rich presence", "pypresence", "rpc", "api", "wrapper", "gamers", "chat", "irc"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Libraries", + "Topic :: Communications :: Chat", + "Framework :: AsyncIO", +] + +[project.urls] +Homepage = "https://github.com/qwertyquerty/pypresence" +Repository = "https://github.com/qwertyquerty/pypresence" +Issues = "https://github.com/qwertyquerty/pypresence/issues" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-mock>=3.10.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "isort>=5.12.0", + "sphinx>=5.0.0", + "sphinx-rtd-theme>=1.2.0", +] + +[tool.setuptools] +packages = ["pypresence"] +zip-safe = true + +[tool.setuptools.dynamic] +version = {attr = "pypresence.__version__"} + +[tool.setuptools.package-data] +pypresence = ["py.typed"] + [tool.black] extend-exclude = ''' /(\.(git|hg|mypy_cache|tox|venv|env|.eggs|.vscode|.github|.idea)| @@ -10,4 +76,33 @@ extend-exclude = ''' __pycache__| docs| )/ -''' \ No newline at end of file +''' + +[tool.pytest.ini_options] +testpaths = "tests" +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = [ + "-v", + "--strict-markers", + "--tb=short", + "--disable-warnings" +] +markers = [ + "asyncio: marks tests as async (deselect with '-m \"not asyncio\"')", + "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", + "manual: marks tests that require manual setup like Discord running (deselect with '-m \"not manual\"')" +] +asyncio_mode = "auto" + +[tool.mypy] +files = ["pypresence/", "examples/"] +exclude = ["docs/*"] +ignore_missing_imports = true + +[tool.isort] +profile = "black" + +[tool.bdist_wheel] +universal = true \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 63d156e..a04a82a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,5 @@ -[bdist_wheel] -universal = 1 +# All configuration has been migrated to pyproject.toml +# This file is kept for backward compatibility with older tools [metadata] -description-file = README.md - -[aliases] -release = sdist bdist_wheel - -[mypy] -files = pypresence/, examples/ -exclude = docs/* -exclude_gitignore = True \ No newline at end of file +description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py index a697cea..26d66de 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,13 @@ -from setuptools import setup -import pypresence +""" +Legacy setup.py for backward compatibility. +All configuration has been migrated to pyproject.toml. +This file is kept for compatibility with older tools. +""" -# Use README for the PyPI page -with open("README.md") as f: - long_description = f.read() +from setuptools import setup -# https://setuptools.readthedocs.io/en/latest/setuptools.html -setup( - name="pypresence", - author="qwertyquerty", - url="https://github.com/qwertyquerty/pypresence", - version=pypresence.__version__, - packages=["pypresence"], - python_requires=">=3.9", - platforms=["Windows", "Linux", "OSX"], - zip_safe=True, - license="MIT", - description="Discord RPC client written in Python", - long_description=long_description, - # PEP 566, PyPI Warehouse, setuptools>=38.6.0 make markdown possible - long_description_content_type="text/markdown", - keywords="discord rich presence pypresence rpc api wrapper gamers chat irc", - # Used by PyPI to classify the project and make it searchable - # Full list: https://pypi.org/pypi?%3Aaction=list_classifiers - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX :: Linux", - "Operating System :: MacOS :: MacOS X", - "Programming Language :: Python", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: Implementation :: CPython", - "Typing :: Typed", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Software Development :: Libraries", - "Topic :: Communications :: Chat", - "Framework :: AsyncIO", - ], -) +# All configuration is now in pyproject.toml +setup() print( r""" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9801d57 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,170 @@ +# pypresence Test Suite + +This directory contains the test suite for pypresence. + +## Structure + +``` +tests/ +├── conftest.py # Shared fixtures and test configuration +├── test_payloads.py # Tests for payload generation (no I/O) +├── test_utils.py # Tests for utility functions +├── test_types.py # Tests for type enums +├── test_exceptions.py # Tests for exception classes +├── test_presence.py # Tests for Presence class (mocked I/O) +├── test_baseclient.py # Tests for BaseClient (mocked I/O) +└── README.md # This file +``` + +## Running Tests + +### Install test dependencies + +```bash +pip install pytest pytest-asyncio pytest-mock +``` + +### Run all tests + +```bash +pytest +``` + +### Run specific test files + +```bash +pytest tests/test_payloads.py +pytest tests/test_presence.py +``` + +### Run specific test classes or methods + +```bash +pytest tests/test_payloads.py::TestPayloadGeneration +pytest tests/test_payloads.py::TestPayloadGeneration::test_basic_payload_creation +``` + +### Run with coverage + +```bash +pip install pytest-cov +pytest --cov=pypresence --cov-report=html +``` + +### Run tests with verbose output + +```bash +pytest -v +``` + +### Run tests and show print statements + +```bash +pytest -s +``` + +## Test Categories + +### Unit Tests (No I/O) +- `test_payloads.py` - Tests payload generation logic +- `test_utils.py` - Tests utility functions +- `test_types.py` - Tests type enums +- `test_exceptions.py` - Tests exception classes + +These tests run entirely in-memory with no external dependencies. + +### Integration Tests (Mocked I/O) +- `test_presence.py` - Tests Presence class with mocked sockets +- `test_baseclient.py` - Tests BaseClient with mocked connections + +These tests mock the IPC communication layer to test the full flow without requiring Discord. + +## Key Testing Strategies + +### 1. **Mocking Socket Communication** +We mock `sock_reader` and `sock_writer` to simulate Discord IPC responses: + +```python +presence.sock_writer = Mock() +presence.sock_reader = AsyncMock() +``` + +### 2. **Testing Payload Format** +We verify that payloads are correctly formatted by parsing the sent data: + +```python +call_args = presence.sock_writer.write.call_args[0][0] +op, length = struct.unpack(' Date: Wed, 15 Oct 2025 11:59:04 -0500 Subject: [PATCH 02/10] feat: add the name arg --- .github/workflows/lint_python.yml | 6 +- examples/rich-presence-custom-name.py | 17 +++ pypresence/client.py | 99 ++++++++++++----- pypresence/payloads.py | 2 + pypresence/presence.py | 4 + pyproject.toml | 6 +- tests/test_payloads.py | 150 +++++++++++++++----------- tests/test_presence.py | 74 +++++++++++++ 8 files changed, 263 insertions(+), 95 deletions(-) create mode 100644 examples/rich-presence-custom-name.py diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml index afb3636..ddacea3 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -8,7 +8,7 @@ jobs: - uses: actions/setup-python@v5 - run: pip install --upgrade pip wheel setuptools - run: pip install bandit black codespell flake8 isort mypy pytest pyupgrade safety - - run: bandit --recursive --skip B101,B311 . + - run: bandit --recursive -c pyproject.toml --skip B101,B311 . - run: black --check . - run: codespell --ignore-words-list="wee" --skip="*.css,*.js" - run: flake8 . --count --select=C,E,F,W,B,B950 --extend-ignore=E203,E501,W503 --max-complexity=10 --max-line-length=88 --show-source --statistics @@ -17,7 +17,7 @@ jobs: - run: pip install -e . - run: mkdir -p .mypy_cache - run: mypy --install-types --non-interactive . || true - - run: pytest . || true - - run: pytest --doctest-modules . || true + - run: pytest . + - run: pytest --doctest-modules . - run: shopt -s globstar && pyupgrade --py36-plus **/*.py || true - run: safety scan || true diff --git a/examples/rich-presence-custom-name.py b/examples/rich-presence-custom-name.py new file mode 100644 index 0000000..68d5647 --- /dev/null +++ b/examples/rich-presence-custom-name.py @@ -0,0 +1,17 @@ +from pypresence import Presence +import time + +client_id = "717091213148160041" # Fake ID, put your real one here +RPC = Presence(client_id) # Initialize the client class +RPC.connect() # Start the handshake loop + +print( + RPC.update( + state="Lookie Lookie", + details="A test of qwertyquerty's Python Discord RPC wrapper, pypresence!", + name="This is a amazing test presence", + ) +) # Set the presence + +while True: # The presence will stay on as long as the program is running + time.sleep(15) # Can only update rich presence every 15 seconds diff --git a/pypresence/client.py b/pypresence/client.py index 729e9b5..b44c441 100644 --- a/pypresence/client.py +++ b/pypresence/client.py @@ -4,6 +4,7 @@ import struct import json import os +import typing from typing import List, Callable from .baseclient import BaseClient @@ -143,10 +144,12 @@ def set_activity( self, pid: int = os.getpid(), activity_type: ActivityType | None = None, + status_display_type: StatusDisplayType | None = None, state: str | None = None, details: str | None = None, - start: int | None = None, - end: int | None = None, + name: str | None = None, + start: typing.Union[int, float] | None = None, + end: typing.Union[int, float] | None = None, large_image: str | None = None, large_text: str | None = None, small_image: str | None = None, @@ -158,27 +161,48 @@ def set_activity( match: str | None = None, buttons: list | None = None, instance: bool = True, + payload_override: dict | None = None, ): - payload = Payload.set_activity( - pid=pid, - activity_type=activity_type, - state=state, - details=details, - start=start, - end=end, - large_image=large_image, - large_text=large_text, - small_image=small_image, - small_text=small_text, - party_id=party_id, - party_size=party_size, - join=join, - spectate=spectate, - match=match, - buttons=buttons, - instance=instance, - activity=True, - ) + """ + Please note that the start and end timestamps are in seconds since the epoch (UTC) (time.time()). + Yes, they will be converted to milliseconds by the library. + """ + + if start: + if isinstance(start, int) or isinstance(start, float): + start = int(start) * 1000 # Convert to milliseconds + + if end: + if isinstance(end, int) or isinstance(end, float): + end = int(end) * 1000 # Convert to milliseconds + + if payload_override is None: + payload = Payload.set_activity( + pid=pid, + activity_type=activity_type.value if activity_type else None, + status_display_type=( + status_display_type.value if status_display_type else None + ), + state=state, + details=details, + name=name, + start=start, + end=end, + large_image=large_image, + large_text=large_text, + small_image=small_image, + small_text=small_text, + party_id=party_id, + party_size=party_size, + join=join, + spectate=spectate, + match=match, + buttons=buttons, + instance=instance, + activity=True, + ) + else: + payload = payload_override self.send_data(1, payload) return self.loop.run_until_complete(self.read_output()) @@ -382,11 +406,12 @@ async def set_activity( self, pid: int = os.getpid(), activity_type: ActivityType | None = None, - status_display_type: StatusDisplayType | int | None = None, + status_display_type: StatusDisplayType | None = None, state: str | None = None, details: str | None = None, - start: int | None = None, - end: int | None = None, + name: str | None = None, + start: typing.Union[int, float] | None = None, + end: typing.Union[int, float] | None = None, large_image: str | None = None, large_text: str | None = None, small_image: str | None = None, @@ -395,16 +420,32 @@ async def set_activity( party_size: list | None = None, join: str | None = None, spectate: str | None = None, - buttons: list | None = None, match: str | None = None, + buttons: list | None = None, instance: bool = True, ): + """ + Please note that the start and end timestamps are in seconds since the epoch (UTC) (time.time()). + Yes, they will be converted to milliseconds by the library. + """ + + if start: + if isinstance(start, int) or isinstance(start, float): + start = int(start) * 1000 # Convert to milliseconds + + if end: + if isinstance(end, int) or isinstance(end, float): + end = int(end) * 1000 # Convert to milliseconds + payload = Payload.set_activity( - pid, - activity_type=activity_type, - status_display_type=status_display_type, + pid=pid, + activity_type=activity_type.value if activity_type else None, + status_display_type=( + status_display_type.value if status_display_type else None + ), state=state, details=details, + name=name, start=start, end=end, large_image=large_image, diff --git a/pypresence/payloads.py b/pypresence/payloads.py index 6099afc..6cd2ce9 100644 --- a/pypresence/payloads.py +++ b/pypresence/payloads.py @@ -30,6 +30,7 @@ def set_activity( status_display_type: StatusDisplayType | int | None = None, state: str | None = None, details: str | None = None, + name: str | None = None, start: int | float | None = None, end: int | float | None = None, large_image: str | None = None, @@ -76,6 +77,7 @@ def set_activity( ), "state": state, "details": details, + "name": name, "timestamps": {"start": start, "end": end}, "assets": { "large_image": large_image, diff --git a/pypresence/presence.py b/pypresence/presence.py index dbc19e8..5117b2b 100644 --- a/pypresence/presence.py +++ b/pypresence/presence.py @@ -22,6 +22,7 @@ def update( status_display_type: StatusDisplayType | None = None, state: str | None = None, details: str | None = None, + name: str | None = None, start: typing.Union[int, float] | None = None, end: typing.Union[int, float] | None = None, large_image: str | None = None, @@ -59,6 +60,7 @@ def update( ), state=state, details=details, + name=name, start=start, end=end, large_image=large_image, @@ -107,6 +109,7 @@ async def update( status_display_type: StatusDisplayType | None = None, state: str | None = None, details: str | None = None, + name: str | None = None, start: int | None = None, end: int | None = None, large_image: str | None = None, @@ -136,6 +139,7 @@ async def update( status_display_type=status_display_type, state=state, details=details, + name=name, start=start, end=end, large_image=large_image, diff --git a/pyproject.toml b/pyproject.toml index 0e16ab9..ff5c2d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,9 +100,13 @@ asyncio_mode = "auto" files = ["pypresence/", "examples/"] exclude = ["docs/*"] ignore_missing_imports = true +strict = true [tool.isort] profile = "black" [tool.bdist_wheel] -universal = true \ No newline at end of file +universal = true + +[tool.bandit] +exclude_dirs = ["tests", ".venv"] \ No newline at end of file diff --git a/tests/test_payloads.py b/tests/test_payloads.py index 0764df3..2ed6f08 100644 --- a/tests/test_payloads.py +++ b/tests/test_payloads.py @@ -1,4 +1,5 @@ """Test payload generation without any I/O operations""" + import pytest import json import os @@ -8,16 +9,16 @@ class TestPayloadGeneration: """Test Payload class methods""" - + def test_basic_payload_creation(self): """Test creating a basic payload""" data = {"cmd": "TEST", "args": {"value": 1}} payload = Payload(data, clear_none=False) - + assert payload.data == data assert isinstance(str(payload), str) assert "TEST" in str(payload) - + def test_payload_removes_none_values(self): """Test that None values are removed by default""" data = { @@ -25,24 +26,21 @@ def test_payload_removes_none_values(self): "args": { "value": 1, "none_value": None, - "nested": { - "keep": "this", - "remove": None - } - } + "nested": {"keep": "this", "remove": None}, + }, } payload = Payload(data, clear_none=True) - + assert "none_value" not in payload.data["args"] assert "remove" not in payload.data["args"]["nested"] assert payload.data["args"]["value"] == 1 assert payload.data["args"]["nested"]["keep"] == "this" - + def test_payload_str_is_valid_json(self): """Test that __str__ returns valid JSON""" data = {"cmd": "TEST", "value": 123} payload = Payload(data) - + # Should be able to parse the string representation as JSON parsed = json.loads(str(payload)) assert parsed["cmd"] == "TEST" @@ -51,148 +49,176 @@ def test_payload_str_is_valid_json(self): class TestSetActivity: """Test SET_ACTIVITY payload generation""" - + def test_set_activity_basic(self): """Test basic activity payload""" - payload = Payload.set_activity( - state="Testing", - details="Running tests" - ) - + payload = Payload.set_activity(state="Testing", details="Running tests") + assert payload.data["cmd"] == "SET_ACTIVITY" assert payload.data["args"]["activity"]["state"] == "Testing" assert payload.data["args"]["activity"]["details"] == "Running tests" assert "nonce" in payload.data - + def test_set_activity_with_timestamps(self): """Test activity with start/end timestamps""" start_time = 1234567890 end_time = 1234567900 - - payload = Payload.set_activity( - start=start_time, - end=end_time - ) - + + payload = Payload.set_activity(start=start_time, end=end_time) + assert payload.data["args"]["activity"]["timestamps"]["start"] == start_time assert payload.data["args"]["activity"]["timestamps"]["end"] == end_time - + def test_set_activity_with_images(self): """Test activity with large and small images""" payload = Payload.set_activity( large_image="large_key", large_text="Large Image Text", small_image="small_key", - small_text="Small Image Text" + small_text="Small Image Text", ) - + assets = payload.data["args"]["activity"]["assets"] assert assets["large_image"] == "large_key" assert assets["large_text"] == "Large Image Text" assert assets["small_image"] == "small_key" assert assets["small_text"] == "Small Image Text" - + def test_set_activity_with_party(self): """Test activity with party information""" - payload = Payload.set_activity( - party_id="party123", - party_size=[1, 5] - ) - + payload = Payload.set_activity(party_id="party123", party_size=[1, 5]) + party = payload.data["args"]["activity"]["party"] assert party["id"] == "party123" assert party["size"] == [1, 5] - + def test_set_activity_with_buttons(self): """Test activity with buttons""" buttons = [ {"label": "Button 1", "url": "https://example.com/1"}, - {"label": "Button 2", "url": "https://example.com/2"} + {"label": "Button 2", "url": "https://example.com/2"}, ] - + payload = Payload.set_activity(buttons=buttons) - + assert payload.data["args"]["activity"]["buttons"] == buttons - + def test_set_activity_with_secrets(self): """Test activity with join/spectate secrets""" payload = Payload.set_activity( - join="join_secret", - spectate="spectate_secret", - match="match_secret" + join="join_secret", spectate="spectate_secret", match="match_secret" ) - + secrets = payload.data["args"]["activity"]["secrets"] assert secrets["join"] == "join_secret" assert secrets["spectate"] == "spectate_secret" assert secrets["match"] == "match_secret" - + def test_set_activity_with_activity_type(self): """Test activity with different activity types""" for activity_type in ActivityType: payload = Payload.set_activity(activity_type=activity_type) assert payload.data["args"]["activity"]["type"] == activity_type.value - + def test_set_activity_with_activity_type_int(self): """Test activity type can be specified as int""" payload = Payload.set_activity(activity_type=2) # LISTENING assert payload.data["args"]["activity"]["type"] == 2 - + def test_set_activity_with_status_display_type(self): """Test activity with different status display types""" for status_type in StatusDisplayType: payload = Payload.set_activity(status_display_type=status_type) - assert payload.data["args"]["activity"]["status_display_type"] == status_type.value - + assert ( + payload.data["args"]["activity"]["status_display_type"] + == status_type.value + ) + + def test_set_activity_with_name(self): + """Test activity with name parameter""" + payload = Payload.set_activity(name="Custom Activity Name") + + assert payload.data["args"]["activity"]["name"] == "Custom Activity Name" + + def test_set_activity_with_name_and_details(self): + """Test activity with both name and details""" + payload = Payload.set_activity( + name="My Activity", details="Doing something", state="In progress" + ) + + assert payload.data["args"]["activity"]["name"] == "My Activity" + assert payload.data["args"]["activity"]["details"] == "Doing something" + assert payload.data["args"]["activity"]["state"] == "In progress" + + def test_set_activity_name_with_status_display_type(self): + """Test activity with name and status display type set to NAME""" + payload = Payload.set_activity( + name="Custom Name", status_display_type=StatusDisplayType.NAME + ) + + assert payload.data["args"]["activity"]["name"] == "Custom Name" + assert ( + payload.data["args"]["activity"]["status_display_type"] + == StatusDisplayType.NAME.value + ) + + def test_set_activity_name_none_is_removed(self): + """Test that name=None is removed from payload""" + payload = Payload.set_activity(name=None, state="Testing") + + # None values should be removed by clear_none + assert "name" not in payload.data["args"]["activity"] + assert payload.data["args"]["activity"]["state"] == "Testing" + def test_set_activity_clear(self): """Test clearing activity (activity=None)""" # When activity=None, clear is set to True and removes the activity field payload = Payload.set_activity(activity=None) - + assert payload.data["cmd"] == "SET_ACTIVITY" # The payload creation clears None values, so check that cmd exists assert "cmd" in payload.data - + def test_set_activity_with_pid(self): """Test activity with custom PID""" custom_pid = 12345 payload = Payload.set_activity(pid=custom_pid) - + assert payload.data["args"]["pid"] == custom_pid - + def test_set_activity_default_pid(self): """Test activity uses current process PID by default""" payload = Payload.set_activity() - + assert payload.data["args"]["pid"] == os.getpid() - + def test_set_activity_instance_flag(self): """Test activity instance flag""" payload = Payload.set_activity(instance=False) assert payload.data["args"]["activity"]["instance"] is False - + payload = Payload.set_activity(instance=True) assert payload.data["args"]["activity"]["instance"] is True - + def test_set_activity_removes_empty_nested_dicts(self): """Test that empty nested dictionaries are removed""" payload = Payload.set_activity(state="Test") - + # These should be removed if empty activity = payload.data["args"]["activity"] - + # Timestamps should be removed if both are None assert "timestamps" not in activity or activity["timestamps"] - - # Assets should be removed if all are None + + # Assets should be removed if all are None assert "assets" not in activity or activity["assets"] - + def test_nonce_is_unique(self): """Test that each payload gets a unique nonce""" import time - + payload1 = Payload.set_activity(state="Test 1") time.sleep(0.001) # Small delay to ensure different timestamp payload2 = Payload.set_activity(state="Test 2") - + assert payload1.data["nonce"] != payload2.data["nonce"] diff --git a/tests/test_presence.py b/tests/test_presence.py index f8c0dfe..113d987 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -112,6 +112,80 @@ async def mock_coro(): assert payload["args"]["activity"]["type"] == ActivityType.LISTENING.value + @patch("pypresence.baseclient.BaseClient.read_output") + def test_update_with_name(self, mock_read_output, client_id): + """Test update with name parameter""" + presence = Presence(client_id) + presence.sock_writer = Mock() + + async def mock_coro(): + return {} + + mock_read_output.return_value = mock_coro() + + presence.update(name="Custom Activity Name") + + call_args = presence.sock_writer.write.call_args[0][0] + op, length = struct.unpack(" Date: Wed, 15 Oct 2025 12:05:10 -0500 Subject: [PATCH 03/10] style: a few flake8 errors --- pyproject.toml | 3 +-- tests/test_baseclient.py | 3 +-- tests/test_payloads.py | 1 - tests/test_presence.py | 1 - tests/test_types.py | 1 - 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff5c2d8..5724ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,9 +98,8 @@ asyncio_mode = "auto" [tool.mypy] files = ["pypresence/", "examples/"] -exclude = ["docs/*"] +exclude = ["docs/*", "tests/*"] ignore_missing_imports = true -strict = true [tool.isort] profile = "black" diff --git a/tests/test_baseclient.py b/tests/test_baseclient.py index 2993585..05f591d 100644 --- a/tests/test_baseclient.py +++ b/tests/test_baseclient.py @@ -1,7 +1,7 @@ """Test BaseClient core functionality""" import pytest -from unittest.mock import Mock, patch, AsyncMock, MagicMock +from unittest.mock import Mock, patch, AsyncMock import asyncio import struct import json @@ -15,7 +15,6 @@ PipeClosed, ConnectionTimeout, ResponseTimeout, - DiscordNotFound, InvalidID, ServerError, ) diff --git a/tests/test_payloads.py b/tests/test_payloads.py index 2ed6f08..0c414eb 100644 --- a/tests/test_payloads.py +++ b/tests/test_payloads.py @@ -1,6 +1,5 @@ """Test payload generation without any I/O operations""" -import pytest import json import os from pypresence.payloads import Payload diff --git a/tests/test_presence.py b/tests/test_presence.py index 113d987..b51b148 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -4,7 +4,6 @@ from unittest.mock import Mock, patch, AsyncMock import struct import json -import os from pypresence import Presence, AioPresence from pypresence.types import ActivityType diff --git a/tests/test_types.py b/tests/test_types.py index f999080..2fc6f56 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,6 +1,5 @@ """Test type enums""" -import pytest from pypresence.types import ActivityType, StatusDisplayType From cc30df9fb35345ce6603f0bfd85bc8a63bbe4c5c Mon Sep 17 00:00:00 2001 From: kaneryu Date: Wed, 15 Oct 2025 12:07:33 -0500 Subject: [PATCH 04/10] ci: swap to only using specific test workflow --- .github/workflows/lint_python.yml | 2 -- .github/workflows/test.yml | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml index ddacea3..18609e8 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -17,7 +17,5 @@ jobs: - run: pip install -e . - run: mkdir -p .mypy_cache - run: mypy --install-types --non-interactive . || true - - run: pytest . - - run: pytest --doctest-modules . - run: shopt -s globstar && pyupgrade --py36-plus **/*.py || true - run: safety scan || true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b2833c9..2093748 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,6 @@ name: Tests -on: - push: - branches: [ master, main ] - pull_request: - branches: [ master, main ] +on: [pull_request, push] jobs: test: From a36a13a504c84eb5a1439aeea6f916ae2435064d Mon Sep 17 00:00:00 2001 From: kaneryu Date: Wed, 15 Oct 2025 12:08:25 -0500 Subject: [PATCH 05/10] ci: we don't need to test for 3.8, only supported on 3.9 and up --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2093748..74f2324 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: [3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 From cd56a2fa23520d1ed2fac5918a685337b09eb563 Mon Sep 17 00:00:00 2001 From: kaneryu Date: Wed, 15 Oct 2025 12:15:28 -0500 Subject: [PATCH 06/10] tests: fixed tests to not require discord to be running --- tests/conftest.py | 5 ++-- tests/test_baseclient.py | 63 ++++++++++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2d9e2f3..81a7a72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -104,8 +104,9 @@ def mock_ipc_path(monkeypatch, tmp_path): def mock_get_ipc_path(pipe=None): return ipc_path - from pypresence import utils + # Patch in baseclient module where it's actually used + from pypresence import baseclient - monkeypatch.setattr(utils, "get_ipc_path", mock_get_ipc_path) + monkeypatch.setattr(baseclient, "get_ipc_path", mock_get_ipc_path) return ipc_path diff --git a/tests/test_baseclient.py b/tests/test_baseclient.py index 05f591d..18bfd1c 100644 --- a/tests/test_baseclient.py +++ b/tests/test_baseclient.py @@ -208,17 +208,22 @@ async def test_handshake_success(self, client_id, mock_ipc_path): """Test successful handshake""" client = BaseClient(client_id) - # Mock create_reader_writer - client.sock_reader = AsyncMock() - client.sock_writer = Mock() - client.create_reader_writer = AsyncMock() - # Mock successful handshake response response = {"cmd": "DISPATCH", "data": {"v": 1}, "evt": "READY"} response_json = json.dumps(response).encode("utf-8") preamble = struct.pack(" Date: Wed, 15 Oct 2025 12:17:38 -0500 Subject: [PATCH 07/10] ci: typo......... --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74f2324..32d0f0b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: [3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 From 66feb15eda8a33f76458c95d1fae9c23a1f1d041 Mon Sep 17 00:00:00 2001 From: kaneryu Date: Wed, 15 Oct 2025 12:20:23 -0500 Subject: [PATCH 08/10] ci: add deps caching --- .github/workflows/lint_python.yml | 2 ++ .github/workflows/publish-to-pypi.yml | 1 + .github/workflows/test.yml | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml index 18609e8..532e9c0 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -6,6 +6,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + with: + cache: 'pip' - run: pip install --upgrade pip wheel setuptools - run: pip install bandit black codespell flake8 isort mypy pytest pyupgrade safety - run: bandit --recursive -c pyproject.toml --skip B101,B311 . diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 1a4f0e4..7da9527 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -16,6 +16,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.x' + cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32d0f0b..f15c1ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' - name: Install dependencies run: | From 72dd8898a467c10d82c82925db365bc3372569e4 Mon Sep 17 00:00:00 2001 From: kaneryu Date: Wed, 15 Oct 2025 12:33:37 -0500 Subject: [PATCH 09/10] build: fully migrate to build command, also bump version to 4.6.0 --- .github/workflows/publish-to-pypi.yml | 5 +++-- .gitignore | 1 + pypresence/__init__.py | 2 +- pyproject.toml | 3 +-- setup.cfg | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 7da9527..4e1d85a 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -20,8 +20,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine - python setup.py sdist bdist_wheel + pip install build + - name: Build package + run: python -m build - name: Upload distributions uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index c652b82..ffe14cc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties build/ +dist/ nyaaa/ *.egg-info/ .venv/ diff --git a/pypresence/__init__.py b/pypresence/__init__.py index 3490628..63797c8 100644 --- a/pypresence/__init__.py +++ b/pypresence/__init__.py @@ -15,4 +15,4 @@ __author__ = "qwertyquerty" __copyright__ = "Copyright 2018 - Current qwertyquerty" __license__ = "MIT" -__version__ = "4.5.2" +__version__ = "4.6.0" diff --git a/pyproject.toml b/pyproject.toml index 5724ebd..0942ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,14 +8,13 @@ dynamic = ["version"] description = "Discord RPC client written in Python" readme = "README.md" requires-python = ">=3.9" -license = {text = "MIT"} +license = "MIT" authors = [ {name = "qwertyquerty"} ] keywords = ["discord", "rich presence", "pypresence", "rpc", "api", "wrapper", "gamers", "chat", "irc"] classifiers = [ "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", diff --git a/setup.cfg b/setup.cfg index a04a82a..f48f5d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,4 +2,4 @@ # This file is kept for backward compatibility with older tools [metadata] -description-file = README.md \ No newline at end of file +description_file = README.md \ No newline at end of file From 66e1c36bfc80ef9649df29064a7ffba3786d1681 Mon Sep 17 00:00:00 2001 From: kaneryu Date: Wed, 15 Oct 2025 12:37:53 -0500 Subject: [PATCH 10/10] build: remove bdist_wheel config from pyproject.toml --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0942ec5..018fb68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,8 +103,5 @@ ignore_missing_imports = true [tool.isort] profile = "black" -[tool.bdist_wheel] -universal = true - [tool.bandit] exclude_dirs = ["tests", ".venv"] \ No newline at end of file