pythonpython-typingmypypylancepyright

Make class attribute private outside API for users, but public inside API for developers


I am having trouble dealing with classes wherein I have classes whose attributes and methods I want to access in my API code, but not have those attributes and methods exposed to the user who is using the API.

Consider this code example:

class Context:
    pass


class Foo(Generic[T]):
    _context: Context


class Bar(Generic[T]):
    _context: Context


def func_1(foo: Foo[str]) -> int:
    # do something with _context
    
    return NotImplemented

def func_2(foo: Foo[int], bar: Bar[int]) -> Bar[str]:
    # do something with _context
    
    return NotImplemented

The two functions are like operators that act on these objects, and it doesn't make sense to have them as methods to Foo and Bar in my application. Additionally they only act on certain types of the Foo and Bar classes. If I access foo._context inside func_1 for example, mypy and pylance will yell at me due to accessing a private attribute. But I don't want to make it public since I don't want the users of the API to access this function. How can I overcome this while still keeping my code up to typing standards?

I understand that a simple way would be to simply access foo._context inside the functions and include a typing ignore comment. However, this doesn't seem clean to me and I was wondering if there was a Pythonic way to do this. Along the same lines as this question, I was wondering if there was a way to prevent the users of an API from instantiating a public class, but still instantiate somehow within the API. I mean that these class attributes and methods should be public inside the package for developers but not for library users.


Solution

  • I'll preface this by saying that in real life I'd probably go with one of the two options you've already rejected, namely:

    1. Make these "operators" public methods of the classes (whose implementations will be allowed to access "private" attributes without any linter complaints). Your reasoning for not wanting to do this isn't clear to me; operators are usually implemented as methods of the classes that they operate on.
    2. Just go ahead and access the private method in the implementation of your module function and add a # pylint: disable (or whatever) where needed. It's not philosophically any different from declaring a friend class in a language that has a more strict public/private concept; the ability to declare exceptions to a general rule exists for a reason.

    There's also a good chance that in real life I'd be rethinking whether I wanted context to be a class attribute, or whether there should be a single object called "context" (which often can imply a sort of "god object").

    That said, another option is to make the classes themselves "private" to the module, and expose a public interface to them via a Protocol or similar.

    from typing import Protocol
    
    
    class Context:
        pass
    
    
    class Foo(Protocol[T]):
        pass  # declare "public" attributes here
    
    
    class _Foo(Foo[T]):
        context: Context
    
    
    class Bar(Protocol[T]):
        pass  # declare "public" attributes here
    
    
    class _Bar(Bar[T]):
        context: Context
    
    
    def func_1(foo: Foo[str]) -> int:
        assert isinstance(foo, _Foo)
        # do something with _Foo.context
        
        raise NotImplemented
    
    def func_2(foo: Foo[int], bar: Bar[int]) -> Bar[str]:
        assert isinstance(bar, _Bar)
        assert isinstance(foo, _Foo)
        # do something with _Foo.context/_Bar.context
        
        raise NotImplemented