// education / python
How proxmoxer Authenticates Through requests
You write a class. You never call it. Somehow it runs on every HTTP request your program makes.
Reading a client library, you find a method that obviously matters. It refreshes an expiring authentication ticket:
def __call__(self, req):
time_diff = time.monotonic() - self.birth_time
if time_diff >= self.renew_age:
self._get_new_tokens()
...
return reqNow grep the library for a call to it. There is none. No auth.__call__(...), no self.authenticate(). Nothing in proxmoxer runs this code and yet it runs, once per request, forever.
The caller lives in a different library. Tracing it takes nine frames, and every hop teaches one useful thing.
1 · Calling an object runs its __call__
Run the following snippet:
class Adder:
def __init__(self, n):
self.n = n
def __call__(self, x):
return x + self.n
add5 = Adder(5) # add5 is an OBJECT, not a function
print(add5(10)) # -> 15 ...but you call it like oneThe translation Python performs is mechanical:
Keep that sentence. When you meet the line r = auth_handler(self) buried inside requests later on, you will recognise it for what it is: a method invocation in disguise.
Why an object rather than a plain function? State. This authenticator has to remember a ticket, when it was issued, and replace both over time. A function would need globals or a closure; an object holds them in attributes and can mutate them.
2 · super() means "the same method, one class up"
The second idea you need. super() lets a subclass run its parent's version of a method and then add to it, instead of copying the parent's code:
class Child(Parent):
def __call__(self, req):
before = self.ticket
req = super().__call__(req) # let Parent do its whole job
if self.ticket != before: # then add your own behaviour
...
return reqYou get the parent's behaviour for free and stay in the loop. That is what makes the next section, a three-deep inheritance chain.
3 · The family tree
ProxmoxHTTPAuth extends ProxmoxHTTPAuthBase, which extends AuthBase and AuthBase is not proxmoxer's at all. It is defined in the requests library, in auth.py. That single fact is the whole bridge between the two libraries.
4 · What AuthBase actually is
class AuthBase:
"""Base class that all auth implementations derive from"""
def __call__(self, r: PreparedRequest) -> PreparedRequest:
raise NotImplementedError("Auth hooks must be callable.")Read it as a job description, not working code: if you want to authenticate requests, you must be callable; you will be handed a request; you must hand one back. It supplies no behaviour because it cannot know whether you want Basic auth, a bearer token, or a Proxmox ticket. The raise is a tripwire for anyone who subclasses it and forgets.
r is just a parameter name, proxmoxer calls the same thing req. The type hints are documentation, not enforcement. But the return is real, — here is the caller, the same r = auth_handler(self) line you saw earlier:
# Allow auth to make its changes.
r = auth_handler(self)
# Update self to reflect the auth changes.
self.__dict__.update(r.__dict__)This lives inside PreparedRequest.prepare_auth, so self here is the request being prepared, the exact PreparedRequest the type hint promised. It calls your auth object, catches whatever you returned as r, and copies every attribute from r back onto itself. Return the request from your own __call__ and nothing breaks. Skip the return there, and r becomes None; then r.__dict__ fails with AttributeError: 'NoneType' object has no attribute '__dict__'.
5 · Which backend are we even in?
proxmoxer ships four transports: local, https, openssh and ssh_paramiko. ProxmoxAPI knows none of them; it imports one by name:
self._backend = importlib.import_module(
f".backends.{backend}", "proxmoxer"
).Backend(**kwargs)You passed backend="https", so every line that follows comes out of backends/https.py.
6 · The hand-off
ProxmoxAPI.__init__ then packs everything a future request needs into one dict:
self._store = {
"base_url": self._backend.get_base_url(),
"session": self._backend.get_session(), # <- here
"serializer": self._backend.get_serializer(),
}And get_session is where the two libraries are wired together:
def get_session(self):
session = ProxmoxHttpSession()
session.cert = self.cert
session.auth = self.auth # <-- THE HAND-OFF Line 326
...
return sessionThe slot has been waiting. requests initialises it to nothing in Session.__init__ (sessions.py:450: self.auth = None), which is why a plain requests.get(url) sends no credentials.
Note the type on line 324: ProxmoxHttpSession is proxmoxer's own subclass of requests.Session. It overrides request(), reads the auth object back out into a local named a (https.py:180), and then calls super().request(...) to hand control to real requests.
class ProxmoxHttpSession(requests.Session):
def request(..., headers=None, cookies=None, auth=None, ...):
...
...
return super().request(
...,
headers,
cookies,
auth,
...
)
One thing to keep straight: session.auth = self.auth has self.auth on both sides of the fence.
https.py:326 self = the Backend -> self.auth = the auth object it built
sessions.py:551 self = the Session -> self.auth = the session's auth obj
line 326 copies the first into the secondRead self. as "the object whose method I am currently inside" and the ambiguity disappears for good.
7 · The descent
Now one ordinary call, followed all the way down:
Two lines in there hide the connection, and both are worth a minute.
8 · merge_setting takes two arguments
It looks like one thing is being passed. It is two, and the second is the one that matters:
auth = merge_setting( auth, self.auth )
| |
request_setting session_setting
= request.auth = session.auth
= None = your auth objectprepare_request is a method of Session, so self is the session, and self.auth is session.auth, the very slot filled at https.py:326. Same attribute, seen from inside instead of outside.
def merge_setting(
request_setting: Any, session_setting: Any, dict_class: type = OrderedDict
) -> Any:
if session_setting is None:
return request_setting # session has none -> use the request's
if request_setting is None:
return session_setting # <-- YOUR CASEproxmoxer never passes a per-call auth=, so request_setting is None and your object is returned. In short: a per-request auth wins if you gave one; otherwise the session's auth is used. That's what lets you set auth once on the session instead of on every call.
9 · cast() does absolutely nothing
if isinstance(auth, tuple) and len(auth) == 2:
auth_handler = HTTPBasicAuth(*auth) # shortcut for auth=("bob", "hunter2")
else:
auth_handler = cast("Callable[..., PreparedRequest]", auth) # <- your objectThat cast looks like a conversion. It is not. Here is its complete runtime body:
def cast(typ, val):
"""This returns the value unchanged. To the type checker this
signals that the return value has the designated type, but at
runtime we intentionally don't check anything."""
return valIt exists only to reassure mypy and pyright. At runtime the line is simply auth_handler = auth, a second name for the same object.
10 · One object, seven names
So auth_handler is session.auth is the Backend's self.auth is your instance. Every arrow in the chain is a rename, never a new object. auth_handler(self) therefore runs ProxmoxHTTPAuth.__call__.
The takeaway
This is inversion of control: you do not call the library at the moment that matters. you hand it something and it calls you. Flask routes, pytest fixtures, Django middleware, every on_click you have ever written: same arrangement.
The question is always the one this article answered: if I never call this code, who does? And so is the method for finding out: locate the slot your object is stored in, then search the framework for the code that reads that slot. Here the slot is session.auth and the reader is prepare_auth. Every framework has its own pair.
Line numbers from requests 2.34.2 and proxmoxer 2.2.0. They drift between releases; the anchor comments do not.