Skip to content

2.x changes #67

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: 1.x
Choose a base branch
from
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
17 changes: 10 additions & 7 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,24 @@ jobs:
strategy:
matrix:
python-version:
- 3.8
- 3.9
- '3.10'
- 3.11
- 3.12
- 3.13
steps:
- name: Checkout the repository
uses: actions/checkout@v3
- name: Create docker-compose.yml
run: curl https://raw.githubusercontent.com/farmOS/farmOS/2.x/docker/docker-compose.development.yml -o docker-compose.yml
run: curl https://raw.githubusercontent.com/farmOS/farmOS/3.x/docker/docker-compose.development.yml -o docker-compose.yml
- name: Start containers
run: docker-compose up -d && sleep 5
run: docker compose up -d && sleep 5
- name: Install farmOS
run: |
docker-compose exec -u www-data -T www drush site-install -y --db-url=pgsql://farm:farm@db/farm --account-pass=admin
docker-compose exec -u www-data -T www drush user-create tester --password test
docker-compose exec -u www-data -T www drush user-add-role farm_manager tester
docker-compose exec -u www-data -T www drush config:set simple_oauth.settings access_token_expiration 15 -y
docker compose exec -u www-data -T www drush site-install -y --db-url=pgsql://farm:farm@db/farm --account-pass=admin
docker compose exec -u www-data -T www drush en farm_api_default_consumer -y
docker compose exec -u www-data -T www drush user-create tester --password test
docker compose exec -u www-data -T www drush user-add-role farm_manager tester
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
Expand All @@ -43,6 +44,8 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install pytest -e .[test]
- name: Check unasync
run: python farmOS/utils/unasync.py --check
- name: Run farmOS.py tests.
run: pytest tests
env:
Expand Down
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@ To install using `conda` see [conda-forge/farmos-feedstock](https://github.com/c

## Documentation

farmOS.py can connect to farmOS servers running version ^1.6 or greater. The version should be specified when instantiating
the farmOS client.

- [1.x documentation](docs/client_1x.md)
- [2.x documentation](docs/index.md)
See [docs/index.md](docs/index.md) or the hosted [farmOS.py documentation](https://farmos.org/development/farmos-py/).

## MAINTAINERS

Expand Down
126 changes: 126 additions & 0 deletions docs/async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Async support

farmOS.py offers support for an async client if you need it. This is made
possible by the [HTTPX](https://www.python-httpx.org/) HTTP Python client that
farmOS.py uses.

The async client may be more efficient when making many requests to the farmOS
server and may be useful when working in an async Python framework.

## Making Async requests

Below are simple examples of using the async client. For more information see
the [HTTPX Async documentation](https://python-httpx.org/async/).

To make asynchronous requests, you'll need an `AsyncFarmClient`.

```python
from farmOS import AsyncFarmClient

hostname, auth = farm_auth
async with AsyncFarmClient(hostname, auth=auth) as farm:
# Get one page of animal assets.
response = await farm.asset.get("animal")
```

### Making requests

All of the standard client resource methods are async, so you should use
`response = await farm_client.resource.get()` style for all of the following:

- `resource.get(entity_type, bundle, params)`
- `resource.get_id(entity_type, bundle, id)`
- `resource.iterate(entity_type, bundle, params)`
- `resource.send(entity_type, bundle, payload)`
- `resource.delete(entity_type, bundle, id)`

### Opening and closing clients

Use `async with AsyncFarmClient() as client:` if you want a context-managed client:

```python
async with AsyncFarmClient() as client:
...
```

Alternatively, use `await farm_client.aclose()` if you want to close a client explicitly:

```python
farm_client = AsyncFarmClient()
...
await farm_client.aclose()
```

### Examples

See the following examples from the [async Asset test (test_asset.py)](../tests/_async/functional/test_asset.py):

```python
from farmOS import AsyncFarmClient

# Create a test asset
test_asset = {
"type": "equipment",
"payload": {
"attributes": {
"name": "Tractor",
"manufacturer": "Allis-Chalmers",
"model": "G",
"serial_number": "1234567890",
}
},
}

async def test_asset_crud(farm_auth):
hostname, auth = farm_auth
async with AsyncFarmClient(hostname, auth=auth) as farm:
post_response = await farm.asset.send(test_asset["type"], test_asset["payload"])
assert "id" in post_response["data"]

# Once created, add 'id' to test_asset
test_asset["id"] = post_response["data"]["id"]

# Get the asset by ID.
get_response = await farm.asset.get_id(test_asset["type"], test_asset["id"])

# Assert that both responses have the correct values.
for response in [post_response, get_response]:
for key, value in test_asset["payload"]["attributes"].items():
assert response["data"]["attributes"][key] == value

test_asset_changes = {
"id": test_asset["id"],
"attributes": {
"name": "Old tractor",
"status": "archived",
},
}

# Update the asset.
patch_response = await farm.asset.send(test_asset["type"], test_asset_changes)
# Get the asset by ID.
get_response = await farm.asset.get_id(test_asset["type"], test_asset["id"])

# Assert that both responses have the correct values.
for response in [patch_response, get_response]:
for key, value in test_asset_changes["attributes"].items():
assert response["data"]["attributes"][key] == value

# Delete the asset.
deleted_response = await farm.asset.delete(test_asset["type"], test_asset["id"])
assert deleted_response.status_code == 204


async def test_asset_get(farm_auth, test_assets):
hostname, auth = farm_auth
async with AsyncFarmClient(hostname, auth=auth) as farm:
# Get one page of assets.
response = await farm.asset.get(test_asset["type"])
assert "data" in response
assert "links" in response
assert len(response["data"]) == 50

# Get all assets.
all_assets = [asset async for asset in farm.asset.iterate(test_asset["type"])]
assert len(all_assets) > len(response["data"])
```
124 changes: 36 additions & 88 deletions docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,104 +2,52 @@

## Background

The farmOS.py client authenticates with the farmOS server via OAuth `Bearer`
tokens. Before authenticating with the server, a farmOS client must be
created and an OAuth Authorization flow must be completed (unless an optional
`token` was provided when creating the client).
farmOS includes an OAuth2 Authorization server for providing 1st and 3rd party
clients access to the farmOS API. For more information on the OAuth2
specification see the [farmOS API authentication documentation](https://farmos.org/development/api/authentication/).

## Authorizing with Password Credentials (most common)
farmOS.py provides a `FarmClient` wrapper around the [HTTPX](https://www.python-httpx.org/)
HTTP Python client and uses the [HTTPX-Auth](https://github.com/Colin-b/httpx_auth)
library for OAuth2 authentication. For advanced use-cases that require other
authentication schemes see [HTTPX custom authentication schemes](https://www.python-httpx.org/advanced/authentication/#custom-authentication-schemes).

```python
from farmOS import farmOS

hostname = "myfarm.farmos.net"
username = "username"
password = "password"

# Create the client.
farm_client = farmOS(
hostname=hostname,
client_id = "farm", # Optional. The default oauth client_id "farm" is enabled on all farmOS servers.
scope="farm_manager", # Optional. The default scope is "farm_manager". Only needed if authorizing with a different scope.
version=2 # Optional. The major version of the farmOS server, 1 or 2. Defaults to 2.
)

# Authorize the client, save the token.
# A scope can be specified, but will default to the default scope set when initializing the client.
token = farm_client.authorize(username, password, scope="farm_manager")
```

Running from a Python Console, the `username` and `password` can also be
omitted and entered at runtime. This allows testing without saving
credentials in plaintext:

```python
>>> from farmOS import farmOS
>>> farm_client = farmOS(hostname="myfarm.farmos.net")
>>> farm_client.authorize()
Warning: Password input may be echoed.
Enter username: >? username
Warning: Password input may be echoed.
Enter password: >? password
>>> farm_client.info()
```
## OAuth2 Authorization Flow

## Authorizing with existing OAuth Token (advanced)
Before making requests to the farmOS server an OAuth2 client and grant must be
configured on the farmOS server to be used in an OAuth2 authorization flow.

An existing token can be provided when creating the farmOS client. This is
useful for advanced use cases where an OAuth token may be persisted.
An OAuth Client represents a 1st or 3rd party integration with the farmOS
server. Clients are uniquely identified by a `client_id` and can have an
optional `client_secret` for private integrations. Clients are configured
to allow only specific OAuth grants and can specify default scopes that
are granted when none are requested.

```python
from farmOS import farmOS

hostname = "myfarm.farmos.net"
token = {
"access_token": "abcd",
"refresh_token": "abcd",
"expires_at": "timestamp",
}

# Create the client with existing token.
farm_client = farmOS(
hostname=hostname,
token=token,
)
```
The OAuth2 Password Credentials Flow is documented here because most Python
scripting use-cases can be trusted with a username and password (considered
a 1st party client). The core `farm_api_default_consumer` module provides a
default client with `client_id = farm` that can use the `password` grant. You
can use this client for general usage of the API, like writing a script that
communicates with your farmOS server, but it comes with limitations. For more
information on OAuth2 authorization flows supported by the farmOS server see the
[farmOS Authorization Flow documentation](https://farmos.org/development/api/authentication/#authorization-flows).

## Saving OAuth Tokens
## Usage in farmOS.py

By default, access tokens expire in 1 hour. This means that requests sent 1
hour after authorization will trigger a `refresh` flow, providing the client
with a new `access_token` to use. A `token_updater` can be provided to save
tokens external of the session when automatic refreshing occurs.

The `token_updater` defaults to an empty lambda function: `lambda new_token: None`.
Alternatively, set `token_updater = None` to allow the [`requests_oauthlib.TokenUpdated`](https://requests-oauthlib.readthedocs.io/en/latest/api.html#requests_oauthlib.TokenUpdated)
exception to be raised and caught by code executing requests from farmOS.py.
Instantiate an OAuth2 flow from the HTTPX-Auth library. Pass this to the
`FarmClient` using the `auth` parameter:

```python
from farmOS import farmOS

hostname = "myfarm.farmos.net"
username = "username"
password = "password"
from httpx_auth import OAuth2ResourceOwnerPasswordCredentials
from farmOS import FarmClient

# Maintain an external state of the token.
current_token = None
FARMOS_HOSTNAME="https://myfarm.farmos.net"

# Callback function to save new tokens.
def token_updater(new_token):
print(f"Got a new token! {new_token}")
# Update state.
current_token = new_token

# Create the client.
farm_client = farmOS(
hostname=hostname,
token_updater=token_updater, # Provide the token updater callback.
auth = OAuth2ResourceOwnerPasswordCredentials(
token_url=f"{FARMOS_HOSTNAME}/oauth/token",
username=USERNAME,
password=PASSWORD,
client_id="farm",
scope="farm_manager",
)

# Authorize the client.
# Save the initial token that is created.
current_token = farm_client.authorize(username, password, scope="farm_manager")
farm_client = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth)
```
Loading
Loading