Skip to content
This repository was archived by the owner on Sep 8, 2023. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,496 changes: 1,494 additions & 2 deletions README.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion apiens/error/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
""" Base Application errors

Its subclasses are meant to be returned to the end user.
"""

from typing import ClassVar, Optional

from .error_object.python import ErrorObject
Expand Down Expand Up @@ -107,7 +112,7 @@ def format(cls, error: str, fixit: str = None, **info):

@property
def name(self):
""" Name of the exception class """
""" Name of the exception class: error codename """
return self.__class__.__name__

def headers(self, headers: dict):
Expand Down
4 changes: 3 additions & 1 deletion apiens/error/converting/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ def converting_unexpected_errors(*, exc=exc):
""" Convert unexpected Python exceptions into a human-friendly F_UNEXPECTED_ERROR Application Error

This function is a catch-all: every expected error should be an instance of `exc.BaseApplicationError`.
Every other Python error is considered to be unexpected and wrapped into an `exc.F_UNEXPECTED_ERROR`
Every other Python error is considered to be unexpected and wrapped into an `exc.F_UNEXPECTED_ERROR`.

If the exception defines the `default_api_error()` method, the method is used to convert it into a different error (!)

Raises:
exc.F_UNEXPECTED_ERROR: for unexpected Python errors
Expand Down
2 changes: 1 addition & 1 deletion apiens/error/error_object/pydantic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" Pydantic definitions for the Error Object (Typed Dict) """
""" Pydantic definitions for the Error Object (to be used with FastAPI) """

from typing import Optional

Expand Down
2 changes: 1 addition & 1 deletion apiens/error/error_object/python.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" Python definitions for the Error Object (Typed Dict) """
""" TypedDict definitions for the Error Object """

from __future__ import annotations

Expand Down
10 changes: 5 additions & 5 deletions apiens/error/error_object/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ type ErrorObject {

Example:

E_NOT_FOUND:
error: 'Cannot find User by id'
fixit: 'Have you entered the correct id?'
info:
object: 'User'
E_NOT_FOUND:
error: 'Cannot find User by id'
fixit: 'Have you entered the correct id?'
info:
object: 'User'
"""
info: Object!

Expand Down
7 changes: 5 additions & 2 deletions apiens/error/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,11 @@ class F_NOT_IMPLEMENTED(BaseApplicationError):
# endregion


def export_error_catalog(globals: dict[str, Union[type[BaseApplicationError], Any]] = globals()):
""" Get a list of every BaseApplicationError defined in `globals` """
def export_error_catalog(globals: dict[str, Union[type[BaseApplicationError], Any]] = globals()) -> list[type[BaseApplicationError]]:
""" Get a list of every BaseApplicationError defined in `globals`

Use this function to export your list of errors as HTTP JSON API.
"""
return [
value
for name, value in globals.items()
Expand Down
14 changes: 14 additions & 0 deletions apiens/testing/model_match/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
""" Model Match compares models from different libraries to one another

For instance, in your API application you may need to make sure that your
Pydantic schemas match your SqlAlchemy models, and they in turn match
your GraphQL objects.
This matching will help you find typos, especially when it comes to
making sure that your fields are consistently nullable or non-nullable.

Turns out, it's so easy to make a typo.
This module saves you the pain.


"""

from .match import match
from .predicates import include_only, exclude

Expand Down
4 changes: 4 additions & 0 deletions apiens/testing/network_gag.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def network_gag():
Such a failure would tell the developer that their unit-test is not mocked properly.

It stops network connections made by: urllib, urllib3, aiohttp, amazon client

Example:
with network_gag():
... # do your stuff without networking
"""
# Network gag: Amazon
# Because some of our tests use Amazon, we put a show stopper here that fails in that case
Expand Down
10 changes: 7 additions & 3 deletions apiens/testing/object_match/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from .object_match import ObjectMatch
from .dict_match import DictMatch
""" Unit-testing tools for testing values of complex objects """

from .parameter import Parameter
from .unsorted import unsorted, runsorted, kunsorted
from .dict_match import DictMatch
from .check import check
from .okok import Whatever

from .object_match import ObjectMatch

from .unsorted import unsorted, runsorted, kunsorted
2 changes: 2 additions & 0 deletions apiens/testing/object_match/check.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
""" Check: use functions to test nested values of an object """

from collections import abc
from typing import Any

Expand Down
2 changes: 2 additions & 0 deletions apiens/testing/object_match/dict_match.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
""" DictMatch: test a dictionary for a partial match """

from collections import abc


Expand Down
2 changes: 2 additions & 0 deletions apiens/testing/object_match/object_match.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
""" ObjectMatch: test an object for a partial match """

class ObjectMatch:
""" Unit test helper: an object for == comparisons with other objects field by field.

Expand Down
2 changes: 1 addition & 1 deletion apiens/testing/object_match/okok.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" OkOk helpers for unit-tests: okay, okay! we're equal! """
""" Ok Ok: accept whatever values in unit-tests. """


class _Whatever:
Expand Down
2 changes: 2 additions & 0 deletions apiens/testing/object_match/parameter.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
""" Parameter: capture a value of a nested field in a complex object """

class Parameter:
""" Grab a parameter during comparison: e.g. a dynamic primary key

Expand Down
5 changes: 5 additions & 0 deletions apiens/testing/object_match/unsorted.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
""" Unsorted: test lists where items change their positions

This often happens when the back-end returns a set() converted into a list()
"""

from collections import abc
from operator import itemgetter

Expand Down
8 changes: 7 additions & 1 deletion apiens/tools/ariadne/schema/load.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
""" Ariadne: tools for putting a schema together """
""" Ariadne: tools for putting a schema together

Features:

* Load *.graphql files
* Import Ariadne definitions from a Python file
"""

import os.path
from types import ModuleType
Expand Down
10 changes: 10 additions & 0 deletions apiens/tools/ariadne/testing/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
""" Test client for Ariadne applications.

This client offers greater flexibility than the FastAPI GraphQL client because:

* It involves fewer application layers and works faster
* You can customize the context before executing any operations
* Errors are reported as Exceptions (rather than JSON objects) and can be analyzed
* Supports subscriptions :)
"""

from __future__ import annotations

import graphql
Expand Down
2 changes: 1 addition & 1 deletion apiens/tools/fastapi/exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ async def http_404_handler_with_route_suggestions(request: Request, e: HTTPExcep

# In FastAPI, there is no such thing yet as "current route". It's not stored in the request.
# Therefore, we can't tell whether this 404 come from a view or from the router itself.
# So this "not found" page is
# So this "not found" page is detected using error message analysis :(
if e.status_code == status.HTTP_404_NOT_FOUND and e.detail == 'Not Found':
close_matches = suggest_api_endpoint(request.app, request.scope['method'], request.scope['path'])

Expand Down
23 changes: 12 additions & 11 deletions apiens/tools/graphql/errors/human_readable.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
""" Tools that enable rich user-friendly validation of input data

