Skip to content

Commit 51da7fa

Browse files
committed
Write the documentation (including for the LLMs)
Signed-off-by: Sergey Vasilyev <[email protected]>
1 parent 6be8009 commit 51da7fa

23 files changed

+2231
-517
lines changed

.readthedocs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ build:
99
jobs:
1010
install:
1111
- pip install --upgrade pip
12-
- pip install --group dev --group docs -e .
12+
- pip install --group docs -e .
1313
sphinx:
1414
configuration: docs/conf.py
1515
builder: "dirhtml"

README.md

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,28 @@
1-
# Kubernetes Mock Server in Python
1+
# HTTP/API/Kubernetes Mock Server for Python
22

33
— Kmock-kmock…
44

55
— Who's there?
66

77
— It's me, a long awaited Kubernetes API mock server!
88

9-
The rationale behind the library itself is simple: monkey-patching is bad. It makes you test the specific implementation of your HTTP/API client, not the overall communication contract. Realistic servers are an acceptable compromise. The trade-off is only the overhead for the localhost network traffic & HTTP/JSON protocol rendering & parsing. The obvious flaw: you can make mistakes in assumptions what is the supposed response of the remote system.
10-
11-
The rationale behind the library's DSL is simple too: tests must be brief. Brief tests require brief setup & brief assertions. Extensive logic, such as for-cycles, if-conditions, temporary variables, talking with external classes, so on — all this verbosity distracts from the test purpose, leading to fewer tests being written in total.
9+
The main practical purpose is testing Kubernetes operators developed with Kopf, operators migrated to or from Kopf (whether Pythonic or not), and for testing Kopf itself. Kopf is a framework to write Kubernetes operators in Python: https://github.com/nolar/kopf
1210

11+
Developers can use KMock to test any arbitrary HTTP APIs, SDKs, or apps — even without Kubernetes nuances (simply ignore the Kubernetes functionality).
1312

14-
## All the worst practices at your service
13+
The rationale behind the library design is simple: monkey-patching is bad. It makes you test the specific implementation of your HTTP/API client, not the overall communication contract. Realistic servers are an acceptable compromise. Unlike with realistic servers, which require heavy deployment, KMock works out of the box, and the only trade-off is the overhead for the localhost network traffic & HTTP/JSON rendering & parsing. The obvious flaw: you can make mistakes in your assumptions of what is the supposed response of the server.
1514

16-
* BECAUSE-I-CAN-driven development — nobody needs it, nobody asked for it.
17-
* Not invented here — there are other alike tools, but I did not like them.
18-
* A 3-week side project 3 years in the making, 90% ready since week four.
19-
* Overengineered from day one.
20-
* Python-based DSL with exsessively overloaded syntax tricks.
21-
* Side effects in supposedly computational operators (`<<`, `>>`).
22-
* Kubernetes in Python. (Who on Earth does that?!)
23-
* Lower-cased naming as in Python builtins rather than CamelCase conventions.
24-
* Around 200%, if not 300% test coverage (some aspects tested twice or more).
25-
* Packaged with setuptools — old but gold.
26-
* Mostly hand-made by organic humans: no code formatters, not much AI.
27-
* Thoughtless AI code & tests for some auxiliary low-level algorithms.
28-
* Contributions are not welcome (but you can try).
15+
The rationale behind the library's DSL is simple too: tests must be brief. Brief tests require brief setup & brief assertions. Extensive logic, such as for-cycles, if-conditions, temporary variables, talking with external classes, so on — all this verbosity distracts from the test purpose, leading to fewer tests being written in total.
2916

3017

3118
## Explanation by examples
3219

3320
```python
3421
import aiohttp
22+
import kmock
3523

3624

37-
def function_under_test(base_url: str) -> None:
25+
async def function_under_test(base_url: str) -> None:
3826
async with aiohttp.ClientSession(base_url=base_url) as session:
3927
resp = await session.get('/')
4028
text = await resp.read()
@@ -43,7 +31,7 @@ def function_under_test(base_url: str) -> None:
4331
return data
4432

4533

46-
async def test_me(kmock):
34+
async def test_simple_http_server(kmock: kmock.RawHandler) -> None:
4735
# Setup the server side.
4836
kmock['get /'] << b'john'
4937
kmock['post /hello'] << (lambda req: {'you-are': req.params.get('name', 'anonymous')})
@@ -60,20 +48,18 @@ async def test_me(kmock):
6048
assert kmock['post'][0].data == {'name': 'john'}
6149
```
6250

