-
Notifications
You must be signed in to change notification settings - Fork 308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor/normalize exceptions #586
Comments
Hi @bhrutledge Thank you for opening this issue. I think this will also help in #577. Also I was wondering if this is something in which you are open to accepting PRs from external contributors? If that is the case, I would be happy to write up some PRs for the same. |
Thanks again for offering, @deveshks. I'd like to get feedback from the other @pypa/twine-maintainers before proceeding. In particular, I'd like to settle on a pattern for encapsulating the error/help message in the exception classes. For example, pip seems to use |
I am not fully sure of what this means. Do you mean that in addition to having all the exception classes in that file we'd have constants defined with exception strings that we then have to figure out? Something like
Absolutely this sounds good to me based on the usage you found. This is also backwards compatible
I'm not sure, I'd have to carefully review the usage of either of them. One may be more an artefact of the old cheeseshop codebase, one could be more specific to preparing to upload things. I don't honestly remember. Generally speaking, though, since we've started building a documented, backwards-compatible API into Twine, we can't make sweeping changes like combining or extracting exceptions without making the lineage of a given class very complicated and convoluted. So I'm tentative about the goal of "combining or extracting" exceptions. Also the goal of doing so isn't explicit.
This makes sense. Some of our exception messages end up being in front of users. We should make the messages themselves more user friendly. All in all, I'd still like to understand the motivation behind any of this. In general, if you weren't a member of the team, this would be a very jarring issue to receive. There's a list of things you'd like to do, but there's no reasoning as to why. What's the value you see in doing this work? How do you propose handling any problems that may arise for users of our (at this point, limited) API? Will this constitute a major version release of twine when done? I need more information before I can say I'm 👍 or 👎 on the proposal |
@sigmavirus24 Thanks for the feedback! I'm attempting to respond succinctly; happy to elaborate (via some sort of synchronous chat if desired).
I can see how that's not obvious from my initial description. I've found the various patterns of exception handling to be confusing, both when reading the code, and when making/reviewing changes.
I think it would improve the experience for maintainers, contributers, and users if error messages were defined in one place and raised with a simple call.
I started to use GitHub's code search to get a sense of if/how other projects use Twine's exceptions, but that was stymied by all the repos that have committed their virtual environment.
Nope. I'm thinking along the lines of what pip seems to do: class RedirectDetected(TwineException):
def __init__(self, repository_url, redirect_url):
super().__init__(
f"{repository_url} attempted to redirect to {redirect_url}.\n"
f"If you trust these URLs, set {redirect_url} as your repository URL."
)
class DistributionFileNotFound(InvalidDistribution):
def __init__(self, filename):
super().__init__(f"No such file: {filename}")
class InvalidPyPIUploadURL(TwineException):
def __init__(self):
super().__init__(
"It appears you're trying to upload to PyPI (or TestPyPI) "
"but have an invalid URL.\n"
f"You probably want: {DEFAULT_REPOSITORY} or {TEST_REPOSITORY}.\n"
"Check your repository configuration."
)
raise exceptions.RedirectDetected(repository_url, resp.headers["location"])
raise exceptions.DistributionFileNotFound(filename)
raise exceptions.InvalidPyPIUploadURL() I'm using |
How does it improve the experience for users? If users go searching for the message they're seeing, they'll only find the exceptions module, not the place using the message itself. That would be far more frustrating to me having to search for a message, then search for something else and likely not finding what's causing my problem. That adds maintenance burden because then I have to open an issue to get you to tell me why it's doing something I don't expect it to, and then you have to find what's going wrong several months or years later after you've forgotten why. To be clear, I'm not categorically against this, but I haven't been convinced it's a good idea yet.
So exceptions in Python (generally speaking) accept a message string in I'm happy to settle on a single pattern for maintainability. In fact, as we think about the future Twine API, I think that there are aspects of what pip does that are very valuable. I think storing the relevant objects/references on the exception make it easier for a 3rd party developer to introspect the error or provide greater detail. This discussion has mean leaning more towards the class TwineException(Exception):
message = "An error was encountered while running Twine"
def __init__(self, message, **kwargs):
super().__init__(message)
self._initialize_attributes(**kwargs)
def _initialize_attributes(self, **kwargs):
self.kwargs = kwargs
@classmethod
def from_args(cls, **kwargs):
return cls(cls.message % kwargs, **kwargs)
class RedirectDetected(TwineException):
message = ("%(repository_url)s attempted to redirect to %(redirect_url)s.\n"
"If you trust these URLs, set %(redirect_url)s as your repository URL.")
def _initialize_attributes(self, repository_url, redirect_url, **kwargs):
self.repository_url = repository_url
self.redirect_url = redirect_url
super()._initialize_attributes(**kwargs) We could use |
I'm enjoying this discussion. 🙂
Excellent!
This was a bit of a stretch, but I was thinking that defining exception messages in one place might help us make them more consistent/helpful.
Fair point. Maybe this could be mitigated by continuing to show the class name in the output. Or, by extending
Looks like you added that in 333a0a8, and I followed it in e53c9b5. However, I don't see the advantage of this over passing explicit arguments to
Why is that? As you said earlier:
To me, this is an argument for letting
In my previous comment, I was tempted to add something like |
Don't downplay it. That makes sense to me.
I think this is a healthy compromise, certainly. Then we also get the exact place that's causing the problem/confusion.
So If we go the
I think overloading Also, I'm entertained that you've referenced the flake8 exceptions module since that was entirely my fault and I've regretted it since I released Flake8 3.0. What if we compromise on this? Let's define our def __init__(self, message=None, *, fully_qualified_filename=None, ...):
... This will mean that all exception parameters are passed as keyword-only arguments, message is optional so one can still write |
Amen. Thanks again for participating in this exercise!
This is appealing, though I think it allows someone to write
I think this is an issue with any unfamiliar API. I think having explicit
I've been thinking that exceptions with specific messages should not accept an arbitrary message. We could allow for things like
I don't follow this; could you elaborate?
Can you clarify what compromise you mean? Class name in the output, traceback in With all this in mind, I'm still partial to this pattern. But, if you feel strongly about passing in an arbitrary message to any exception, I'm game for class InvalidDistribution(TwineException):
pass
class DistributionFileNotFound(InvalidDistribution):
def __init__(self, *, filename):
super().__init__(f"No such file: {filename}")
class InvalidPyPIUploadURL(TwineException):
def __init__(self):
super().__init__(
"It appears you're trying to upload to PyPI (or TestPyPI) "
"but have an invalid URL.\n"
f"You probably want: {DEFAULT_REPOSITORY} or {TEST_REPOSITORY}.\n"
"Check your repository configuration."
)
raise exceptions.DistributionFileNotFound(filename=filename)
raise exceptions.InvalidPyPIUploadURL() |
I don't think we'll see this in practice.
Editor auto-complete solves the "what arguments are here?" question but not so much "what's the semantic meaning of these arguments?" That often takes looking at other places an exception is used or committing to excellent docstrings. And only some editors provide hints from doctsrings. Also not everyone uses an editor that provides auto-complete on the arguments so this argument inherently precludes folks who don't use exactly the same tools as us.
So let's say there are two worlds (our current one, and the future one we're discussing here). In the current one,
Let's say that I'm a 3rd party user integrating with twine via the API. If I know that r = unittest.mock.Mock(autospec=twine.repository.Repository)
r.upload.side_effect = twine.exceptions.InvalidDistribution() If our r = unittest.mock.Mock(autospec=twine.repository.Repository)
r.upload.side_effect = twine.exceptions.InvalidDistribution("Testing what happens when twine raises an InvalidDistribution") They can be very clear to future contributors what's happening here (if for some reason the test isn't already clear) or they can document that case closer to the code they're using to mock it. It's far from perfect.
Sorry about that. It was perfectly clear in my head ;). Specifically printing the traceback in Re: class DistributionFileNotFound(InvalidDistribution):
def __init__(self, *, filename):
super().__init__(f"No such file: {filename}") I'm a firm believer that we should save any parameters passed in on the class. Even if I'm just using Twine's already limited API, I have to parse a string (which we change, as proven by a recent PR) to find the information that's important to me and/or my users. |
I was thinking about this more after my last commit, and was starting to come around to the class InvalidDistribution(TwineException):
pass
class DistributionFileNotFound(InvalidDistribution):
@classmethod
def format(cls, filename):
return cls(f"No such file: {filename}")
class InvalidPyPIUploadURL(TwineException):
@classmethod
def format(cls):
return cls(
"It appears you're trying to upload to PyPI (or TestPyPI) "
"but have an invalid URL.\n"
f"You probably want: {DEFAULT_REPOSITORY} or {TEST_REPOSITORY}.\n"
"Check your repository configuration."
)
raise exceptions.DistributionFileNotFound.format(filename)
raise exceptions.InvalidPyPIUploadURL.format()
I wasn't expecting hundreds, but I was thinking of a class for each distinct message. But, it does make me wonder about something like this: class InvalidDistribution(TwineException):
@classmethod
def no_such_file(cls, filename):
return cls(f"No such file: {filename}")
@classmethod
def unknown_format(cls, filename):
return cls(f"Unknown archive format for file: {filename}")
raise exceptions.InvalidDistribution.no_such_file(filename) |
So I was thinking further into the future, to be clear. It wouldn't be hundreds today. Your If we take the tact of starting just with |
Looking over the custom exceptions in
exceptions.py
and searching for how they're used, I see a couple patterns. The first is an empty class, with the message provided atraise
. The second is to define a message template in the exception, using arguments provided atraise
with.from_args(...)
.The empty class pattern is more common, but leads to duplicate, possibly inconsistent, and sometimes long message text in the code. To improve this, I propose:
Moving all exception messages to
exceptions.py
, making them templates where needed. I don't have a specific pattern in mind for this, and would prefer to follow one used by a larger project. This could mean replacing.from_args
.Adding subclasses of
InvalidDistribution
with a more specific message, for example:Combining
UploadToDeprecatedPyPIDetected
andInvalidPyPIUploadURL
as suggested in Add helpful error message for incorrect PyPI URL #509 (comment)Looking for other opportunities to combine or extract exceptions. For example, could
PackageNotFound
be the same asDistributionFileNotFound
?Looking for opportunities to improve the exception messages. For example, somewhere I heard the concept of "help messages" instead of "error messages".
Happy to do this in multiple PR's.
PS: GitHub's code navigation is really handy. For example, when viewing
exceptions.py
, click a class name, see where it's referenced, and click one to go there:The text was updated successfully, but these errors were encountered: