I've been digging into possible farmOS.py API structures and (relatively) "recent" improvements in Python's support for async co-routines as prompted by the discussion on symbioquine/farm-os-area-feature-proxy#1.
I plan to use this issue to capture my thoughts and see if we can converge on a design for a next major version of this library that could support a wider range of use-cases more gracefully.
Design goals
- Fully abstract Drupal API semantics and return types
- Support all major farmOS data types which can be accessed via a vanilla farmOS installation's APIs
- Support both synchronous and asynchronous usage with same API and code base
- Support pagination/iteration elegantly for all record types
- Map elegantly/efficiently between the proposed Python API and the underlying farmOS transport API(s)
- "Batteries included" extension strategy
- Accept extensions for common functionality (async/OAuth2/etc) in-repo without creating dependencies
- Model extensions so it is easy to use off-repo extensions where functionality is less general or cannot be included for code/license reasons
- Pluggable authentication support without otherwise affecting API interface; session cookie, basic auth, OAuth2, etc
- Pluggable transport backend without otherwise affecting API interface; requests, AIOHTTP, Twisted, etc
- Make it easy to maintain near full test coverage
API Sketching
Synchronous Usage
Simple login/password session client;
import farmOS
with farmOS.create_session_client(url="http://farmos.local", username="root", password="test") as farm:
print(farm.info())
Yields;
FarmOSFarmInfo(name='Test0', url='http://farmos.local', api_version='1.1')
OAuth2 client;
import farmOS
# TODO What should the parameters be... presumably this needs to support Authorization/PKCE flows for both interactive console and non-interactive console + configuration of scope/grant-type/urls/etc
with farmOS.create_oauth2_client(url="http://farmos.local", ...) as farm:
Asynchronous Usage
All API usage should be identical except that;
- Different
farmOS.create_*_client methods are used to create the client
await is used for calling all methods which directly return a value
async with is used anywhere you would use with in synchronous usage
async for is used when iterating over pages of results - or derivatives thereof
import asyncio, farmOS
async def main():
async with farmOS.create_asyncio_session_client(url="http://farmos.local", username="root", password="test") as farm:
print(await farm.info())
asyncio.run(main())
Areas
By Id
farm.area.get_by_id(record_id=8)
Yields;
FarmOSArea(area_id='8', area_type='field', name='cactus_field', description='<p>This is the description!</p>\n', flags=['priority', 'monitor', 'review'])
Pagination
page = farm.area.query_page(filters={'area_type': 'field'})
while page:
for area in page:
print(area)
page = await page.next_page()
or
for page in farm.area.iterate_query_pages(filters={'area_type': 'field'}):
for area in page:
print(area)
Yields;
FarmOSArea(area_id='8', area_type='field', name='cactus_field', description='<p>This is the prickly description!</p>\n', flags=['priority', 'monitor'])
FarmOSArea(area_id='9', area_type='field', name='boulder_field', description='<p>This is the cold hard description!</p>\n', flags=['review'])
Iteration
for area in farm.area.iterate_query(filters={'area_type': 'field'}):
print(area)
Yields;
FarmOSArea(area_id='8', area_type='field', name='cactus_field', description='<p>This is the prickly description!</p>\n', flags=['priority', 'monitor'])
FarmOSArea(area_id='9', area_type='field', name='boulder_field', description='<p>This is the cold hard description!</p>\n', flags=['review'])
Creation
farm.area.create(FarmOSArea(area_type='field', name='neon_field', description='<p>This is the vibrant description!</p>\n'))
Yields;
FarmOSArea(area_id='10', area_type='field', name='neon_field', description='<p>This is the vibrant description!</p>\n', flags=[])
Updates
farm.area.update(FarmOSArea(area_id='10', description='<p>This is the really vibrant description!</p>\n'))
Yields;
Deletion
farm.area.delete(FarmOSArea(area_id='8', area_type='field', name='cactus_field', description='<p>This is the prickly description!</p>\n', flags=['priority', 'monitor']))
or
farm.area.delete_by_id(record_id='8')
Yields;
How to support sync/async in the same code base
The layers that do most of the heavy lifting get written in the async style, but have no concrete external dependencies themselves.
These classes get put into python files named async_*.py and the classes are named following the pattern Async*.
Then we generate the non-async versions from that with some code seen below. The top level of the farmOS library then provides static factory methods for wiring together the various permutations of the sync/async client layers.
import glob
import os.path
import libcst as cst
# Vaguely based on https://stackoverflow.com/a/55365529
class AsyncToSync(cst.CSTTransformer):
def leave_FunctionDef(self, old_node, node):
return node.with_changes(asynchronous=None)
def leave_For(self, old_node, node):
return node.with_changes(asynchronous=None)
def leave_CompFor(self, old_node, node):
return node.with_changes(asynchronous=None)
def leave_With(self, old_node, node):
return node.with_changes(asynchronous=None)
def leave_Await(self, old_node, node):
return node.expression
def leave_Name(self, old_node, node):
if node.value == 'AbstractAsyncContextManager':
return node.with_changes(value='AbstractContextManager')
if node.value == '__aenter__':
return node.with_changes(value='__enter__')
if node.value == '__aexit__':
return node.with_changes(value='__exit__')
if not node.value.startswith("Async"):
return node
return node.with_changes(value=node.value[5:]) # Remove "Async" prefix
def leave_ImportFrom(self, old_node, node):
if not type(node.module) is cst.Attribute:
return node
if not type(node.module.attr) is cst.Name:
return node
if not node.module.attr.value.startswith("async_"):
return node
return node.with_changes(
module=node.module.with_changes(
attr=node.module.attr.with_changes(
value=node.module.attr.value[6:])))
for filename in glob.glob('./farmOS/**/async_*.py', recursive=True):
print(filename)
with open(filename, 'r') as ifp:
source_tree = cst.parse_module(ifp.read())
modified_tree = source_tree.visit(AsyncToSync())
base_filename = os.path.basename(filename)
output_filename = os.path.join(
os.path.dirname(filename), base_filename[6:])
with open(output_filename, 'w') as ofp:
ofp.write(
"# Generated from {!r} do not modify by hand.\n".format(base_filename))
ofp.write(modified_tree.code)
Structure
Diagram .png files include embedded draw.io source.

