diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..a7884c0 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + # manually triggered + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + env: + UV_PYTHON: ${{ matrix.python-version }} + + steps: + - name: Checkout the repository + uses: actions/checkout@main + - name: Install the default version of uv + id: setup-uv + uses: astral-sh/setup-uv@v3 + - name: Print the installed version + run: echo "Installed uv version is ${{ steps.setup-uv.outputs.uv-version }}" + - name: Install Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Tests + run: | + uv venv + uv pip install ".[testing]" + .venv/bin/pytest tests + .venv/bin/pylint --rcfile=.pylintrc mailboxer tests + + publish: + if: startsWith(github.ref, 'refs/tags/') + needs: test + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install hatch + run: pip install hatch + + - name: Build package + run: hatch build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true + skip-existing: true diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..3c47301 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,10 @@ +[MESSAGES CONTROL] +disable=missing-docstring,too-many-public-methods,too-few-public-methods,no-self-use,import-error,locally-disabled,invalid-name,wrong-import-order,,bad-option-value,ungrouped-imports,too-many-arguments,useless-object-inheritance,consider-using-f-string,unnecessary-lambda-assignment,raise-missing-from,unspecified-encoding + +[FORMAT] +max-line-length=120 + +[REPORTS] +reports=no +score=no +output-format=parseable diff --git a/Makefile b/Makefile index cecf198..29f6e5f 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,9 @@ default: test test: env - .env/bin/pytest -x tests + .venv/bin/pytest -x tests -travis_test: env - PYTHONPATH=.env/mailboxer .env/bin/pytest - -env: .env/.up-to-date - - -.env/.up-to-date: setup.py Makefile - python -m virtualenv .env - .env/bin/pip install -e ".[testing]" - touch $@ +env: + uv venv + uv pip install -e ".[testing]" diff --git a/mailboxer/mailboxer.py b/mailboxer/mailboxer.py index 55ee983..7aa48f7 100644 --- a/mailboxer/mailboxer.py +++ b/mailboxer/mailboxer.py @@ -5,10 +5,9 @@ from .query import Query -class Mailboxer(object): - +class Mailboxer: def __init__(self, url): - super(Mailboxer, self).__init__() + super().__init__() self.url = URL(url).add_path("v2") def create_mailbox(self, address): @@ -18,7 +17,7 @@ def create_mailbox(self, address): def delete_mailbox(self, address): return self.get_mailbox(address).delete() - def get_emails(self, address, unread = False): + def get_emails(self, address, unread=False): return self.get_mailbox(address).get_emails(unread) def get_mailboxes(self, **kwargs): @@ -31,48 +30,56 @@ def does_mailbox_exist(self, address): return Mailbox(self, address).exists() def _post(self, url, data): - returned = requests.post(url, data=json.dumps(data), - headers={"Content-type": "application/json"}) + returned = requests.post( + url, + data=json.dumps(data), + headers={"Content-type": "application/json"}, + timeout=30, + ) returned.raise_for_status() return returned def _get_paged(self, url, obj): - response = requests.get(url) + response = requests.get(url, timeout=30) response.raise_for_status() return [obj(data) for data in response.json()["result"]] def _mailbox_url(self, address): return self.url.add_path("mailboxes").add_path(address) -class Mailbox(object): +class Mailbox: def __init__(self, mailboxer, address): - super(Mailbox, self).__init__() + super().__init__() self.mailboxer = mailboxer self.address = address self.url = self.mailboxer.url.add_path("mailboxes").add_path(self.address) @classmethod - def from_query_json(cls, mailboxer, json): + def from_query_json(cls, mailboxer, json): # pylint: disable=redefined-outer-name return cls(mailboxer, json["address"]) - def get_emails(self, unread = False): - url = self.url.add_path("unread_emails") if unread else self.url.add_path("emails") - return self.mailboxer._get_paged(url, Email) + def get_emails(self, unread=False): + url = ( + self.url.add_path("unread_emails") + if unread + else self.url.add_path("emails") + ) + return self.mailboxer._get_paged(url, Email) # pylint: disable=protected-access def exists(self): url = self.url.add_path("emails") - response = requests.get(url) - if(response.status_code == requests.codes.not_found): + response = requests.get(url, timeout=30) + if response.status_code == requests.codes.not_found: # pylint: disable=no-member return False response.raise_for_status() return True def delete(self): - requests.delete(self.url).raise_for_status() + requests.delete(self.url, timeout=30).raise_for_status() -class Email(object): +class Email: def __init__(self, email_dict): - super(Email, self).__init__() + super().__init__() self.__dict__.update(email_dict) diff --git a/mailboxer/query.py b/mailboxer/query.py index 7be2a89..ee7ff7d 100644 --- a/mailboxer/query.py +++ b/mailboxer/query.py @@ -1,11 +1,11 @@ import requests -class Query(object): +class Query: _metadata = None def __init__(self, client, url, objtype, page_size=100): - super(Query, self).__init__() + super().__init__() self.client = client self.url = url.set_query_param("page_size", str(page_size)) self.objtype = objtype @@ -14,7 +14,9 @@ def __init__(self, client, url, objtype, page_size=100): def _fetch_page(self, page_index): assert page_index > 0 - result = requests.get(self.url.set_query_param("page", str(page_index))) + result = requests.get( + self.url.set_query_param("page", str(page_index)), timeout=30 + ) result.raise_for_status() result_json = result.json() if self._objects is None: @@ -28,7 +30,7 @@ def __iter__(self): def get_json_objects(self): for i in range(len(self)): - obj = self._objects[i] + assert self._objects is not None if self._objects[i] is None: self._fetch_page((i // self.page_size) + 1) assert self._objects[i] is not None @@ -37,4 +39,5 @@ def get_json_objects(self): def __len__(self): if self._objects is None: self._fetch_page(1) + assert self._objects is not None return len(self._objects) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..457c2f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling>=0.25.1", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "mailboxer-python" +description = "Client library for Mailboxer" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "BSD 3-Clause License" } + +classifiers = [ + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +dependencies = ["requests", "URLObject"] +dynamic = ["version"] + +authors = [{ name = "Rotem Yaari", email = "vmalloc@gmail.com" }] + +[project.urls] +"Homepage" = "https://github.com/getslash/mailboxer-python" + +[project.optional-dependencies] +testing = ["dataclasses_json", "Flask-Loopback", "pylint", "pytest"] + +[tool.hatch.build.targets.wheel] +packages = ["mailboxer"] + +[tool.hatch.version] +source = "vcs" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f9a8b81..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests -URLObject diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7d111ec..0000000 --- a/setup.cfg +++ /dev/null @@ -1,22 +0,0 @@ -[metadata] -name = mailboxer-python -classifiers = - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 -summary = Client library for Mailboxer -description-file = - README.md -description-content-type = text/markdown -license = BSD -author = Rotem Yaari -author_email = vmalloc@gmail.com -url = https://github.com/getslash/mailboxer-python - - -[extras] -testing = - pylint - flask-loopback - pytest diff --git a/setup.py b/setup.py deleted file mode 100644 index 1cc6594..0000000 --- a/setup.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python -from setuptools import setup - -setup( - setup_requires=['pbr>=3.0', 'setuptools>=17.1'], - pbr=True, - python_requires=">=3.6.*", - long_description_content_type='text/markdown; charset=UTF-8', -) diff --git a/tests/conftest.py b/tests/conftest.py index 130b33f..95797bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,3 @@ -import os -import shutil -import subprocess -import sys -import tempfile import uuid from urlobject import URLObject as URL @@ -11,63 +6,42 @@ import pytest from mailboxer import Mailboxer -sys.path.insert(0, os.path.join( - os.path.abspath(os.path.dirname(__file__)), - "..", ".env", "mailboxer")) -from flask_app.app import create_app -from flask_app import models +from .flask_app import create_app, app_initializations -def pytest_addoption(parser): - parser.addoption("--setup-db", action="store_true", default=False) -@pytest.fixture(scope="session") -def db_engine(request): - if request.config.getoption("--setup-db"): - tmpdir = tempfile.mkdtemp() - subprocess.check_call("pg_ctl init -D {0} -w".format(tmpdir), shell=True) - subprocess.check_call("pg_ctl start -D {0} -w".format(tmpdir), shell=True) - - @request.addfinalizer - def finalize(): - subprocess.check_call("pg_ctl stop -D {0} -w -m immediate".format(tmpdir), shell=True) - shutil.rmtree(tmpdir) - - subprocess.check_call("createdb mailboxer", shell=True) - - with create_app().app_context(): - models.db.session.close() - models.db.drop_all() - models.db.create_all() - - -@pytest.fixture(scope="session") -def mailboxer_url(request, db_engine): - loopback = FlaskLoopback(create_app()) +@pytest.fixture(scope="session", name="mailboxer_url") +def mailboxer_url_fx(): + app = create_app() + loopback = FlaskLoopback(app) hostname = str(uuid.uuid1()) - loopback.activate_address((hostname, 80)) - @request.addfinalizer - def close(): - loopback.deactivate_address((hostname, 80)) - return URL("http://{0}".format(hostname)) + port = 80 + loopback.activate_address((hostname, port)) + with app.app_context(): + app_initializations() + yield URL(f"http://{hostname}:{port}") + loopback.deactivate_address((hostname, port)) -@pytest.fixture -def mailboxer(mailboxer_url): + +@pytest.fixture(name="mailboxer") +def mailboxer_fx(mailboxer_url): return Mailboxer(mailboxer_url) -@pytest.fixture( - params=[10] - ) -def num_objects(request): + +@pytest.fixture(params=[10], name="num_objects") +def num_objects_fx(request): return request.param + @pytest.fixture def mailbox(request, mailboxer): returned = mailboxer.create_mailbox("mailbox@mailboxer.com") request.addfinalizer(returned.delete) return returned + @pytest.fixture def mailboxes(mailboxer, num_objects): return [ mailboxer.create_mailbox("mailbox{0}@mailboxer.com".format(i)) - for i in range(num_objects)] + for i in range(num_objects) + ] diff --git a/tests/flask_app.py b/tests/flask_app.py new file mode 100644 index 0000000..e4f4302 --- /dev/null +++ b/tests/flask_app.py @@ -0,0 +1,86 @@ +import datetime +import dataclasses +from dataclasses_json import DataClassJsonMixin +from itertools import count +from typing import Any, Dict, List +from flask import Flask, g, request, jsonify + + +@dataclasses.dataclass +class Mailbox(DataClassJsonMixin): + id: int + address: str + last_activity: str + + +@dataclasses.dataclass +class Email(DataClassJsonMixin): + id: int + mailbox_id: int + fromaddr: str + message: str + timestamp: str + sent_via_ssl: bool + read: bool + + +def app_initializations() -> None: + g.mailboxes = {} + g.emails = {} + g.mailboxes_count = count(1) + + +def create_app() -> Flask: + app = Flask("MailboxerSimulation") + + def _get_success_response() -> Dict[str, str]: + return {"result": "ok"} + + def _get_paginated_response(objects: List[Any]) -> Dict[str, Any]: + page = request.args.get("page", default=1, type=int) + page_size = request.args.get("page_size", default=1000, type=int) + result_objects = objects[(page - 1) * page_size : page_size * page] + return { + "metadata": {"total_num_objects": len(objects)}, + "result": result_objects, + } + + def now() -> str: + return datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + + @app.route("/v2/mailboxes", methods=["GET"]) + def query_mailboxes(): + sorted_mailboxes = sorted( + g.mailboxes.values(), key=lambda mailbox: mailbox.address + ) + return _get_paginated_response(sorted_mailboxes) + + @app.route("/v2/mailboxes", methods=["POST"]) + def create_mailbox(): + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Not JSON body"}), 400 + address = data.get("address") + if not isinstance(address, str): + return jsonify({"error": "Invalid address {address!r}"}), 400 + + g.mailboxes[address] = Mailbox( + id=next(g.mailboxes_count), address=address, last_activity=now() + ) + return _get_success_response() + + @app.route("/v2/mailboxes/
", methods=["DELETE"]) + def delete_mailbox(address: str): + g.mailboxes.pop(address, None) + return _get_success_response() + + @app.route("/v2/mailboxes/
/emails", methods=["GET"]) + def query_mailbox_emails(address: str): + emails = g.emails.get(address, []) + return _get_paginated_response(emails) + + @app.route("/v2/vacuum", methods=["POST"]) + def vacuum(): + return _get_success_response() + + return app