diff --git a/.editorconfig b/.editorconfig index e49e1803..896fb863 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,6 @@ indent_size = 2 [*.py] indent_size = 4 + +[{Makefile,*.mk}] +indent_style = tab diff --git a/.env.example b/.env.example deleted file mode 100644 index dd61d09c..00000000 --- a/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -MPT_API_BASE_URL=https://api.example.com/ - -MPT_API_TOKEN=idt:TKN-4138-9324:... -MPT_API_TOKEN_CLIENT=idt:TKN... -MPT_API_TOKEN_OPERATIONS=idt:TKN-4138-9324:... -MPT_API_TOKEN_VENDOR=idt:TKN-8857-1729:... -RP_API_KEY="pytest_Ox... -RP_ENDPOINT="https://reportportal.example.com" -RP_LAUNCH="dev-env" diff --git a/.env.sample b/.env.sample new file mode 100644 index 00000000..eca413e8 --- /dev/null +++ b/.env.sample @@ -0,0 +1,9 @@ +MPT_API_BASE_URL=http://localhost:8000 +MPT_API_TOKEN= +# E2E env vars (only needed for development) +MPT_API_TOKEN_CLIENT= +MPT_API_TOKEN_OPERATIONS= +MPT_API_TOKEN_VENDOR= +RP_API_KEY= +RP_ENDPOINT=https://reportportal.example.com +RP_LAUNCH=dev-env diff --git a/.gitignore b/.gitignore index 67743650..0b0fad83 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +out.xml *.cover *.py,cover .hypothesis/ @@ -159,17 +160,15 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.DS_Store - -# ignore xlsx temporary files -~$*.xlsx +.devcontainer/ +.vscode -# ruff cache +.DS_Store .ruff_cache .idea -# VS Code dev container -.devcontainer/ +# Makefile +make/local.mk # E2E report e2e-report.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5deef2f..cd1920aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,8 +28,8 @@ repos: - id: flake8 additional_dependencies: [ - Flake8-pyproject==1.2.*, - Flake8-AAA==0.17.*, + flake8-aaa==0.17.*, + flake8-pyproject==1.2.*, wemake-python-styleguide==1.5.*, ] @@ -39,3 +39,5 @@ repos: - id: mypy args: ["--config-file=pyproject.toml", "."] pass_filenames: false + additional_dependencies: + - python-box==7.3.2 diff --git a/Dockerfile b/Dockerfile index 034285b4..6ce1f6c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,34 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base -WORKDIR /extension +WORKDIR /mpt_api_client RUN uv venv /opt/venv -ENV VIRTUAL_ENV=/opt/venv +ENV UV_PROJECT_ENVIRONMENT=/opt/venv ENV PATH=/opt/venv/bin:$PATH FROM base AS build -COPY . /extension +COPY . . -RUN uv sync --frozen --no-cache --all-groups --active +RUN uv sync --frozen --no-cache --no-dev FROM build AS dev +RUN uv sync --frozen --no-cache --dev + +CMD ["bash"] + +FROM build AS prod + +RUN rm -rf tests/ + +RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser && \ + mkdir -p /home/appuser/.cache/uv && \ + chown -R appuser:appuser /mpt_api_client /opt/venv /home/appuser + +ENV UV_CACHE_DIR=/home/appuser/.cache/uv + +USER appuser + CMD ["bash"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..845f5137 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +MK_FILES := $(sort $(wildcard make/*.mk)) +-include $(MK_FILES) + +.DEFAULT_GOAL := help +.PHONY: $(shell awk -F: '/^[a-zA-Z0-9_-]+:([^=]|$$)/ {print $$1}' $(MAKEFILE_LIST)) + +require = $(if $(value $(1)),,$(error Missing required variable: $(1). Example: make $(MAKECMDGOALS) $(1)=)) + +help: ## Show available commands + @echo "Available commands:" + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " make %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) diff --git a/README.md b/README.md index 8d9f275c..0fc1214f 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,125 @@ +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=softwareone-platform_mpt-api-python-client&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=softwareone-platform_mpt-api-python-client) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=softwareone-platform_mpt-api-python-client&metric=coverage)](https://sonarcloud.io/summary/new_code?id=softwareone-platform_mpt-api-python-client) + +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + # mpt-api-python-client A Python client for interacting with the MPT API. -## Installation +## Documentation + +📚 **[Complete Usage Guide](docs/PROJECT_DESCRIPTION.md)** + +## Getting started + +### Prerequisites + +- Docker and Docker Compose plugin (`docker compose` CLI) +- `make` +- [CodeRabbit CLI](https://www.coderabbit.ai/cli) (optional. Used for running review check locally) + +### Make targets overview + +Common development workflows are wrapped in the `Makefile`. Run `make help` to see the list of available commands. -Install as a uv dependency: +### How the Makefile works + +The project uses a modular Makefile structure that organizes commands into logical groups: +- **Main Makefile** (`Makefile`): Entry point that automatically includes all `.mk` files from the `make/` directory +- **Modular includes** (`make/*.mk`): Commands are organized by category: + - `common.mk` - Core development commands (build, test, format, etc.) + - `repo.mk` - Repository management and dependency commands + - `migrations.mk` - Database migration commands (Only available in extension repositories) + - `external_tools.mk` - Integration with external tools + + +You can extend the Makefile with your own custom commands creating a `local.mk` file inside make folder. This file is +automatically ignored by git, so your personal commands won't affect other developers or appear in version control. + + +### Setup + +Follow these steps to set up the development environment: + +#### 1. Clone the repository ```bash -uv add mpt-api-client -cp .env.example .env +git clone +``` +```bash +cd mpt-api-python-client ``` -## Usage - -```python -from mpt_api_client import MPTClient +#### 2. Create environment configuration -#client = MPTClient(api_key=os.getenv("MPT_API_KEY"), base_url=os.getenv("MPT_API_URL")) -client = MPTClient() # Will get the api_key and base_url from the environment variables +Copy the sample environment file and update it with your values: -for product in client.catalog.products.iterate(): - print(product.name) +```bash +cp .env.sample .env ``` -## Async Usage +Edit the `.env` file with your actual configuration values. See the [Configuration](#configuration) section for details on available variables. -```python -import asyncio -from mpt_api_client import AsyncMPTClient +#### 3. Build the Docker images -async def main(): - # client = AsyncMPTClient(api_key=os.getenv("MPT_API_KEY"), base_url=os.getenv("MPT_API_URL")) - client = AsyncMPTClient() # Will get the api_key and base_url from the environment variables - async for product in client.catalog.products.iterate(): - print(product.name) +Build the development environment: -asyncio.run(main()) +```bash +make build ``` -## Development +This will create the Docker images with all required dependencies and the virtualenv. + +#### 4. Verify the setup -Clone the repository and install dependencies: +Run the test suite to ensure everything is configured correctly: ```bash -git clone https://github.com/albertsola/mpt-api-python-client.git -cd mpt-api-python-client -uv add -r requirements.txt +make test ``` -## Testing +You're now ready to start developing! See [Running the client](#running-the-client) for next steps. + -Run all validations with: +## Running the client + +Before running, ensure your `.env` file is populated. ```bash -make test-all +make run ``` -Run pytest with: +## Developer utilities + +Useful helper targets during development: ```bash -make test-all +make bash # open a bash shell in the app container +make check # run ruff, flake8, and lockfile checks +make check-all # run checks and tests +make format # auto-format code and imports +make review # check the code in the cli by running CodeRabbit ``` -## License -MIT +## Configuration + +The following environment variables are typically set in `.env`. Docker Compose reads them when using the Make targets described above. + +### Application + +| Environment Variable | Default | Example | Description | +|---------------------------------|---------|-------------------------------------------|-------------------------------------------------------------------------------------------| +| `MPT_API_BASE_URL` | - | `https://portal.softwareone.com/mpt` | SoftwareONE Marketplace API URL | +| `MPT_API_TOKEN` | - | eyJhbGciOiJSUzI1N... | SoftwareONE Marketplace API Token | + +### E2E + +| Environment Variable | Default | Example | Description | +|----------------------------|---------|--------------------------------------|----------------------------------------------| +| `MPT_API_TOKEN_CLIENT` | - | eyJhbGciOiJSUzI1N... | SoftwareONE Marketplace API Client Token | +| `MPT_API_TOKEN_OPERATIONS` | - | eyJhbGciOiJSUzI1N... | SoftwareONE Marketplace API Operations Token | +| `MPT_API_TOKEN_VENDOR` | - | eyJhbGciOiJSUzI1N... | SoftwareONE Marketplace API Vendor Token | +| `RP_API_KEY` | - | pytest_XXXXXXXXXXXXXX | ReportPortal API key | +| `RP_ENDPOINT` | - | `https://reportportal.example.com` | ReportPortal endpoint | +| `RP_LAUNCH` | - | `dev-env` | ReportPortal launch | diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md new file mode 100644 index 00000000..08dcdbce --- /dev/null +++ b/docs/PROJECT_DESCRIPTION.md @@ -0,0 +1,50 @@ +# mpt-api-python-client + +mpt-api-python-client is a Python client for interacting with the MPT API + +## Installation + +Install with pip or your favorite PyPI package manager: + +```bash +pip install mpt-api-client +``` + +```bash +uv add mpt-api-client +``` + +## Prerequisites + +- Python 3.12+ in your environment + +## Usage + +```python +from mpt_api_client import MPTClient + +# client = MPTClient(api_key=, base_url=) +client = MPTClient() # Reads MPT_API_TOKEN and MPT_API_BASE_URL from the environment + +for product in client.catalog.products.iterate(): + print(product.name) +``` + +## Async Usage + +```python +import asyncio +from mpt_api_client import AsyncMPTClient + +async def main(): + # client = AsyncMPTClient(api_key=, base_url=) + client = AsyncMPTClient() # Reads MPT_API_TOKEN and MPT_API_BASE_URL from the environment + async for product in client.catalog.products.iterate(): + print(product.name) + +asyncio.run(main()) +``` + +## Development + +For development purposes, please, check the Readme in the GitHub repository. diff --git a/make/common.mk b/make/common.mk new file mode 100644 index 00000000..7d379d85 --- /dev/null +++ b/make/common.mk @@ -0,0 +1,40 @@ +DC = docker compose -f compose.yaml +RUN = $(DC) run --rm app +RUN_IT = $(DC) run --rm -it app + +bash: ## Open a bash shell + $(RUN_IT) bash + +build: ## Build images + $(DC) build + +check: ## Check code quality + $(RUN) bash -c "ruff format --check . && ruff check . && flake8 . && mypy . && uv lock --check" + +check-all: check test ## Run checks and tests + +down: ## Stop and remove containers + $(DC) down + +format: ## Format code + $(RUN) bash -c "ruff check --select I --fix . && ruff format ." + +run: ## Run service + $(RUN_IT) bash -c "ipython" + +test: ## Run test + $(RUN) pytest $(if $(args),$(args), tests/unit) + +uv-add: ## Add a production dependency (pkg=) + $(call require,pkg) + $(RUN) bash -c "uv add $(pkg)" + $(MAKE) build + +uv-add-dev: ## Add a dev dependency (pkg=) + $(call require,pkg) + $(RUN) bash -c "uv add --dev $(pkg)" + $(MAKE) build + +uv-upgrade: ## Upgrade all packages or a specific package (use pkg="package_name" to target one) + $(RUN) bash -c "uv lock $(if $(pkg),--upgrade-package $(pkg),--upgrade) && uv sync" + $(MAKE) build diff --git a/make/external_tools.mk b/make/external_tools.mk new file mode 100644 index 00000000..f60ac1f6 --- /dev/null +++ b/make/external_tools.mk @@ -0,0 +1,2 @@ +review: ## Run CodeRabbit code review in interactive mode. Pass args= to override or include more options + coderabbit review $(args) diff --git a/make/repo.mk b/make/repo.mk new file mode 100644 index 00000000..f34a2ea4 --- /dev/null +++ b/make/repo.mk @@ -0,0 +1,3 @@ +## Add repo-specific targets here. Do not modify the shared *.mk files. +e2e: ## Run e2e test + $(RUN) pytest -p no:randomly --junitxml=e2e-report.xml $(if $(args),$(args), tests/e2e) diff --git a/makefile b/makefile deleted file mode 100644 index a30016b4..00000000 --- a/makefile +++ /dev/null @@ -1,48 +0,0 @@ -.PHONY: bash build check check-all down format review test help - -DC = docker compose -f compose.yaml - -help: - @echo "Available commands:" - @echo " make bash - Open a bash shell in the app container." - @echo " make build - Build images." - @echo " make check - Check code quality with ruff." - @echo " make check-all - Run check and tests." - @echo " make down - Stop and remove containers." - @echo " make e2e - Run e2e test." - @echo " make format - Format code." - @echo " make review - Check the code in the cli by running CodeRabbit." - @echo " make test - Run tests." - @echo " make help - Display this help message." - -bash: - $(DC) run --rm -it app bash - -build: - $(DC) build - -check: - $(DC) run --rm app bash -c "ruff format --check . && ruff check . && flake8 . && mypy . && uv lock --check" - -check-all: - make check - make test - -down: - $(DC) down - -format: - $(DC) run --rm app bash -c "ruff check --select I --fix . && ruff format ." - -review: - coderabbit review --prompt-only - -test: - $(DC) run --rm app pytest $(args) tests/unit - -test-all: - make test - make e2e - -e2e: - $(DC) run --rm app pytest -p no:randomly --junitxml=e2e-report.xml $(args) tests/e2e diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index 860793fa..adc12066 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -31,15 +31,15 @@ def __init__( timeout: float = 20.0, retries: int = 5, ): - api_token = api_token or os.getenv("MPT_TOKEN") + api_token = api_token or os.getenv("MPT_API_TOKEN") if not api_token: raise ValueError( "API token is required. " - "Set it up as env variable MPT_TOKEN or pass it as `api_token` " + "Set it up as env variable MPT_API_TOKEN or pass it as `api_token` " "argument to MPTClient." ) - base_url = validate_base_url(base_url or os.getenv("MPT_URL")) + base_url = validate_base_url(base_url or os.getenv("MPT_API_BASE_URL")) base_headers = { "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index c86ba4bf..fab0dfda 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -44,15 +44,15 @@ def __init__( timeout: float = 20.0, retries: int = 5, ): - api_token = api_token or os.getenv("MPT_TOKEN") + api_token = api_token or os.getenv("MPT_API_TOKEN") if not api_token: raise ValueError( "API token is required. " - "Set it up as env variable MPT_TOKEN or pass it as `api_token` " + "Set it up as env variable MPT_API_TOKEN or pass it as `api_token` " "argument to MPTClient." ) - base_url = validate_base_url(base_url or os.getenv("MPT_URL")) + base_url = validate_base_url(base_url or os.getenv("MPT_API_BASE_URL")) base_headers = { "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", diff --git a/pyproject.toml b/pyproject.toml index 55a3b0da..e122161c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "SoftwareOne Marketplace API Client for Python" authors = [{ name = "SoftwareOne AG" }] requires-python = ">=3.12,<4" -readme = "README.md" +readme = "docs/PROJECT_DESCRIPTION.md" license = {text = "Apache-2.0 license"} keywords = [ "openapi", @@ -54,7 +54,7 @@ dev = [ ] [tool.hatch.build.targets.sdist] -include = ["mpt_api_client"] +include = ["mpt_api_client", "docs/PROJECT_DESCRIPTION.md"] [tool.hatch.build.targets.wheel] include = ["mpt_api_client"] diff --git a/tests/unit/http/test_async_client.py b/tests/unit/http/test_async_client.py index db51c35e..d176d046 100644 --- a/tests/unit/http/test_async_client.py +++ b/tests/unit/http/test_async_client.py @@ -38,8 +38,8 @@ def test_async_http_initialization(mocker): def test_async_env_initialization(monkeypatch, mocker): - monkeypatch.setenv("MPT_TOKEN", API_TOKEN) - monkeypatch.setenv("MPT_URL", API_URL) + monkeypatch.setenv("MPT_API_BASE_URL", API_URL) + monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) mock_async_client = mocker.patch("mpt_api_client.http.async_client.AsyncClient") AsyncHTTPClient() # act @@ -56,14 +56,11 @@ def test_async_env_initialization(monkeypatch, mocker): ) -def test_async_http_without_token(): - with pytest.raises(ValueError): - AsyncHTTPClient(base_url=API_URL) +def test_async_http_without_token(monkeypatch): + monkeypatch.delenv("MPT_API_TOKEN", raising=False) - -def test_async_http_without_url(): with pytest.raises(ValueError): - AsyncHTTPClient(api_token=API_TOKEN) + AsyncHTTPClient(base_url=API_URL) @respx.mock diff --git a/tests/unit/http/test_client.py b/tests/unit/http/test_client.py index 02b9f632..3dc263ca 100644 --- a/tests/unit/http/test_client.py +++ b/tests/unit/http/test_client.py @@ -28,8 +28,8 @@ def test_http_initialization(mocker): def test_env_initialization(monkeypatch, mocker): - monkeypatch.setenv("MPT_TOKEN", API_TOKEN) - monkeypatch.setenv("MPT_URL", API_URL) + monkeypatch.setenv("MPT_API_BASE_URL", API_URL) + monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) mock_client = mocker.patch("mpt_api_client.http.client.Client") HTTPClient() # act @@ -46,14 +46,11 @@ def test_env_initialization(monkeypatch, mocker): ) -def test_http_without_token(): - with pytest.raises(ValueError): - HTTPClient(base_url=API_URL) +def test_http_without_token(monkeypatch): + monkeypatch.delenv("MPT_API_TOKEN", raising=False) - -def test_http_without_url(): with pytest.raises(ValueError): - HTTPClient(api_token=API_TOKEN) + HTTPClient(base_url=API_URL) @respx.mock diff --git a/tests/unit/test_mpt_client.py b/tests/unit/test_mpt_client.py index 25af1fef..5001f438 100644 --- a/tests/unit/test_mpt_client.py +++ b/tests/unit/test_mpt_client.py @@ -44,8 +44,8 @@ def test_mpt_client(resource_name: str, expected_type: type) -> None: def test_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("MPT_URL", API_URL) - monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + monkeypatch.setenv("MPT_API_BASE_URL", API_URL) + monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) result = MPTClient() @@ -74,8 +74,8 @@ def test_async_mpt_client(resource_name: str, expected_type: type) -> None: def test_async_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("MPT_URL", API_URL) - monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + monkeypatch.setenv("MPT_API_BASE_URL", API_URL) + monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) result = AsyncMPTClient()