63-
Even live streaming is possible. See also:
64-
65-
* [janitor](https://github.com/nolar/janitor) for pytest task- & resource-handling.
51+
Even live streaming is possible.
6652

6753
```python
6854
import datetime
6955
import asyncio
7056
import aiohttp
7157
import freezegun
58+
import kmock
7259

7360

7461
@freezegun.freeze_time("2020-01-01T00:00:00")
75-
async def test_k8s_out_of_the_box(kmock, janitor) -> None:
76-
62+
async def test_k8s_out_of_the_box(kmock: kmock.RawHandler) -> None:
7763
kmock['/'] << (
7864
b'hello', lambda: asyncio.sleep(1), b', world!\n',
7965
{'key': 'val'},
@@ -87,7 +73,8 @@ async def test_k8s_out_of_the_box(kmock, janitor) -> None:
8773
kmock[...] << (lambda: datetime.datetime.now(tz=datetime.UTC).isoformat(), ...)
8874
await asyncio.sleep(1)
8975

90-
janitor.run(pulse())
76+
asyncio.create_task(pulse()) # we do not clean it up here for brevity
77+
9178
async with aiohttp.ClientSession(base_url='http://localhost', read_timeout=5) as session:
9279
resp = await session.get('/')
9380
text = await resp.read() # this might take some time
@@ -99,15 +86,14 @@ And even an out-of-box Kubernetes stateful server:
9986

10087
```python
10188
import aiohttp
89+
import kmock
10290
import pytest
10391

104-
10592
@pytest.fixture
10693
def k8surl() -> str:
10794
return 'http://localhost'
10895

109-
110-
def test_k8s_out_of_the_box(kmock, k8surl: str) -> None:
96+
async def test_k8s_out_of_the_box(kmock: kmock.RawHandler, k8surl: str) -> None:
11197
async with aiohttp.ClientSession(base_url=k8surl) as session:
11298
pod1 = {'metadata': {'name': 'pod1'}, 'spec': {'key': 'val'}}
11399
pod2 = {'metadata': {'name': 'pod1'}, 'spec': {'key': 'val'}}
@@ -119,5 +105,5 @@ def test_k8s_out_of_the_box(kmock, k8surl: str) -> None:
119105

120106
assert len(kmock[kmock.LIST]) == 1
121107
assert len(kmock[kmock.resource['pods']]) == 3
122-
assert kmock[kmock.resource['pods']][-1].method == 'GET'
108+
assert kmock[kmock.resource('v1/pods')][-1].method == 'GET'
123109
```

TODO.md

Lines changed: 0 additions & 45 deletions
This file was deleted.

docs/assertions.rst

Lines changed: 75 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,97 +2,112 @@
22
Assertions
33
==========
44

5-
.. highlight:: python
5+
Requests spies
6+
==============
67

7-
Request logging
8-
===============
8+
The results of the ``<<`` or ``>>`` operations of type :class:`kmock.Reaction` can be preserved into variables and later used in assertions — a typical "spy" or a "checkpoint" pattern.
99

10-
All incoming requests, including their parsed interpretations, are recorded for further assertions in tests and can be accessed either via the mock handler itself or via individual rules (i.e. ``kmock`` or ``kmock << …``).
10+
Note that every such filter instance keeps its own list of requests served, so a repeated filtering on the same criteria will return a new instance with no requests in its log. You should preserve the original filter or reaction to collect & see the requests.
1111

12-
The handler and the rules are iterables of requests that have been served by these rules; for the root handler, that ever arrived. This can be used in assertions::
12+
Spies can be preserved either as simple filters, or as responses with ``None`` as the payload (read as "no payload").
1313

14-
def test_me(kmock):
15-
rule1 = kmock['get /'] << b'hello'
16-
rule2 = kmock['post /'] << b'world'
14+
The root handler and the inidividual filters/reactions, among other things, are sequences of requests of type :class:`kmock.Request` that have been served by these rules; for the root handler, those that ever arrived — even if responded with HTTP 404 Not Found. This can be used in assertions:
1715

18-
funcion_under_test()
16+
.. code-block:: python
1917
20-
all_requests = list(kmock)
21-
requests1 = list(rule1)
22-
requests2 = list(rule2)
23-
assert len(all_requests) == 3
24-
assert requests1[0].path == '/'
25-
assert requests2[-1].path == '/'
18+
import kmock
2619
27-
The requests are of type :class:`kmock.Request`.
20+
async def test_requests_assertions(kmock: kmock.RawHandler) -> None:
21+
# Setup the responses and preserve the spies for later assertions.
22+
gets = kmock['get'] # just a filter, no response/reaction
23+
posts = kmock['post'] << b'hello' # a filter with response/reaction
2824
29-
For convenience, the requests can be compared directly to any value that otherwise could be used in the handler filters (i.e., ``kmock[…]``). In particular, regular strings are automatically recognized as either an HTTP verb or path, or as a Kubernetes action or resource::
25+
# Simulate the activity of the system-under-test.
26+
await kmock.get('/info')
27+
await kmock.post('/data', data={'key': 'val'})
28+
await kmock.delete('/info')
3029
31-
def test_me(kmock):
32-
rule1 = kmock['get /'] << b'hello'
33-
rule2 = kmock['post /'] << b'world'
30+
# Check the overall number of requests globally & filtered.
31+
assert len(kmock) == 3
32+
assert len(gets) == 1
33+
assert len(posts) == 1
3434
35-
funcion_under_test()
35+
# Check the 1st request globall and the 1st to the get-filter.
36+
assert gets[0].path == '/info'
3637
37-
requests1 = list(rule1)
38-
requests2 = list(rule2)
39-
assert requests1[0] == 'get'
40-
assert requests2[-1] == '/'
38+
# Check the 2nd request globally or the 1st one to the post-filter.
39+
assert posts[0].path == '/data'
40+
assert posts[0].data == {'key': 'val'}
4141
42+
# Check the 3rd request globally (zero-based index 2).
43+
assert kmock[2].method == kmock.method.DELETE
44+
assert kmock[2].path == '/info'
4245
43-
Server-side errors
44-
==================
46+
To make a spy which responds with an empty body and stops matching the following filters, explicitly mention ``b""`` as the content, so that it does not go to the following filters at all.
4547

46-
To catch server-side errors, usually coming from the callbacks of the mock server itself, ``kmock.errors`` (a list of exceptions) can be asserted to be empty.
48+
.. code-block:: python
4749
48-
Alternatively, the mock server can be created with the ``strict=True`` option. In this case, the assertion will be performed on the client (test) side when closing the fixture. However, the server shutdown usually happens outside of the test, so explicit checking might be more preferable in some cases.
50+
import kmock
4951
50-
The ``StopIteration`` exception is not escalated, it is processed internally to deactivate the response handler as "depleted".
52+
async def test_spies_with_payloads(kmock: kmock.RawHandler) -> None:
53+
get1 = kmock['get'] << b'' # this one will intercept all requests
54+
get2 = kmock['get /'] << b'' # this will never be matched
5155
56+
await kmock.get('/')
5257
53-
Kubernetes objects
54-
==================
58+
assert len(get1) == 1
59+
assert len(get2) == 0
60+
61+
62+
Unexpected errors
63+
=================
64+
65+
Errors list
66+
-----------
5567

56-
:class:`KubernetesEmulator` —the in-memory stateful database of objects— exposes the property ``.objects`` to either pre-populate the server's database or to assert it after the test.
68+
To catch server-side errors usually coming from the callbacks of the mock server itself, :attr:`kmock.RawHandler.errors` (a list of exceptions) can be asserted to be empty.
5769

58-
Objects are accessed with a 3- or 4-item key: the resource, the namespace, the name of the object, and an optional version index (an integer or a slice).
70+
.. code-block:: python
5971
60-
The objects are stored and fetched "as is", i.e. the server does not add or remove any implicit fields, such as the metadata, namespaces, names. These values are taken from the URL and used in the keys only. However, it uses some fields from the payload to retrieve the values of they cannot be figured from the URL — for example, the name of a newly created object.
72+
import kmock
6173
62-
To pre-populate the server::
74+
async def test_errors_assertions(kmock: kmock.RawHandler) -> None:
75+
kmock['/'] << ZeroDivisionError('boo!')
6376
64-
def test_me(kmock):
65-
kmock.objects[resource, 'ns1', 'n1'] = {'spec': 'val1'}
66-
kmock.objects[resource, 'ns1', 'n2'] = {'spec': 'val2'}
67-
function_under_test()
77+
resp = await kmock.get('/')
78+
assert resp.status == 500
6879
69-
To assert the objects after the test::
80+
assert len(kmock.errors) == 1
81+
assert str(kmock.errors[0]) == 'boo!'
82+
assert isinstance(kmock.errors[0], ZeroDivisionError)
7083
71-
def test_me(kmock):
72-
function_under_test()
73-
assert kmock.objects[resource, 'ns1', 'n1']['spec'] == 'val1'
74-
assert kmock.objects[resource, 'ns1', 'n2']['spec'] == 'val2'
7584
76-
To access the selected versions of the object as it was manipulated via the API, use either ``.history[idx]``, or the 4th item in the main key. The usual magic with negative indexes or slices works::
85+
Strict mode
86+
-----------
7787

78-
def test_me(kmock):
79-
function_under_test()
88+
Alternatively, the mock server can be created with the :attr:`kmock.RawHandler.strict` set t ``True`` option. In this case, the assertion will be performed on the client (test) side when closing the fixture. However, the server shutdown usually happens outside of the test, so explicit checking might be more preferable in some cases.
8089

81-
assert kmock.objects[resource, 'ns1', 'n1', 0]['spec'] == 'val1'
82-
assert kmock.objects[resource, 'ns1', 'n2'].history[-1]['spec'] == 'val2'
90+
in this example, the test will fail: while the main test body will succeed as all expectations are met, the test's teardown will raise an exception ``ZeroDivisionError`` because it has happened in the request and was remembered (assume it is some unexpected error, despite simulated intentionally for the sake of the example):
8391

84-
assert kmock.objects[resource, 'ns1', 'n1', :] == [{'spec': 'val1'}]
85-
assert kmock.objects[resource, 'ns1', 'n2'].history[:] == [{'spec': 'val2'}]
92+
.. code-block:: python
8693
87-
To avoid the dependency on very specific payloads generated by some realistic API clients, use the partial dict matching instead of the precise equality — with set-like ``<=`` and ``>=`` operations on objects::
94+
import kmock
8895
89-
def test_me(kmock):
90-
function_under_test()
91-
assert kmock.objects[resource, 'ns1', 'n1', 0] >=
92-
assert {'spec': 'val2'} <= kmock.objects[resource, 'ns1', 'n2', -1]
96+
@pytest.mark.kmock(strict=True)
97+
async def test_errors_assertions(kmock: kmock.RawHandler) -> None:
98+
kmock['/'] << ZeroDivisionError('boo!')
9399
94-
Mind that the object should always be the "greater" operand, the pattern as the "lesser" operand. Usually, the object contains more fields than required by the pattern. But all fields present in the pattern MUST match. In other words, a pattern must be a sub-dict of the object.
100+
resp = await kmock.get('/')
101+
assert resp.status == 500
102+
103+
.. note::
104+
105+
The :class:`StopIteration` exception is not escalated, it is processed internally to deactivate the response handler as "depleted".
106+
107+
108+
Kubernetes objects
109+
==================
95110

96-
Nested sub-dicts are also matched partially, recursively — either by selecting them by the key, or by adding them into the pattern.
111+
:class:`KubernetesEmulator` —the in-memory stateful database of objects— exposes the property :attr:`kmock.KubernetesEmulator.objects` to either pre-populate the server's database or to assert it after the test which uses the API.
97112

98-
Beware: accessing objects and sub-dicts always returns a dict-like wrapper instead of the originally stored dict. This includes iterating over object's fields, and converting them to ``dict()`` directly. To unwrap, use ``.raw``: e.g. ``obj.raw`` or ``obj['metadata'].raw``.
113+
It is explained in detail in :doc:`/kubernetes/assertions` (and, to the extent needed, in :doc:`/kubernetes/persistence`).

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
'sphinx.ext.extlinks',
2525
'sphinx.ext.linkcode',
2626
'sphinx.ext.intersphinx',
27+
'sphinx_llm.txt',
2728
]
2829

2930
templates_path = ['_templates']

0 commit comments

Comments
 (0)