Asynchronous

Synchronous

Asynchronous Sensors

Synchronous Sensors

FAQs
It seems like you are trying to "boil the ocean" with this issue... is all this tractable to tackle as a single "issue"?
Maybe not. I expect the API described here to take a lot of work to implement, document, and test thoroughly.
My purpose as mentioned above is to polish a vision for how the farmOS Python API should be. If we can align on that vision, a branch can be used to track the development against that vision until it can be released as a new major version.
Why expose pagination explicitly?
This gives consumers of the farmOS.py library the flexibility to handle large amounts of data in/from farmOS performantly. It would be really challenging or impossible to completely hide the pagination and still provide that flexibility.
Why are there no "get all" or "query all" methods which return collections, only pagination and iteration?
This would unnecessarily increase the "surface area" of the API while encouraging unperformant usage patterns that hold all entities/records in memory.
Consumers which require the entities/pages as collections can easily do so themselves;
all_areas = list(farm.area.iterate_query(filters={'area_type': 'field'}))
Will those static factory methods on the top-level module of farmOS explode combinatorially as the library supports more kinds of transports, auth mechanisms, etc?
Possibly, but this also lets us be opinionated about the supported combinations and provide concrete documentation with embedded code examples for each one.
How would the proposed API support retrieving multiple records by id in a single request - i.e. #14?
Naively, it would seem that this should be supported by the get_by_id methods - e.g. on FarmOSTaxonomyClient - however that would probably be a mistake since that would make the return type different depending whether a single or multiple ids are passed.
A better approach would be to support this via the filters argument to the query_page, iterate_query_pages, iterate_query methods. This conveniently fits into both the proposed python API (which already return multiple records for those methods) and the existing Drupal entity "transport" API simply by adding a simple requirement for the handling of the filters argument;
The values in the filters dictionary may be either a single value, or an iterable of values. If it is an iterable of values then the result will be the union of the results of the query run separately with each value passed singly - assuming no changes occurred between the two queries.
Thus the example from #14 would be supported by the proposed API as follows;
for log in farm.log.iterate_query(filters={'id': ['163', '167']}):
print(log)
Calls farmOS as;
https://farmos.local/log.json?id[]=163&id[]=167
Conveniently, this also allows for other kinds of cool queries to execute efficiently from a transport API perspective;
for log in farm.log.iterate_query(filters={'type': ['farm_harvest', 'farm_observation']}):
print(log)
Calls farmOS as;
https://farmos.local/log.json?type[]=farm_harvest&type[]=farm_observation
References
I've been digging into possible farmOS.py API structures and (relatively) "recent" improvements in Python's support for async co-routines as prompted by the discussion on symbioquine/farm-os-area-feature-proxy#1.
I plan to use this issue to capture my thoughts and see if we can converge on a design for a next major version of this library that could support a wider range of use-cases more gracefully.
Design goals
API Sketching
Synchronous Usage
Simple login/password session client;
Yields;
FarmOSFarmInfo(name='Test0', url='http://farmos.local', api_version='1.1')OAuth2 client;
Asynchronous Usage
All API usage should be identical except that;
farmOS.create_*_clientmethods are used to create the clientawaitis used for calling all methods which directly return a valueasync withis used anywhere you would usewithin synchronous usageasync foris used when iterating over pages of results - or derivatives thereofAreas
By Id
Yields;
FarmOSArea(area_id='8', area_type='field', name='cactus_field', description='<p>This is the description!</p>\n', flags=['priority', 'monitor', 'review'])Pagination
or
Yields;
Iteration
Yields;
Creation
Yields;
FarmOSArea(area_id='10', area_type='field', name='neon_field', description='<p>This is the vibrant description!</p>\n', flags=[])Updates
Yields;
None/throwsDeletion
or
Yields;
None/throwsHow to support sync/async in the same code base
The layers that do most of the heavy lifting get written in the async style, but have no concrete external dependencies themselves.
These classes get put into python files named
async_*.pyand the classes are named following the patternAsync*.Then we generate the non-async versions from that with some code seen below. The top level of the farmOS library then provides static factory methods for wiring together the various permutations of the sync/async client layers.
Structure
Diagram .png files include embedded draw.io source.
Asynchronous
Synchronous
Asynchronous Sensors
Synchronous Sensors
FAQs
It seems like you are trying to "boil the ocean" with this issue... is all this tractable to tackle as a single "issue"?
Maybe not. I expect the API described here to take a lot of work to implement, document, and test thoroughly.
My purpose as mentioned above is to polish a vision for how the farmOS Python API should be. If we can align on that vision, a branch can be used to track the development against that vision until it can be released as a new major version.
Why expose pagination explicitly?
This gives consumers of the farmOS.py library the flexibility to handle large amounts of data in/from farmOS performantly. It would be really challenging or impossible to completely hide the pagination and still provide that flexibility.
Why are there no "get all" or "query all" methods which return collections, only pagination and iteration?
This would unnecessarily increase the "surface area" of the API while encouraging unperformant usage patterns that hold all entities/records in memory.
Consumers which require the entities/pages as collections can easily do so themselves;
Will those static factory methods on the top-level module of farmOS explode combinatorially as the library supports more kinds of transports, auth mechanisms, etc?
Possibly, but this also lets us be opinionated about the supported combinations and provide concrete documentation with embedded code examples for each one.
How would the proposed API support retrieving multiple records by id in a single request - i.e. #14?
Naively, it would seem that this should be supported by the
get_by_idmethods - e.g. onFarmOSTaxonomyClient- however that would probably be a mistake since that would make the return type different depending whether a single or multiple ids are passed.A better approach would be to support this via the
filtersargument to thequery_page,iterate_query_pages,iterate_querymethods. This conveniently fits into both the proposed python API (which already return multiple records for those methods) and the existing Drupal entity "transport" API simply by adding a simple requirement for the handling of thefiltersargument;Thus the example from #14 would be supported by the proposed API as follows;
Calls farmOS as;
Conveniently, this also allows for other kinds of cool queries to execute efficiently from a transport API perspective;
Calls farmOS as;
References
async/awaitkeywords