// 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:

proxmoxer/backends/https.py:102
def __call__(self, req):
    time_diff = time.monotonic() - self.birth_time
    if time_diff >= self.renew_age:
        self._get_new_tokens()
    ...
    return req

Now 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:

python
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 one

The translation Python performs is mechanical:

WHAT YOU WRITE add5(10) IDENTICAL WHAT PYTHON ACTUALLY RUNS type(add5).__call__(add5, 10) add5 Adder instance self.n = 5 __call__ Putting (...) after any object runs that object's __call__.
Calling an object is not special syntax, it is a lookup of __call__ on the object's class.

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:

the pattern
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 req

You 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.

Which __call__ actually runs Method resolution across two libraries requests territory proxmoxer territory AuthBase requests/auth.py:78 __call__ raises NotImplementedError — a contract, no behaviour extends ProxmoxHTTPAuthBase proxmoxer/https.py:35 __call__ returns req unchanged — a harmless no-op extends ProxmoxHTTPAuth proxmoxer/https.py:52 __call__ at line 102 — the real one STOPS HERE Python searches bottom-up and stops at the first __call__ it finds. lookup starts here never reached never reached
The NotImplementedError at the top never runs — it only fires for a subclass that defines nothing.

4 · What AuthBase actually is

requests/auth.py:78
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:

requests/models.py — PreparedRequest.prepare_auth
# 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:

proxmoxer/core.py:208
self._backend = importlib.import_module(
    f".backends.{backend}", "proxmoxer"
).Backend(**kwargs)
TRANSPORT SELECTION AT RUNTIME ProxmoxAPI(host, backend="https") importlib.import_module( f".backends.{backend}") core.py:208 local https ACTIVE openssh ssh_paramiko everything in this article lives in this one file: backends/https.py The transport is chosen by name, at runtime — which is why the auth machinery lives in the backend and not in the core.
Four transports ship with proxmoxer; the string you pass decides which module is imported.

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:

proxmoxer/core.py:213
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:

proxmoxer/backends/https.py:323
def get_session(self):
    session = ProxmoxHttpSession()
    session.cert = self.cert
    session.auth = self.auth          # <-- THE HAND-OFF  Line 326
    ...
    return session
One auth object, two owners proxmoxer / https.py Backend proxmoxer · https.py:254 base_url cert auth ProxmoxHttpSession subclass of requests.Session headers cookies cert auth Nothing is called here. A reference is copied. session.auth = self.auth https.py:326 two arrows, one target ProxmoxHTTPAuth single instance username pve_auth_ticket birth_time renew_age __call__() the hook — this is what runs One object · two owners · no duplication
Two owners hold a pointer to the same instance. session.auth = self.auth is what makes the second pointer exist.

The 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, method request
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.

two objects, two attributes, same name
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 second

Read 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:

Where your auth handler actually gets called requests · proxmoxer PROXMOXER REQUESTS PROXMOXER proxmox.version.get() your code └─ session.request(...) sessions.py:557 └─ prepare_request(req) sessions.py:511 ├─ p = PreparedRequest() sessions.py:540 └─ p.prepare(...) sessions.py:541 └─ merge_setting(auth, self.auth) session.auth comes back out here sessions.py:551 └─ prepare_auth(auth, url) models.py:445 └─ r = auth_handler(self) ★ your __call__ runs models.py:691 └─ ProxmoxHTTPAuth.__call__(req) https.py:102 requests calls proxmoxer — not the other way around.
Nine frames, two libraries. Frames 2-8 are requests; the first and last are proxmoxer.

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:

requests/sessions.py:551 lined up with the signature at :76
auth = merge_setting( auth,             self.auth )
                      |                 |
                      request_setting   session_setting
                      = request.auth    = session.auth
                      = None            = your auth object

prepare_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.

requests/sessions.py:84
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 CASE

proxmoxer 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

requests/models.py:683
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 object

That cast looks like a conversion. It is not. Here is its complete runtime body:

python/typing.py
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 val

It 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

SEVEN NAMES ONE OBJECT self.auth attribute on the Backend https.py:310 session.auth copied onto the Session https.py:326 a local in ProxmoxHttpSession.request https.py:180 self.auth read back off the Session sessions.py:551 auth parameter of prepare() models.py:432 auth parameter of prepare_auth() models.py:672 auth_handler after cast(), which is a no-op models.py:688 THE OBJECT ProxmoxHTTPAuth instance constructed once at https.py:310 THIS IS THE ONE THAT RUNS auth_handler(self) __call__ runs Seven names. Zero copies. id() is identical from first to last.
Seven bindings, one instance, the blue path is the one that ends in a call.

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.