Usage:
from apiens.tools.ariadne.schema import load_schema_from_module
import apiens.tools.ariadne.scalars.date
schema = ariadne.make_executable_schema([
ariadne.load_schema_from_path(os.path.dirname(__file__)),
load_schema_from_module(apiens.tools.ariadne, 'rich_validation.graphql'),
],
apiens.tools.ariadne.scalars.date.definitions,
)
""" Make GraphQL errors more human-readable.

This module improves the way Int, Float and Bool objects report errors.
The new error message is human-readable and can be reported to the end-user.

Before:

> 'message': "Int cannot represent non-integer value: 'INVALID'",

After:

> 'message': "Not a valid number", # Improved, human-readable
"""

from typing import Any
Expand Down
3 changes: 2 additions & 1 deletion apiens/tools/graphql/middleware/documented_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ def documented_errors_middleware(*, ignore_errors: frozenset[str] = DEFAULT_IGNO
Errors:
E_AUTH_REQUIRED: you must be signed in in order to use this API.

When an error is raised, this middleware would check whether docstring has it covered.
When an error is raised, this middleware would check whether the docstring of the field,
or the docstring of the parent object, mentions this error by name.
If not, an UndocumentedError is raised instead.

NOTE: it's an async middleware. It won't work with GraphQL running in sync mode (i.e. using graphql_sync())!
Expand Down
10 changes: 9 additions & 1 deletion apiens/tools/graphql/testing/test_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
""" Test Client for the GraphQL app """
""" Test Client for the GraphQL app

This client offers greater flexibility than the FastAPI GraphQL client because:

* It involves fewer application layers and works faster
* You can customize the context before executing any operations
* Errors are reported as Exceptions (rather than JSON objects) and can be analyzed
* Supports subscriptions :)
"""
from __future__ import annotations

import asyncio
Expand Down
15 changes: 13 additions & 2 deletions apiens/tools/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
""" Helpers for your app configuration

Features:

* Supports three modes: production, development, testing
* Loads configuration from environment variables
* Automatically switches to "testing" when `pytest` is used

Example:
class Settings(apiens.tools.settings.Settings):
import pydantic as pd

class Settings(pd.Settings):
REDIS_URL: str

set_default_environment('ENV', default_environment='dev')
load_environment_defaults_for('ENV')
switch_environment_when_running_tests('ENV')
logging.basicConfig()

settings = Settings()

Check out `mixins` for some configuration values' recipes.
"""

from .defs import Env
Expand All @@ -19,13 +29,14 @@ class Settings(apiens.tools.settings.Settings):
get_environment,
)
from .env_test import switch_environment_when_running_tests
from . import mixins
from . import mixins, logging


