Skip to content
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

bpo-46337: Urllib.parse scheme-specific behavior without reliance on URL scheme #30520

Closed

Conversation

oldaccountdeadname
Copy link

@oldaccountdeadname oldaccountdeadname commented Jan 10, 2022

See bpo-46337. Basically, this allows code like this:

>>> urljoin("custom-scheme://a.host/loc", "..", "custom-scheme://a.host/", classes=[urllib.parse.SchemeClass.RELATIVE, urllib.parse.SchemeClass.NETLOC])
'custom-scheme://a.host/'

https://bugs.python.org/issue46337

Some features in urllib are dependent on schemes, (i.e., preserving the
netloc in url joining). Prior to this patch, this was governed by the
uses_* lists (uses_relative, uses_netloc, uses_params) which hard code
these attributes for certain schemes. Providing an enum interface and a
'constructor' that allows overrides makes this mechanism a bit more
flexible for future modifications.
This allows the callers of urljoin and urlparse to add guaranteed scheme
classes to the url regardless of the actual scheme, which may not be in
the default uses_* lists of schemes. This call-time behavior is done
through an optional parameter that preserves backwards compatibility.

A test case is added for this, and requires the change present in
test_urlparse.checkJoin.
@github-actions
Copy link

This PR is stale because it has been open for 30 days with no activity.

@github-actions github-actions bot added the stale Stale PR or inactive for long period of time. label Feb 10, 2022
@orsenthil orsenthil self-requested a review February 11, 2022 05:57
Copy link
Contributor

@MaxwellDupre MaxwellDupre left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add info to docs Please?
Otherwise how will users know how to use or that it exists?

@oldaccountdeadname
Copy link
Author

oldaccountdeadname commented Feb 26, 2022 via email

This functionality was exposed in 53c6ccc.
@oldaccountdeadname
Copy link
Author

Ah, CI was failing due to my editor inserting tabs - should be fixed. The docs commit is now f9b59dd.

It looks like CI expects this when building documentation.
@oldaccountdeadname
Copy link
Author

@orsenthil - sorry for the extra email/ping, but would you be able to give this a review when you've got some spare time? It's been sitting stale for roughly a month now. thanks!

@orsenthil
Copy link
Member

@lincolnauster - sure, I will review.

@orsenthil orsenthil self-assigned this Mar 12, 2022
urljoin will not treat `..` as moving up one directory rather than
moving up one file, thus causing the doctests to fail due to a missing
trailing slash. Both changes are of the form:

http://example.org/post/x -> http://example.org/post/x/

Additionally, the my-protocol example's expected output had the wrong
scheme.
@oldaccountdeadname
Copy link
Author

sigh - code was right, docs were wrong.

Stupid question, but I couldn't find this in the devguide. How do I actually run the doctests on my local checkout? I was performing them manually in a shell but evidently I accidentally corrected them while typing them in.

I'm running this in Doc/:

gmake doctest PYTHON=../python SPHINXOPTS="-j24 --keep-going" 

...and I'm getting a ton of errors from code I haven't touched. (A bunch of stuff in the ast module, _tkinter wasn't found, etc.) Is this a misconfiguration on my part? How can I actually run doctests?

Also, I pushed a fix for the current cause of failure. Would it be helpful to squash/rebase it into the original doc commit (f9b59dd) and force-push? Thanks so much!

oldaccountdeadname and others added 2 commits March 13, 2022 01:09
Minor style things:
+ _scheme_classes' docstring's summary was made explicit.
+ _scheme_classes was prepended with and followed by two newlines.
@@ -543,6 +555,53 @@ operating on :class:`bytes` or :class:`bytearray` objects:

.. versionadded:: 3.2

Special URL Behaviors and Scheme Classes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name "class" is confusing to me, because it makes me think of a class statement.

Also, why not just add three boolean parameters to urlparse and urljoin? That should make the behavior simpler to use for users.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also my alternate proposal for a dedicated function on the ticket.
(Fits with the API design guideline of avoiding a boolean param that changes behaviour — I forget the exact phrasing and origin of that)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JelleZijlstra Three boolean parameters would be a pain. re.match, re.search, etc., use an options parameter to collect all the flags in one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would they be a pain? As a user I'd much rather write use_relative=True than look up some separate flags enum.

Copy link
Member

@merwok merwok Mar 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise, I am not sure that a bitfield integer flag is the best thing for a regular Python API. The re module had a backward compat concern that doesn’t apply here. But (sorry to repeat my opinion) I am not in favour of piecemeal control, I would rather have a universal parsing function that just implements the universal resource identifier spec :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@merwok (It's not an integer, that would be IntFlag)

re the universal parsing function -- how would that differ from the proposed additions to urlparse.urlparse?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See ticket:

urllib is a module that pre-dates the idea of universal parsing for URIs, where the delimiters (like ://) are enough to determine the parts of a URI and give them meaning (host, port, user, path, etc).
Backward compat for urllib is always a concern; someone said at the time that it could be good to have a new module for modern, generic parsing, but that hasn’t happened. Maybe a new parse function, or new parameter to the existing one, could be easier to add.

So I don’t want to have to pass parameters to request standard parsing for this or that component, I only want one function that forgets the special registries that urllib.parse relies on and just parses a well-formed URI into the universal components (scheme host path etc).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I don’t want to have to pass parameters to request standard parsing for this or that component, I only want one function that forgets the special registries that urllib.parse relies on and just parses a well-formed URI into the universal components (scheme host path etc).

With the currently pushed code, this would be a one line lambda:

from urllib.parse import SchemeFlag, urlparse

parse_standardized = lambda url: urlparse(url, flags=SchemeFlag.NETLOC | SchemeFlag.RELATIVE | SchemeFlag.PARAMS)

Is this worth adding to the PR? I imagine it could be done more extremely (i.e., completely redefine the behavior of urlparse), but would backwards compat not be a concern?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we should not redefine the behaviour of urlparse.

I was always talking about adding another function. Yes it can be a one-liner, but my point is that I don’t see the usefulness of having the separate flags to pick and choose parts of standard parsing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@merwok - added comments to issue; discussion here should be about this PR, discussion about competing PRs (even if the other PRs haven't been written yet ;) should be on the issue tracker.

describe methods for URL resolution, usually by scheme. These resolution classes
determine, namely, whether a scheme supports, respectively, relative addressing,
preserving the netloc (domain name), and preserving the parameters."""
SchemeClass = Enum('SchemeClass', 'RELATIVE NETLOC PARAMS')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use a class statement so the docstring can actually be a docstring.

@@ -363,7 +394,7 @@ def _fix_result_transcoding():
_fix_result_transcoding()
del _fix_result_transcoding

def urlparse(url, scheme='', allow_fragments=True):
def urlparse(url, scheme='', allow_fragments=True, classes=set()):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid mutable defaults, an empty iterable works well. (There are other cases of this throughout the diff.)

Suggested change
def urlparse(url, scheme='', allow_fragments=True, classes=set()):
def urlparse(url, scheme='', allow_fragments=True, classes=()):

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JelleZijlstra using SchemeFlag() creates an immutable default.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was referring to the original code that used sets, not to your flags suggestion.

Copy link
Member

@ethanfurman ethanfurman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think SchemeFlag works better than SchemeClass. Either way, use an enum.Flag for it, and consider using __repr__ similar to the one in re.RegexFlag.

@@ -38,13 +39,19 @@
"urlsplit", "urlunsplit", "urlencode", "parse_qs",
"parse_qsl", "quote", "quote_plus", "quote_from_bytes",
"unquote", "unquote_plus", "unquote_to_bytes",
"DefragResult", "ParseResult", "SplitResult",
"DefragResult", "ParseResult", "SplitResult", "SchemeClass",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the example of re.RegexFlag, name this SchemeFlag. Also, use enum.Flag instead of enum.Enum.

describe methods for URL resolution, usually by scheme. These resolution classes
determine, namely, whether a scheme supports, respectively, relative addressing,
preserving the netloc (domain name), and preserving the parameters."""
SchemeClass = Enum('SchemeClass', 'RELATIVE NETLOC PARAMS')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SchemeFlag

@@ -60,6 +67,30 @@
'https', 'shttp', 'rtsp', 'rtspu', 'sip', 'sips',
'mms', 'sftp', 'tel']


def _scheme_classes(scheme, overrides=set()):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overrides should be options or flags. (re.search uses flags)

Lib/urllib/parse.py Outdated Show resolved Hide resolved
@@ -386,7 +417,8 @@ def urlparse(url, scheme='', allow_fragments=True):
url, scheme, _coerce_result = _coerce_args(url, scheme)
splitresult = urlsplit(url, scheme, allow_fragments)
scheme, netloc, url, query, fragment = splitresult
if scheme in uses_params and ';' in url:
scheme_classes = _scheme_classes(scheme, overrides=classes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_scheme_classes(scheme, overrides=classes) --> _scheme_classes(scheme, flags)

parameter.

"""
scheme_classes = set(overrides)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove line

scheme_classes = set(overrides)

if scheme in uses_relative:
scheme_classes.add(SchemeClass.RELATIVE)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options |= SchemeFlag.RELATIVE

same transformation for the next two branches

if scheme in uses_params:
scheme_classes.add(SchemeClass.PARAMS)

return scheme_classes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return options

@bedevere-bot
Copy link

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

@oldaccountdeadname
Copy link
Author

I have made the requested changes; please review again.

Again, thanks so much for the thorough review, and please tell me if there's anything missing!

@bedevere-bot
Copy link

Thanks for making the requested changes!

@ethanfurman: please review the changes made to this pull request.

@orsenthil
Copy link
Member

(copying from #90495) - we can continue discussion here as lot of context is preserved here.


Hi @lincolnauster , I was -1 and was thinking much on introducing a flag with the enum in the parse module.

urlparse(url, scheme='', allow_fragments=True, flags=SchemeFlag(0)):

This API signature is going to confuse people and will be huge blocker for further adoption and change (even if the default arguments are specified). I was thinking how best to mitigate that.

  1. Did we ever consider not going to flag as as parameter?
  2. Any other default for the flag rather than an enum?

If design design required, we can bring it to a wider forum too.

@orsenthil orsenthil requested a review from gpshead April 14, 2022 22:43
@oldaccountdeadname
Copy link
Author

oldaccountdeadname commented Apr 14, 2022 via email

Copy link
Member

@gpshead gpshead left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Senthil about the API growing something unobvious. If we add a parameter, it should be keyword only and well named to indicate that it is unusual to provide it. I'd also set its default value to None rather than introducing the casual user to a new concept (SchemeFlag).

The name flags= is probably not sufficient. Something more wordy like behavior_overrides= would communicate better that these are not necessary in most cases as reasonable default behaviors exist. Similarly the name SchemeFlags doesn't necessarily communicate the right thing when seen in code as it technically has nothing to do with a scheme itself. Perhaps something like ParseBehaviors?

ParseResult(scheme='http', netloc='docs.python.org:80',
path='/3/library/urllib.parse.html', params='',
query='highlight=params', fragment='url-parsing')
ParseResult(scheme='http', netloc='docs.python.org:80', path='/3/library/urllib.parse.html', params='', query='highlight=params', fragment='url-parsing')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not reformat doctest examples. these were formatted to be narrow to avoid horizontal scrollbars in documentation on most common displays and to keep the .rst itself <80 columns when possible.

reformatting is unrelated to the change at hand and distracts from the actual change.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoring the original formatting causes the doctest to fail, I should've broken that out into a separate and clear commit... I'm doctesting with .python -m doctest. Is that wrong, or is there some other way I can keep the old linebreaks?

Copy link
Member

@ethanfurman ethanfurman Apr 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gpshead re behavior_overrides vs flags: aren't flags usually behavior overrides? ssl, socket, _pydecimal, _osx_support, and re all use flags, while doctest uses compileflags, _pyio use dec_flags, and subprocess uses creationflags.

My first choice here would be a simple flags, and it should be easily understood that the flags given will modify the parsing behavior of urlparse. Would it be more precise to call it uri_flags? At any rate, behavior_overrides is no less generic and much more verbose than flags.

@@ -348,19 +349,22 @@ or on combining URL components into a URL string.
with an empty query; the RFC states that these are equivalent).


.. function:: urljoin(base, url, allow_fragments=True)
.. function:: urljoin(base, url, allow_fragments=True, classes=SchemeFlag(0))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this called classes here and flags above? Consistency is important. These should also be made keyword only arguments.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to update after renaming from classes to flags. I'll change it over to behavior_overrides.

@@ -39,12 +40,35 @@
"parse_qsl", "quote", "quote_plus", "quote_from_bytes",
"unquote", "unquote_plus", "unquote_to_bytes",
"DefragResult", "ParseResult", "SplitResult",
"SchemeFlag", "RELATIVE", "NETLOC", "PARAMS", "UNIVERSAL",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets not pollute __all__ with the CONSTANT_NAMES. People shouldn't really use from urllib.parse import * but if they do they shouldn't get these, just SchemeFlag.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ethanfurman thoughts? I remember you suggested that these should be exported as such for code like

urlparse(uri_string, flags=UNIVERSAL)

or similar. I'm fine either way, but do agree that the namespace would be cleaner were the flags not exported individually.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting them in globals() is not for the from ... import * case, since, as @gpshead said, folks should not be doing that; putting them in globals() is to enable urlparse.RELATIVE usage, much like we have re.IGNORECASE and not re.RegexFlag.IGNORECASE.

Lib/urllib/parse.py Outdated Show resolved Hide resolved
@@ -417,6 +417,11 @@ def test_urljoins(self):
self.checkJoin('svn+ssh://pathtorepo/dir1', 'dir2', 'svn+ssh://pathtorepo/dir2')
self.checkJoin('ws://a/b','g','ws://a/g')
self.checkJoin('wss://a/b','g','wss://a/g')
self.checkJoin(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make these new SchemeFlag specific test methods instead of appending to an existing long one.

self.checkJoin(
'nonsensebase://net.loc/url/', '..',
'nonsensebase://net.loc/',
flags=(urllib.parse.SchemeFlag.RELATIVE | urllib.parse.SchemeFlag.NETLOC),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add more test cases that explicitly cover PARAMS and UNIVERSAL behaviors.

@@ -363,7 +408,7 @@ def _fix_result_transcoding():
_fix_result_transcoding()
del _fix_result_transcoding

def urlparse(url, scheme='', allow_fragments=True):
def urlparse(url, scheme='', allow_fragments=True, flags=SchemeFlag(0)):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does the 0 mean?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gpshead Somewhere I thought you said it should be flags=None instead -- I agree.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None is used - 677ed1aac3.

"""Join a base URL and a possibly relative URL to form an absolute
interpretation of the latter."""
interpretation of the latter. Some logic may be enabled by setting
the classes variable."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also suggest making the new parameter be keyword only.

@bedevere-bot
Copy link

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

@oldaccountdeadname
Copy link
Author

Hi, sorry for getting back to this late, and thanks for all the feedback.

I agree with Senthil about the API growing something unobvious. If we add a parameter, it should be keyword only and well named to indicate that it is unusual to provide it. I'd also set its default value to None rather than introducing the casual user to a new concept (SchemeFlag).

The name flags= is probably not sufficient. Something more wordy like behavior_overrides= would communicate better that these are not necessary in most cases as reasonable default behaviors exist. Similarly the name SchemeFlags doesn't necessarily communicate the right thing when seen in code as it technically has nothing to do with a scheme itself. Perhaps something like ParseBehaviors?

Agreed, I'll push these API clarifications shortly!

@ethanfurman
Copy link
Member

Okay, I finally had some time to do a thorough review of the module and, as much as I love enums, I don't think this is the right solution to this problem:

  • allow_fragments should be part of the enum, but that would just be confusing at this point
  • specifying NETLOC or RELATIVE to urlparse() does nothing

I like the attempt to unify the three class lists, but I don't think this approach is the best choice for the urlparse module as it exists.

Given that we already have an allow_fragments flag, I think the best path forward is to add an allow_params flag, with a default of False -- or add a separate universal parsing function, as has been suggested earlier.

@lincolnauster Are you interested in converting this PR to one of the two above choices?

Copy link
Member

@ethanfurman ethanfurman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See previous comment.

@oldaccountdeadname
Copy link
Author

oldaccountdeadname commented Jun 12, 2022 via email

@oldaccountdeadname
Copy link
Author

oldaccountdeadname commented Oct 11, 2022 via email

@oldaccountdeadname
Copy link
Author

oldaccountdeadname commented Oct 11, 2022 via email

@orsenthil
Copy link
Member

I think this change is stale now. We really do not want to introduce additional complexity to the parsing here, at least many changes in one go. I am closing this request, and we can reopen it to add individual changes (minor refactors in a planned manner) if we want a generic solution. Current diff can introduce complexity that many reviewers have observed above.

Thank you for your contribution.

@orsenthil orsenthil closed this Apr 21, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
awaiting changes stale Stale PR or inactive for long period of time.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants