2
\$\begingroup\$

I'm working with an API, where the url can be constructed with setting_ids. To more clearly indicate the functionality of the setting ids I am mapping them to a WriterClass' methods, with relevant names. In the code below these are method_a to method_c, in the true application this is a list of ~250 settings.

The functionality I desire would access the API for a desired setting through the structure writer_instance.setting_name(value) - returning a to be awaited coroutine.

For this I'm using descriptors, with which I'm still familiarising myself. This is my main motivation for asking this question, to ensure I'm utilising them correctly and as efficiently as possible - and if not, to learn how to do so now and in the future.

Below is code that functionally does what I desire, with placeholder prints instead of code accessing the (private) API.

The Credentials class is a separate class as it's utilised by other Classes which also access the same API.

import asyncio
import httpx
DOMAIN = 'api.mysite.com'
class Credentials:
 # In true context this fetches an api_token from database using the serial.
 def __init__(self, device_serial: str, api_token):
 self.device_serial = device_serial
 self._api_token = api_token
 self.headers = self.__build_headers()
 def __build_headers(self):
 headers = {
 'Authorization': ('Bearer ' + self._api_token),
 'Content-Type': 'application/json',
 'Accept': 'application/json',
 }
 return headers
class MethodDescriptor:
 def __init__(self, id):
 self._id = id
 def __get__(self, instance, owner):
 # Using this so that the final method has access to instance variables.
 # Not happy with this approach necessarily, but it gets the results I desire.
 return BoundMethod(self._id, instance)
class BoundMethod:
 def __init__(self, id, instance):
 self._id = id
 self._instance = instance
 async def __call__(self, value):
 # placeholder logic for actual API call.
 await asyncio.sleep(1) # Simulate work
 print(
 f"Method called with id={self._id}, {value=}, at url={self._instance.domain} for serial {self._instance.device_serial}")
 return f'accessed setting number {self._id}'
class WriterClass:
 method_a = MethodDescriptor(1)
 method_b = MethodDescriptor(2)
 method_c = MethodDescriptor(3)
 def __init__(self, credentials: Credentials, client: httpx.AsyncClient):
 self.device_serial = credentials.device_serial
 self.headers = credentials.headers
 self.domain = DOMAIN
 self.client = client
# Example usage
async def main():
 creds = Credentials("123AA", "API_TOKEN") # API token obtained elsewhere in real code.
 async with httpx.AsyncClient() as client:
 writer = WriterClass(creds, client)
 task1 = asyncio.create_task(writer.method_c("some_str")) # should use __call__ with self._id = 3, value="some_str" and instance vars.
 task2 = asyncio.create_task(writer.method_b("12:34"))
 task3 = asyncio.create_task(writer.method_a("3700"))
 results = await asyncio.gather(task1, task2, task3)
 print(results)
if __name__ == '__main__':
 asyncio.run(main())

Which returns:

Method called with id=3, value='some_str', at url=api.mysite.com for serial 123AA
Method called with id=2, value='12:34', at url=api.mysite.com for serial 123AA
Method called with id=1, value='3700', at url=api.mysite.com for serial 123AA
['accessed setting number 3', 'accessed setting number 2', 'accessed setting number 1']

As expected.

asked Mar 3, 2024 at 18:07
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Use a More Meaningful Name For Your Descriptor Class?

How about renaming your descriptor class from MethodDescriptor to SettingDescriptor? And consider renaming class WriterClass to SettingWriter. I would also use more descriptive names for the descriptor instances themselves. For example:

class SettingWriter:
 set_a = SettingDescriptor(1)
 set_b = SettingDescriptor(2)
 set_c = SettingDescriptor(3)
 ...

Then your main function becomes:

async def main():
 creds = Credentials("123AA", "API_TOKEN") # API token obtained elsewhere in real code.
 async with httpx.AsyncClient() as client:
 setter = SettingWriter(creds, client)
 task1 = asyncio.create_task(setter.set_c("some_str")) # should use __call__ with self._id = 3, value="some_str" and instance vars.
 task2 = asyncio.create_task(setter.set_b("12:34"))
 task3 = asyncio.create_task(setter.set_a("3700"))
 results = await asyncio.gather(task1, task2, task3)
 print(results)

Calling a method named set_a seems more descriptive than calling method_a.

The above renaming suggestions are just that -- suggestions. You might be able to find names that are even more descriptive than the ones I quickly came up with due to your greater familiarity with the actual application.

A Simplification to Consider

It seems to me that you can do away with class BoundedMethod if you modify class SettingDescriptor as follows:

class SettingDescriptor:
 def __init__(self, id):
 self._id = id
 def __get__(self, instance, owner):
 # Using this so that the final method has access to instance variables.
 self._instance = instance
 return self
 async def __call__(self, value):
 # placeholder logic for actual API call.
 await asyncio.sleep(1) # Simulate work
 print(
 f"Method called with id={self._id}, {value=}, at url={self._instance.domain} for serial {self._instance.device_serial}")
 return f'accessed setting number {self._id}'

Add Type Hints

Describe what arguments and return values a method/function expects using type hints. For example,

from typing import Type, Callable
...
class SettingDescriptor:
 def __init__(self, id: int):
 self._id = id
 def __get__(self, instance: object, owner: Type) -> Callable:
 ...

Add Doctsrings Describing What Your Methods Do

For example,

class SettingDescriptor:
 ...
 def __get__(self, instance: object, owner: Type) -> Callable:
 """Returns self, a callable that invokes the API with
 the appropriate id and value arguments."""
 ...

But Is Using Descriptors for This Overkill?

Ultimately, you are just trying to map a property name, which is a string, to an integer that the API you are using requires. For this you could use a dictionary. So what if you just had this instead:

...
class SettingWriter:
 property_name_mapping = {
 'a': 1,
 'b': 2,
 'c': 3
 }
 def __init__(self, credentials: Credentials, client: httpx.AsyncClient):
 self.device_serial = credentials.device_serial
 self.headers = credentials.headers
 self.domain = DOMAIN
 self.client = client
 async def set(self, property_name: str, value: str) -> str:
 id = self.property_name_mapping[property_name]
 await asyncio.sleep(1) # Simulate work
 print(
 f"Method called with id={id}, {value=}, at url={self.domain} for serial {self.device_serial}")
 return f'accessed setting number {id}'
# Example usage
async def main():
 creds = Credentials("123AA", "API_TOKEN") # API token obtained elsewhere in real code.
 async with httpx.AsyncClient() as client:
 setter = SettingWriter(creds, client)
 task1 = asyncio.create_task(setter.set("c", "some_str"))
 task2 = asyncio.create_task(setter.set("b", "12:34"))
 task3 = asyncio.create_task(setter.set("a", "3700"))
 results = await asyncio.gather(task1, task2, task3)
 print(results)
answered Mar 3, 2024 at 20:30
\$\endgroup\$
2
  • \$\begingroup\$ I've implemented the simplification, that's exactly the type of improvement I was looking for but couldn't figure out myself. Regarding the type hinting, how should one typehint within the descriptor that one is dealing with an instance of SettingWriter, when said class is only defined later in the document? \$\endgroup\$ Commented Mar 6, 2024 at 9:54
  • 1
    \$\begingroup\$ See if this helps. \$\endgroup\$ Commented Mar 6, 2024 at 11:27

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.