# Syntactic sugar to indicate values that will get defaults when not provided
AUTOMATIC = None


# When `pint` is available, we can use `unit`
try:
from .unit import unit
except ImportError as e:
Expand Down
40 changes: 27 additions & 13 deletions apiens/tools/settings/env.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
""" Loads configuration values from the environment

Provides the following features:

* Use an environment variable `VAR_NAME` to choose the current environment (dev, prod, test)
* Load configuration values from `.env` files under `misc/env` and `misc/env.local`
* Load some additional values from `misc/env.local` when running locally (not in Docker)
"""
from __future__ import annotations

import os.path
Expand All @@ -9,19 +17,20 @@
# Environment enum class
ENV_ENUM = Env

# Path to .env files for environments
# Path to .env files for environments: dev.env, prod.env, test.env
ENVS_PATH = 'misc/env/'

# Path to .env files when running locally (as determined by the following function)
# Path to .env files when running locally (not in Docker)
# See `IS_RUNNING_LOCALLY`
ENVS_LOCAL_PATH = 'misc/env.local/'

# Is the application running locally? i.e. not in Docker.
# If so, then `ENVS_LOCAL_PATH` will be loaded too
# Is the application running locally (i.e. not in Docker)?
# When running locally, will load configuration from `misc/env.local`
IS_RUNNING_LOCALLY = int(os.getenv('ENV_RUNNING_LOCALLY', '0'))


def set_default_environment(VAR_NAME: str, *, default_environment: str):
""" Set the default app environment if not set
""" Set the default app environment, if not set already.

Example:
set_default_environment('ENV', default_environment='dev')
Expand All @@ -33,12 +42,11 @@ def set_default_environment(VAR_NAME: str, *, default_environment: str):
def load_environment_defaults_for(VAR_NAME: str):
""" Load .env files for the environment if not already loaded.

The app reads its configuration from the environment.
But in some cases, like debugging with an IDE, this is inconvenient.

For this reason, we *also* load .env files with Python, but it never overrides the existing environment.
It will load values from `.env` files into the environment:

This is a back-up method, not the primary one.
* /misc/env/{name}.env
* /misc/env.local/{name}.env (only when running locally)
* /.{name}.env (only when running locally)
"""
# Current environment
env = get_environment(VAR_NAME)
Expand All @@ -58,12 +66,12 @@ def load_environment_from_file(name: str, *, override: bool):
override: Override existing environment variables?
"""
# Load from `misc/env`
dotenv.load_dotenv(dotenv.find_dotenv(os.path.join(ENVS_PATH, f'{name}.env')), override=override)
_load_dotenv_file(ENVS_PATH, f'{name}.env', override=override)

# Load from `misc/env.local` (only if running locally)
if IS_RUNNING_LOCALLY:
dotenv.load_dotenv(dotenv.find_dotenv(os.path.join(ENVS_LOCAL_PATH, f'{name}.env')), override=override)
dotenv.load_dotenv(dotenv.find_dotenv(f'.{name}.env'), override=override)
_load_dotenv_file(ENVS_LOCAL_PATH, f'{name}.env', override=override)
_load_dotenv_file('.', f'.{name}.env', override=override)


def get_environment(VAR_NAME: str) -> Env:
Expand All @@ -77,3 +85,9 @@ def get_environment(VAR_NAME: str) -> Env:
"""
env: str = os.environ[VAR_NAME]
return ENV_ENUM(env)


def _load_dotenv_file(path: str, filename: str, *, override: bool):
""" Load a .env file in path/filename, if it exists """
file_path = dotenv.find_dotenv(os.path.join(path, filename))
dotenv.load_dotenv(file_path, override=override)
2 changes: 2 additions & 0 deletions apiens/tools/settings/env_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
""" Automatically switch to "testing" mode when a test runner (pytest) is used """

from __future__ import annotations

import os
Expand Down
5 changes: 5 additions & 0 deletions apiens/tools/settings/logging.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
""" Default logging configuration for your convenience

The default logging configuration in Python
"""

import logging.config


Expand Down
Loading