Skip to content

farmOS.py revisited: Design/collaboration proposal - hybrid sync/async, pluggable authentication/reactors #31

Description

@symbioquine

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;

None/throws

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;

None/throws

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.

2020_02_11_farmos_py_issue_31_top_level_module

Asynchronous

2020_02_11_farmos_py_issue_31_async

Synchronous

2020_02_11_farmos_py_issue_31_sync

Asynchronous Sensors

2020_02_11_farmos_py_issue_31_sensor_async

Synchronous Sensors

2020_02_11_farmos_py_issue_31_sensor_sync

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions