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.
1 Answer 1
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)
-
\$\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\$Floriancitt– Floriancitt2024年03月06日 09:54:02 +00:00Commented Mar 6, 2024 at 9:54 -
1