3

I want to define a NewType like this:

from typing import NewType
from os import PathLike

AnyPath = PathLike[str] | str
RepoPath = NewType("RepoPath", AnyPath)          # ERR: Argument 2 to NewType(...) must be subclassable (got "Union[PathLike[str], str]")
# RepoPath = NewType("RepoPath", PathLike[str])  # ERR: NewType cannot be used with protocol classes

Basically so that later I can pass a raw path "str" or a "pathlib.Path" to functions, and they can enforce this is specifically a path to a "Repo" rather than a random path. This is useful because there are a lot of paths and urls etc in my code and I don't want them to get mixed up (I don't want to use (Apps) hungarian notation especially either).

Is there a good way to get the type checker to do this for me?

Greedo
  • 4,967
  • 2
  • 30
  • 78
  • Why is `Union` not a good option here? – C.Nivs Jan 28 '22 at 13:36
  • @C.Nivs I don't follow: I'm using it here `AnyPath = PathLike[str] | str = Union[PathLike[str], str]`. But if I make a function `def foo(repo: AnyPath): ...` then I don't guarantee `repo` is a path to a Repo, it could just be a programming error, some Path (or string) that is meaningless in this context. NewType makes it explicit when you call the function, you are passing a str/PathLike but more specifically it is one that has been marked as a `RepoPath` so is unlikely to be a programming error. Same as `safe_str=escape(unsafe_str)` - both are strings but have different meanings. – Greedo Jan 28 '22 at 13:43
  • Sorry I think I misread the code a bit. The type signature will not guarantee that a path is valid, so I'm assuming you're doing some runtime validation of the path itself. Otherwise, you can still pass whatever you want to a function. [This](https://stackoverflow.com/a/58775376/7867968) might useful reading. Basically, `NewType` accepts a callable as its second arg, which isn't what's happening in your code – C.Nivs Jan 28 '22 at 13:52
  • @C.Nivs Yeah, I've read that and have a good idea what the difference is/ why what I've written doesn't work. I'm asking how I achieve the desired effect within the constraints of the type system - I just used the code to illustrate what I'm getting at. See the answer I posted below for an idea - however it's not perfect and allows `pathlib.Path` but not `os.PathLike` – Greedo Jan 28 '22 at 14:07
  • Allowing `NewType`'s to be based on unions and similar was [actually discussed](https://github.com/python/mypy/issues/1284#issuecomment-199227263) when `NewType` was introduced. It was however dropped, and `NewType`s are required to be based o "class-like" objects. – Carl Dec 13 '22 at 20:41

1 Answers1

0

Ok, here's a solution without Protocol - i.e. rather than accepting anything defining __fspath__ per os.PathLike, this code only allows concrete pathlib.Path or str.

Basically make two NewTypes then accept a union of them rather than a single NewType which is a union of subtypes.

from typing import NewType, overload, TypeAlias  # py3.10 +
from pathlib import Path
#from os import PathLike  # can't get this to work with NewType

AnyPath: TypeAlias = Path | str

RepoPathP = NewType("RepoPathP", Path)
RepoPathS = NewType("RepoPathS", str)

AnyRepoPath: TypeAlias = RepoPathP | RepoPathS


@overload
def RepoPath(path: str) -> RepoPathS: ...
@overload
def RepoPath(path: Path) -> RepoPathP: ...

def RepoPath(path: AnyPath) -> AnyRepoPath:
    if isinstance(path, str):
        return RepoPathS(path)
    else:
        return RepoPathP(path)


def foo(repo: AnyRepoPath) -> None:
    print(repo)

    
foo("bad")                        # Argument 1 to "foo" has incompatible type "str"; expected "Union[RepoPathP, RepoPathS]"
foo(Path("still bad"))            # Argument 1 to "foo" has incompatible type "Path"; expected "Union[RepoPathP, RepoPathS]"
foo(RepoPath("good"))             # Pass
foo(RepoPath(Path("also good")))  # Pass

As mentioned this isn't perfect, as:

class MyCustomPath():
    def __fspath__(self) -> str:
        return r"C:/my/custom/dir"

path: os.PathLike = MyCustomPath() #fine, as expected
repo = RepoPath(path) #fails, since RepoPath accepts only str|Path, not str|PathLike

where ideally I'd like it to succeed

Greedo
  • 4,967
  • 2
  • 30
  • 78