Repository: omni-us/jsonargparse
Pull request #222 (2022-12-22 18:16:40)

Several improvements to path types
## What does this PR do?

Adds several new features for path types:
- Relative paths within remote fsspec/url config files.
- New context manager methods: `open` and `relative_path_context`.
- Implements the `os.PathLike` protocol.
- New path mode ``cc`` to not require the parent directory to exists but that it can be created.

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/master/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=222)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=222)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222&metric=new_duplicated_lines_density&view=list)


codecov-commenter: # [Codecov](https://codecov.io/gh/omni-us/jsonargparse/pull/222?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Base: **100.00**% // Head: **100.00**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`12cf0ef`)](https://codecov.io/gh/omni-us/jsonargparse/pull/222?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) compared to base [(`e6ab5fb`)](https://codecov.io/gh/omni-us/jsonargparse/commit/e6ab5fb19130255b26056a71d7be88d7ee291ad5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master      #222   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           19        19           
  Lines         4990      5074   +84     
=========================================
+ Hits          4990      5074   +84     
```

| Flag | Coverage Δ | |
|---|---|---|
| py3.10 | `87.62% <75.78%> (-0.32%)` | :arrow_down: |
| py3.10_all | `99.88% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6 | `87.27% <75.59%> (-0.32%)` | :arrow_down: |
| py3.6_all | `99.62% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7 | `87.29% <75.59%> (-0.32%)` | :arrow_down: |
| py3.7_all | `99.80% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8 | `87.60% <75.78%> (-0.32%)` | :arrow_down: |
| py3.8_all | `99.84% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9 | `87.60% <75.78%> (-0.32%)` | :arrow_down: |
| py3.9_all | `99.86% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/omni-us/jsonargparse/pull/222?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [jsonargparse/optionals.py](https://codecov.io/gh/omni-us/jsonargparse/pull/222/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL29wdGlvbmFscy5weQ==) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/typehints.py](https://codecov.io/gh/omni-us/jsonargparse/pull/222/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL3R5cGVoaW50cy5weQ==) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/typing.py](https://codecov.io/gh/omni-us/jsonargparse/pull/222/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL3R5cGluZy5weQ==) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/util.py](https://codecov.io/gh/omni-us/jsonargparse/pull/222/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL3V0aWwucHk=) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/omni-us/jsonargparse/pull/222?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=222)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=222&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=222&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f4f6c13..6a9373a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -26,6 +26,12 @@ Added
   a previous parse link and vice versa `#208
   <https://github.com/omni-us/jsonargparse/issues/208>`__.
 - New resolver that identifies parameter types from stub files ``*.pyi``.
+- Support for relative paths within remote fsspec/url config files.
+- New context manager methods for path types: ``open`` and
+  ``relative_path_context``.
+- Path types now implement the ``os.PathLike`` protocol.
+- New path mode ``cc`` to not require the parent directory to exists but that it
+  can be created.
 
 Fixed
 ^^^^^
@@ -36,6 +42,7 @@ Fixed
   <https://github.com/omni-us/jsonargparse/issues/205>`__.
 - ``fail_untyped=False`` not propagated to subclass ``--*.help`` actions.
 - Issues reported by CodeQL.
+- Incorrect value when ``Path`` is cast to ``str`` and ``rel_path`` was changed.
 
 Changed
 ^^^^^^^
@@ -43,6 +50,8 @@ Changed
   target key does not have ``init_args`` `pytorch-lightning#16032
   <https://github.com/Lightning-AI/lightning/issues/16032>`__.
 - The ``signatures`` extras now installs the ``typeshed-client`` package.
+- ``validators`` package is no longer a dependency.
+- Path types are no longer a subclass of ``str``.
 
 
 v4.18.0 (2022-11-29)
diff --git a/README.rst b/README.rst
index 01a0af3..c6046b5 100644
--- a/README.rst
+++ b/README.rst
@@ -619,21 +619,20 @@ Parsing URLs
 
 The :func:`.path_type` function also supports URLs which after parsing, the
 :py:meth:`.Path.get_content` method can be used to perform a GET request to the
-corresponding URL and retrieve its content. For this to work the *validators*
-and *requests* python packages are required. Alternatively, :func:`.path_type`
-can also be used for `fsspec <https://filesystem-spec.readthedocs.io>`__
-supported file systems. The respective optional package(s) will be installed
-along with jsonargparse if installed with the ``urls`` or ``fsspec`` extras
-require as explained in section :ref:`installation`.
+corresponding URL and retrieve its content. For this to work the *requests*
+python package is required. Alternatively, :func:`.path_type` can also be used
+for `fsspec <https://filesystem-spec.readthedocs.io>`__ supported file systems.
+The respective optional package(s) will be installed along with jsonargparse if
+installed with the ``urls`` or ``fsspec`` extras require as explained in section
+:ref:`installation`.
 
 The ``'u'`` flag is used to parse URLs using requests and the flag ``'s'`` to
 parse fsspec file systems. For example if it is desired that an argument can be
 either a readable file or URL, the type would be created as ``Path_fur =
-path_type('fur')``. If the value appears to be a URL according to
-:func:`validators.url.url` then a HEAD request would be triggered to check if it
-is accessible. To get the content of the parsed path, without needing to care if
-it is a local file or a URL, the :py:meth:`.Path.get_content` method Scan be
-used.
+path_type('fur')``. If the value appears to be a URL, a HEAD request would be
+triggered to check if it is accessible. To get the content of the parsed path,
+without needing to care if it is a local file or a URL, the
+:py:meth:`.Path.get_content` method Scan be used.
 
 If you import ``from jsonargparse import set_config_read_mode`` and then run
 ``set_config_read_mode(urls_enabled=True)`` or
@@ -650,6 +649,13 @@ like the following would work:
 
     my_tool.py --config http://example.com/config.yaml
 
+.. note::
+
+    Relative paths inside a remote path are parsed as remote. For example, for a
+    relative path ``model/state_dict.pt`` found inside
+    ``s3://bucket/config.yaml``, its parsed absolute path becomes
+    ``s3://bucket/model/state_dict.pt``.
+
 
 .. _boolean-arguments:
 
diff --git a/jsonargparse/optionals.py b/jsonargparse/optionals.py
index 61f3f48..71bb33e 100644
--- a/jsonargparse/optionals.py
+++ b/jsonargparse/optionals.py
@@ -18,7 +18,7 @@ typing_extensions_support = find_spec('typing_extensions') is not None
 typeshed_client_support = find_spec('typeshed_client') is not None
 jsonschema_support = find_spec('jsonschema') is not None
 jsonnet_support = find_spec('_jsonnet') is not None
-url_support = False if any(find_spec(x) is None for x in ['validators', 'requests']) else True
+url_support = find_spec('requests') is not None
 docstring_parser_support = find_spec('docstring_parser') is not None
 argcomplete_support = find_spec('argcomplete') is not None
 fsspec_support = find_spec('fsspec') is not None
@@ -97,12 +97,6 @@ def import_jsonnet(importer):
     return _jsonnet
 
 
-def import_url_validator(importer):
-    with missing_package_raise('validators', importer):
-        from validators.url import url as url_validator
-    return url_validator
-
-
 def import_requests(importer):
     with missing_package_raise('requests', importer):
         import requests
@@ -150,15 +144,14 @@ def set_config_read_mode(
         fsspec_enabled: Whether to read config files from fsspec supported file systems.
     """
     imports = {
-        'u': [import_url_validator, import_requests],
-        's': [import_fsspec],
+        'u': import_requests,
+        's': import_fsspec,
     }
 
     def update_mode(flag, enabled):
         global _config_read_mode
         if enabled:
-            for import_func in imports[flag]:
-                import_func('set_config_read_mode')
+            imports[flag]('set_config_read_mode')
             if flag not in _config_read_mode:
                 _config_read_mode = _config_read_mode.replace('f', 'f'+flag)
         else:
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index 32d4fcd..485468a 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -1082,7 +1082,7 @@ def typehint_from_action(action_or_typehint):
 
 
 def type_to_str(obj):
-    if obj in {bool, tuple} or is_subclass(obj, (int, float, str, Enum)):
+    if obj in {bool, tuple} or is_subclass(obj, (int, float, str, Path, Enum)):
         return obj.__name__
     return re.sub(r'[A-Za-z0-9_<>.]+\.', '', str(obj)).replace('NoneType', 'null')
 
diff --git a/jsonargparse/typing.py b/jsonargparse/typing.py
index 57f8f48..e5d7cf9 100644
--- a/jsonargparse/typing.py
+++ b/jsonargparse/typing.py
@@ -44,17 +44,19 @@ _operators1 = {
 }
 _operators2 = {v: k for k, v in _operators1.items()}
 
-registered_types: Dict[Union[str, Type, Tuple], Any] = {}
+registered_types: Dict[tuple, type] = {}
+registered_type_handlers: Dict[type, 'RegisteredType'] = {}
+registration_pending: Dict[str, Callable] = {}
 
 
 def create_type(
     name: str,
-    base_type: Type,
+    base_type: type,
     check_value: Callable,
     register_key: Optional[Tuple] = None,
     docstring: Optional[str] = None,
     extra_attrs: Optional[dict] = None,
-) -> Type:
+) -> type:
 
     if register_key in registered_types:
         registered_type = registered_types[register_key]
@@ -82,11 +84,11 @@ def create_type(
 
 def restricted_number_type(
     name: Optional[str],
-    base_type: Type,
+    base_type: type,
     restrictions: Union[Tuple, List[Tuple]],
     join: str = 'and',
     docstring: Optional[str] = None,
-) -> Type:
+) -> type:
     """Creates or returns an already registered restricted number type class.
 
     Args:
@@ -154,7 +156,7 @@ def restricted_string_type(
     name: str,
     regex: Union[str, Pattern],
     docstring: Optional[str] = None,
-) -> Type:
+) -> type:
     """Creates or returns an already registered restricted string type class.
 
     Args:
@@ -197,7 +199,7 @@ def path_type(
     mode: str,
     docstring: Optional[str] = None,
     skip_check: bool = False,
-) -> Type:
+) -> type:
     """Creates or returns an already registered path type class.
 
     Args:
@@ -226,10 +228,10 @@ def path_type(
         _skip_check = skip_check
         _type = str
 
-        def __init__(self, v):
-            super().__init__(v, mode=self._mode, skip_check=self._skip_check)
+        def __init__(self, v, **k):
+            super().__init__(v, mode=self._mode, skip_check=self._skip_check, **k)
 
-    restricted_type = type(name, (PathType, str), {'__doc__': docstring})
+    restricted_type = type(name, (PathType,), {'__doc__': docstring})
     add_type(restricted_type, register_key, type_check=_is_path_type)
 
     return restricted_type
@@ -272,7 +274,7 @@ def register_type(
     type_check: Callable = lambda v, t: v.__class__ == t,
     fail_already_registered: bool = True,
     uniqueness_key: Optional[Tuple] = None,
-):
+) -> None:
     """Registers a new type for use in jsonargparse parsers.
 
     Args:
@@ -284,34 +286,32 @@ def register_type(
         fail_already_registered: Whether to fail if type has already been registered.
         uniqueness_key: Key to determine uniqueness of type.
     """
-    type_wrapper = RegisteredType(type_class, serializer, deserializer, deserializer_exceptions, type_check)
+    type_handler = RegisteredType(type_class, serializer, deserializer, deserializer_exceptions, type_check)
     fail_already_registered = globals().get('_fail_already_registered', fail_already_registered)
     if not uniqueness_key and fail_already_registered and get_registered_type(type_class):
-        if type_wrapper == registered_types[type_class]:
+        if type_handler == registered_type_handlers[type_class]:
             return
         raise ValueError(f'Type "{type_class}" already registered with different serializer and/or deserializer.')
-    registered_types[type_class] = type_wrapper
+    registered_type_handlers[type_class] = type_handler
     if uniqueness_key is not None:
         registered_types[uniqueness_key] = type_class
 
 
 def register_type_on_first_use(import_path: str, *args, **kwargs):
-    registered_types[import_path] = lambda: register_type(
+    registration_pending[import_path] = lambda: register_type(
         import_object(import_path),
         *args,
         **kwargs,
     )
 
 
-def get_registered_type(type_class):
-    if type_class in registered_types:
-        return registered_types[type_class]
-    with suppress(AttributeError, ValueError):
-        import_path = get_import_path(type_class)
-        if import_path in registered_types:
-            registered_types.pop(import_path)()
-            return registered_types[type_class]
-    return None
+def get_registered_type(type_class) -> Optional[RegisteredType]:
+    if type_class not in registered_type_handlers:
+        with suppress(AttributeError, ValueError):
+            import_path = get_import_path(type_class)
+            if import_path in registration_pending:
+                registration_pending.pop(import_path)()
+    return registered_type_handlers.get(type_class)
 
 
 def add_type(type_class: Type, uniqueness_key: Optional[Tuple], type_check: Optional[Callable] = None):
@@ -350,11 +350,11 @@ NotEmptyStr = restricted_string_type('NotEmptyStr', r'^.*[^ ].*$',
 Email       = restricted_string_type('Email', r'^[^@ ]+@[^@ ]+\.[^@ ]+$',
                                      docstring=r'str restricted to the email pattern ^[^@ ]+@[^@ ]+\.[^@ ]+$')
 
-Path_fr = path_type('fr', docstring='str pointing to a file that exists and is readable')
-Path_fc = path_type('fc', docstring='str pointing to a file that can be created if it does not exist')
-Path_dw = path_type('dw', docstring='str pointing to a directory that exists and is writeable')
-Path_dc = path_type('dc', docstring='str pointing to a directory that can be created if it does not exist')
-Path_drw = path_type('drw', docstring='str pointing to a directory that exists and is readable and writeable')
+Path_fr = path_type('fr', docstring='path to a file that exists and is readable')
+Path_fc = path_type('fc', docstring='path to a file that can be created if it does not exist')
+Path_dw = path_type('dw', docstring='path to a directory that exists and is writeable')
+Path_dc = path_type('dc', docstring='path to a directory that can be created if it does not exist')
+Path_drw = path_type('drw', docstring='path to a directory that exists and is readable and writeable')
 
 register_type(os.PathLike, str, str)
 register_type(complex)
diff --git a/jsonargparse/util.py b/jsonargparse/util.py
index 9616d60..9a480c9 100644
--- a/jsonargparse/util.py
+++ b/jsonargparse/util.py
@@ -8,13 +8,26 @@ import stat
 import sys
 import textwrap
 import warnings
-from collections import namedtuple
+from collections import Counter, namedtuple
 from contextlib import contextmanager
 from contextvars import ContextVar
+from dataclasses import dataclass
 from functools import wraps
 from importlib import import_module
+from io import StringIO
 from types import BuiltinFunctionType, FunctionType, ModuleType
-from typing import Any, Callable, Optional, Tuple, Type, TypeVar, Union
+from typing import (
+    IO,
+    Any,
+    Callable,
+    Iterator,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
 
 from .loaders_dumpers import json_dump, load_value
 from .optionals import (
@@ -23,7 +36,6 @@ from .optionals import (
     import_fsspec,
     import_reconplogger,
     import_requests,
-    import_url_validator,
     reconplogger_support,
     url_support,
 )
@@ -53,6 +65,12 @@ NoneType = type(None)
 default_config_option_help = 'Path to a configuration file.'
 
 
+@dataclass
+class UrlData:
+    scheme: str
+    url_path: str
+
+
 class ParserError(Exception):
     """Error raised when parsing a value fails."""
 
@@ -286,18 +304,38 @@ def lenient_check_context(caller=None, lenient=True):
         lenient_check.reset(t)
 
 
+current_path_dir: ContextVar[Optional[str]] = ContextVar('current_path_dir', default=None)
+
+
 @contextmanager
-def change_to_path_dir(path: Optional['Path']):
+def change_to_path_dir(path: Optional['Path']) -> Iterator[Optional[str]]:
     """A context manager for running code in the directory of a path."""
-    chdir = path is not None and not (path.is_url or path.is_fsspec)
-    if chdir:
-        cwd = os.getcwd()
-        os.chdir(os.path.abspath(os.path.dirname(str(path))))
+    path_dir = current_path_dir.get()
+    chdir: Union[bool, str] = False
+    if path is not None:
+        if path._url_data and (path.is_url or path.is_fsspec):
+            scheme = path._url_data.scheme
+            path_dir = path._url_data.url_path
+        else:
+            scheme = ''
+            path_dir = path.abs_path
+            chdir = True
+        if 'd' not in path.mode:
+            path_dir = os.path.dirname(path_dir)
+        path_dir = scheme + path_dir
+
+    token = current_path_dir.set(path_dir)
+    if chdir and path_dir:
+        chdir = os.getcwd()
+        path_dir = os.path.abspath(path_dir)
+        os.chdir(path_dir)
+
     try:
-        yield None
+        yield path_dir
     finally:
+        current_path_dir.reset(token)
         if chdir:
-            os.chdir(cwd)
+            os.chdir(chdir)
 
 
 def hash_item(item):
@@ -383,19 +421,56 @@ def class_from_function(func: Callable[..., ClassType]) -> Type[ClassType]:
     return ClassFromFunction
 
 
+def parse_url(url: str) -> Optional[UrlData]:
+    index = url.rfind('://')
+    if index <= 0:
+        return None
+    return UrlData(
+        scheme=url[:index+3],
+        url_path=url[index+3:],
+    )
+
+
+def is_absolute(path: str) -> bool:
+    if path.find('://') > 0:
+        return True
+    return os.path.isabs(path)
+
+
+def resolve_relative_path(path: str) -> str:
+    parts = path.split('/')
+    resolved: List[str] = []
+    for part in parts:
+        if part == '..':
+            resolved.pop()
+        elif part != '.':
+            resolved.append(part)
+    return '/'.join(resolved)
+
+
 class Path:
     """Stores a (possibly relative) path and the corresponding absolute path.
 
-    When a Path instance is created it is checked that: the path exists, whether
-    it is a file or directory and whether has the required access permissions
-    (f=file, d=directory, r=readable, w=writeable, x=executable, c=creatable,
-    u=url, s=fsspec or in uppercase meaning not, i.e., F=not-file,
-    D=not-directory, R=not-readable, W=not-writeable and X=not-executable). The
-    absolute path can be obtained without having to remember the working
-    directory from when the object was created.
+    The absolute path can be obtained without having to remember the working
+    directory (or parent remote path) from when the object was created.
+
+    When a Path instance is created, it is checked that: the path exists,
+    whether it is a file or directory and whether it has the required access
+    permissions (f=file, d=directory, r=readable, w=writeable, x=executable,
+    c=creatable, u=url, s=fsspec or in uppercase meaning not, i.e., F=not-file,
+    D=not-directory, R=not-readable, W=not-writeable and X=not-executable).
+
+    The creatable flag "c" can be given one or two times. If give once, the
+    parent directory must exist and be writeable. If given twice, the parent
+    directory does not have to exist, but should be allowed to create.
     """
 
-    file_scheme = re.compile('^file:///?')
+    rel_path: str
+    abs_path: str
+    cwd: str
+    mode: str
+    _url_data: Optional[UrlData]
+    _file_scheme = re.compile('^file:///?')
 
     def __init__(
         self,
@@ -417,27 +492,42 @@ class Path:
             TypeError: If the path does not exist or does not agree with the mode.
         """
         self._check_mode(mode)
-        if cwd is None:
-            cwd = os.getcwd()
 
         is_url = False
         is_fsspec = False
         if isinstance(path, Path):
             is_url = path.is_url
             is_fsspec = path.is_fsspec
-            cwd = path.cwd  # type: ignore
-            abs_path = path.abs_path  # type: ignore
-            path = path.rel_path  # type: ignore
+            url_data = path._url_data
+            cwd = path.cwd
+            abs_path = path.abs_path
+            path = path.rel_path
         elif isinstance(path, str):
             abs_path = os.path.expanduser(path)
-            if self.file_scheme.match(abs_path):
-                abs_path = self.file_scheme.sub('' if os.name == 'nt' else '/', abs_path)
-            if 'u' in mode and url_support and import_url_validator('Path')(abs_path):
-                is_url = True
-            elif 's' in mode and fsspec_support and known_to_fsspec(abs_path):
-                is_fsspec = True
-            elif 'f' in mode or 'd' in mode:
-                abs_path = abs_path if os.path.isabs(abs_path) else os.path.join(cwd, abs_path)
+            if self._file_scheme.match(abs_path):
+                abs_path = self._file_scheme.sub('' if os.name == 'nt' else '/', abs_path)
+            absolute = is_absolute(abs_path)
+            url_data = parse_url(abs_path)
+            cwd_url_data = parse_url(cwd or current_path_dir.get() or os.getcwd())
+            if (
+                ('u' in mode or 's' in mode) and
+                (url_data or (cwd_url_data and not absolute))
+            ):
+                if cwd_url_data and not absolute:
+                    abs_path = resolve_relative_path(cwd_url_data.url_path + '/' + path)
+                    abs_path = cwd_url_data.scheme + abs_path
+                    url_data = parse_url(abs_path)
+                if cwd is None:
+                    cwd = current_path_dir.get() or os.getcwd()
+                if 'u' in mode and url_support:
+                    is_url = True
+                elif 's' in mode and fsspec_support and known_to_fsspec(abs_path):
+                    is_fsspec = True
+            else:
+                if cwd is None:
+                    cwd = os.getcwd()
+                abs_path = abs_path if absolute else os.path.join(cwd, abs_path)
+                url_data = None
         else:
             raise TypeError('Expected path to be a string or a Path object.')
 
@@ -464,6 +554,11 @@ class Path:
             ptype = 'Directory' if 'd' in mode else 'File'
             if 'c' in mode:
                 pdir = os.path.realpath(os.path.join(abs_path, '..'))
+                if not os.path.isdir(pdir) and mode.count('c') == 2:
+                    ppdir = None
+                    while not os.path.isdir(pdir) and pdir != ppdir:
+                        ppdir = pdir
+                        pdir = os.path.realpath(os.path.join(pdir, '..'))
                 if not os.path.isdir(pdir):
                     raise TypeError(ptype+' is not creatable since parent directory does not exist: '+abs_path)
                 if not os.access(pdir, os.W_OK):
@@ -472,7 +567,7 @@ class Path:
                     raise TypeError(ptype+' is not creatable since path already exists: '+abs_path)
                 if 'f' in mode and os.access(abs_path, os.F_OK) and not os.path.isfile(abs_path):
                     raise TypeError(ptype+' is not creatable since path already exists: '+abs_path)
-            else:
+            elif 'd' in mode or 'f' in mode:
                 if not os.access(abs_path, os.F_OK):
                     raise TypeError(ptype+' does not exist: '+abs_path)
                 if 'd' in mode and not os.path.isdir(abs_path):
@@ -500,6 +595,7 @@ class Path:
         self.abs_path = abs_path
         self.cwd = cwd
         self.mode = mode
+        self._url_data = url_data
         self.is_url: bool = is_url
         self.is_fsspec: bool = is_fsspec
         self.skip_check = skip_check
@@ -514,7 +610,17 @@ class Path:
             name += '_skip_check'
         return name+'('+self.rel_path+cwd+')'
 
-    def __call__(self, absolute:bool=True) -> str:
+    def __fspath__(self) -> str:
+        return self.abs_path
+
+    def __eq__(self, other: Any) -> bool:
+        if isinstance(other, Path):
+            return self.abs_path == other.abs_path
+        elif isinstance(other, str):
+            return str(self) == other
+        return False
+
+    def __call__(self, absolute: bool = True) -> str:
         """Returns the path as a string.
 
         Args:
@@ -522,15 +628,16 @@ class Path:
         """
         return self.abs_path if absolute else self.rel_path
 
-    def get_content(self, mode:str='r') -> str:
-        """Returns the contents of the file or the response of a GET request to the URL."""
+    def get_content(self, mode: str = 'r') -> str:
+        """Returns the contents of the file or the remote path."""
         if self.is_url:
-            requests = import_requests('Path with URL support')
+            assert mode == 'r'
+            requests = import_requests('Path.get_content')
             response = requests.get(self.abs_path)
             response.raise_for_status()
             return response.text
         elif self.is_fsspec:
-            fsspec = import_fsspec('Path')
+            fsspec = import_fsspec('Path.get_content')
             with fsspec.open(self.abs_path, mode) as handle:
                 with handle as input_file:
                     return input_file.read()
@@ -538,12 +645,35 @@ class Path:
             with open(self.abs_path, mode) as input_file:
                 return input_file.read()
 
+    @contextmanager
+    def open(self, mode: str = 'r') -> Iterator[IO]:
+        """Return an opened file object for the path."""
+        if self.is_url:
+            yield StringIO(self.get_content())
+        elif self.is_fsspec:
+            fsspec = import_fsspec('Path.open')
+            with fsspec.open(self.abs_path, mode) as handle:
+                yield handle
+        else:
+            with open(self.abs_path, mode) as handle:
+                yield handle
+
+    @contextmanager
+    def relative_path_context(self) -> Iterator[str]:
+        """Context manager to use this path's parent (directory or URL) for relative paths defined within."""
+        with change_to_path_dir(self) as path_dir:
+            assert isinstance(path_dir, str)
+            yield path_dir
+
     @staticmethod
-    def _check_mode(mode:str):
+    def _check_mode(mode: str):
         if not isinstance(mode, str):
             raise ValueError('Expected mode to be a string.')
         if len(set(mode)-set('fdrwxcusFDRWX')) > 0:
             raise ValueError('Expected mode to only include [fdrwxcusFDRWX] flags.')
+        for flag, count in Counter(mode).items():
+            if count > (2 if flag == 'c' else 1):
+                raise ValueError(f'Too many occurrences ({count}) for flag "{flag}".')
         if 'f' in mode and 'd' in mode:
             raise ValueError('Both modes "f" and "d" not possible.')
         if 'u' in mode and 'd' in mode:
diff --git a/setup.cfg b/setup.cfg
index bc33267..7d791d1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,7 +29,6 @@ jsonnet =
     jsonnet>=0.13.0; os_name == 'posix'
     jsonnet-binary>=0.17.0; os_name != 'posix'
 urls =
-    validators>=0.14.2
     requests>=2.18.4
 fsspec =
     fsspec>=0.8.4


Test patch:
diff --git a/jsonargparse_tests/test_core.py b/jsonargparse_tests/test_core.py
index aa96447..8aeac23 100755
--- a/jsonargparse_tests/test_core.py
+++ b/jsonargparse_tests/test_core.py
@@ -593,7 +593,7 @@ class AdvancedFeaturesTests(unittest.TestCase):
         self.assertEqual(yaml.safe_load(out.getvalue()), {'o': 1})
 
 
-    @unittest.skipIf(not (url_support and responses_available), 'validators, requests and responses packages are required')
+    @unittest.skipIf(not (url_support and responses_available), 'requests and responses packages are required')
     @responses_activate
     def test_urls(self):
         set_config_read_mode(urls_enabled=True)
diff --git a/jsonargparse_tests/test_deprecated.py b/jsonargparse_tests/test_deprecated.py
index e1c9925..eac2d95 100755
--- a/jsonargparse_tests/test_deprecated.py
+++ b/jsonargparse_tests/test_deprecated.py
@@ -133,7 +133,7 @@ class DeprecatedTests(unittest.TestCase):
         self.assertRaises(ValueError, lambda: ActionOperators(expr=[('<', 5), ('>=', 10)], join='xor'))
 
 
-    @unittest.skipIf(not url_support, 'validators and requests packages are required')
+    @unittest.skipIf(not url_support, 'requests package is required')
     def test_url_support_true(self):
         self.assertEqual('fr', get_config_read_mode())
         with catch_warnings(record=True) as w:
@@ -144,7 +144,7 @@ class DeprecatedTests(unittest.TestCase):
         self.assertEqual('fr', get_config_read_mode())
 
 
-    @unittest.skipIf(url_support, 'validators and requests packages should not be installed')
+    @unittest.skipIf(url_support, 'requests package should not be installed')
     def test_url_support_false(self):
         self.assertEqual('fr', get_config_read_mode())
         with catch_warnings(record=True) as w:
diff --git a/jsonargparse_tests/test_optionals.py b/jsonargparse_tests/test_optionals.py
index 6133d1e..4c21cb1 100755
--- a/jsonargparse_tests/test_optionals.py
+++ b/jsonargparse_tests/test_optionals.py
@@ -16,7 +16,6 @@ from jsonargparse.optionals import (
     import_jsonschema,
     import_requests,
     import_ruyaml,
-    import_url_validator,
     jsonnet_support,
     jsonschema_support,
     ruyaml_support,
@@ -55,18 +54,13 @@ class JsonnetSupportTests(unittest.TestCase):
 
 class UrlSupportTests(unittest.TestCase):
 
-    @unittest.skipIf(not url_support, 'validators and requests packages are required')
+    @unittest.skipIf(not url_support, 'requests package is required')
     def test_url_support_true(self):
-        import_url_validator('test_url_support_true')
         import_requests('test_url_support_true')
 
 
-    @unittest.skipIf(url_support, 'validators and requests packages should not be installed')
+    @unittest.skipIf(url_support, 'requests package should not be installed')
     def test_url_support_false(self):
-        if find_spec('validators') is None:
-            with self.assertRaises(ImportError) as context:
-                import_url_validator('test_url_support_false')
-                self.assertIn('test_url_support_false', context.msg)
         if find_spec('requests') is None:
             with self.assertRaises(ImportError) as context:
                 import_requests('test_url_support_false')
@@ -159,7 +153,7 @@ class ConfigReadModeTests(unittest.TestCase):
         set_config_read_mode()
 
 
-    @unittest.skipIf(not url_support, 'validators and requests packages are required')
+    @unittest.skipIf(not url_support, 'requests package is required')
     def test_url_support_true(self):
         self.assertEqual('fr', get_config_read_mode())
         set_config_read_mode(urls_enabled=True)
@@ -168,7 +162,7 @@ class ConfigReadModeTests(unittest.TestCase):
         self.assertEqual('fr', get_config_read_mode())
 
 
-    @unittest.skipIf(url_support, 'validators and requests packages should not be installed')
+    @unittest.skipIf(url_support, 'request package should not be installed')
     def test_url_support_false(self):
         self.assertEqual('fr', get_config_read_mode())
         with self.assertRaises(ImportError):
diff --git a/jsonargparse_tests/test_typing.py b/jsonargparse_tests/test_typing.py
index aacdab2..514c4ce 100755
--- a/jsonargparse_tests/test_typing.py
+++ b/jsonargparse_tests/test_typing.py
@@ -13,10 +13,10 @@ from jsonargparse.typing import (
     NonNegativeFloat,
     NonNegativeInt,
     OpenUnitInterval,
+    Path_fc,
     Path_fr,
     PositiveFloat,
     PositiveInt,
-    RegisteredType,
     get_registered_type,
     path_type,
     register_type,
@@ -24,7 +24,7 @@ from jsonargparse.typing import (
     restricted_number_type,
     restricted_string_type,
 )
-from jsonargparse.util import import_object, object_path_serializer
+from jsonargparse.util import object_path_serializer
 from jsonargparse_tests.base import TempDirTestCase, mock_module
 
 
@@ -175,6 +175,11 @@ class PathTypeTests(TempDirTestCase):
         self.assertRaises(TypeError, lambda: Path_fr('does_not_exist'))
 
 
+    def test_Path_fc_with_kwargs(self):
+        path = Path_fc('some-file.txt', cwd=self.tmpdir)
+        self.assertEqual(path(), os.path.join(self.tmpdir, 'some-file.txt'))
+
+
     def test_already_registered(self):
         NewPath_fr = path_type('fr')
         self.assertEqual(Path_fr, NewPath_fr)
@@ -198,15 +203,9 @@ class PathTypeTests(TempDirTestCase):
 class OtherTests(unittest.TestCase):
 
     def test_pickle_module_types(self):
-        for import_path in [x for x in registered_types.keys() if isinstance(x, str)]:
-            get_registered_type(import_object(import_path))
-        for otype in registered_types.values():
-            if isinstance(otype, RegisteredType) or hasattr(__import__('jsonargparse.typing'), otype.__name__):
-                if isinstance(otype, RegisteredType):
-                    otype = otype.type_class
-                with self.subTest(str(otype)):
-                    utype = pickle.loads(pickle.dumps(otype))
-                    self.assertEqual(otype, utype)
+        for type_class in registered_types.values():
+            with self.subTest(str(type_class)):
+                self.assertEqual(type_class, pickle.loads(pickle.dumps(type_class)))
 
 
     def test_name_clash(self):
@@ -288,8 +287,6 @@ class OtherTests(unittest.TestCase):
         dump = parser.dump(cfg, format='json')
         self.assertEqual(dump, '{"elems":[1,2,3]}')
 
-        del registered_types[Elems]
-
 
 if __name__ == '__main__':
     unittest.main(verbosity=2)
diff --git a/jsonargparse_tests/test_util.py b/jsonargparse_tests/test_util.py
index 60539ac..4441432 100755
--- a/jsonargparse_tests/test_util.py
+++ b/jsonargparse_tests/test_util.py
@@ -8,15 +8,12 @@ import unittest
 import zipfile
 
 from jsonargparse import ArgumentParser, LoggerProperty, Path, null_logger
-from jsonargparse.optionals import (
-    fsspec_support,
-    import_fsspec,
-    reconplogger_support,
-    url_support,
-)
+from jsonargparse.optionals import fsspec_support, reconplogger_support, url_support
 from jsonargparse.util import (
+    current_path_dir,
     get_import_path,
     import_object,
+    parse_url,
     register_unresolvable_import_paths,
     unique,
 )
@@ -75,6 +72,8 @@ class PathTests(TempDirTestCase):
         self.assertEqual(path1.rel_path, path2.rel_path)
         self.assertEqual(path1.is_url, path2.is_url)
         self.assertRaises(TypeError, lambda: Path(True))
+        self.assertRaises(ValueError, lambda: Path(self.file_rw, '-'))
+        self.assertRaises(ValueError, lambda: Path(self.file_rw, 'frr'))
 
 
     def test_cwd(self):
@@ -82,6 +81,24 @@ class PathTests(TempDirTestCase):
         self.assertEqual(path.cwd, Path('file_rx', mode='fr', cwd=path.cwd).cwd)
 
 
+    def test_empty_mode(self):
+        path = Path('does_not_exist', '')
+        self.assertEqual(path(), os.path.join(self.tmpdir, 'does_not_exist'))
+
+
+    def test_pathlike(self):
+        path = Path(self.file_rw)
+        self.assertEqual(os.fspath(path), os.path.join(self.tmpdir, self.file_rw))
+        self.assertEqual(os.path.dirname(path), self.tmpdir)
+
+
+    def test_equality_operator(self):
+        path1 = Path(self.file_rw)
+        path2 = Path(os.path.join(self.tmpdir, self.file_rw))
+        self.assertEqual(path1, path2)
+        self.assertNotEqual(Path('123', 'fc'), 123)
+
+
     def test_file_access_mode(self):
         Path(self.file_rw, 'frw')
         Path(self.file_r, 'fr')
@@ -115,11 +132,13 @@ class PathTests(TempDirTestCase):
     def test_create_mode(self):
         Path(self.file_rw, 'fcrw')
         Path(os.path.join(self.tmpdir, 'file_c'), 'fc')
+        Path(os.path.join(self.tmpdir, 'not_existing_dir', 'file_c'), 'fcc')
         Path(self.dir_rwx, 'dcrwx')
         Path(os.path.join(self.tmpdir, 'dir_c'), 'dc')
         if is_posix:
             self.assertRaises(TypeError, lambda: Path(os.path.join(self.dir_rx, 'file_c'), 'fc'))
             self.assertRaises(TypeError, lambda: Path(os.path.join(self.dir_rx, 'dir_c'), 'dc'))
+            self.assertRaises(TypeError, lambda: Path(os.path.join(self.dir_rx, 'not_existing_dir', 'file_c'), 'fcc'))
         self.assertRaises(TypeError, lambda: Path(self.file_rw, 'dc'))
         self.assertRaises(TypeError, lambda: Path(self.dir_rwx, 'fc'))
         self.assertRaises(TypeError, lambda: Path(os.path.join(self.dir_rwx, 'ne', 'file_c'), 'fc'))
@@ -161,7 +180,28 @@ class PathTests(TempDirTestCase):
             self.assertEqual(path(), os.path.join(self.tmpdir, self.file_rw))
 
 
-    @unittest.skipIf(not url_support or not responses_available, 'validators, requests and responses packages are required')
+    def test_parse_url(self):
+        for url, scheme, path in [
+            ('https://eg.com:8080/eg',                          'https://',                         'eg.com:8080/eg'),
+            ('dask::s3://bucket/key',                           'dask::s3://',                      'bucket/key'),
+            ('filecache::s3://bucket/key',                      'filecache::s3://',                 'bucket/key'),
+            ('zip://*.csv::simplecache::gcs://bucket/file.zip', 'zip://*.csv::simplecache::gcs://', 'bucket/file.zip'),
+            ('simplecache::zip://*.csv::gcs://bucket/file.zip', 'simplecache::zip://*.csv::gcs://', 'bucket/file.zip'),
+            ('zip://existing.txt::file://file1.zip',            'zip://existing.txt::file://',      'file1.zip'),
+            ('file.txt',       None, None),
+            ('../../file.txt', None, None),
+            ('/tmp/file.txt',  None, None),
+        ]:
+            with self.subTest(url):
+                url_data = parse_url(url)
+                if scheme is None:
+                    self.assertIsNone(url_data)
+                else:
+                    self.assertEqual(url_data.scheme, scheme)
+                    self.assertEqual(url_data.url_path, path)
+
+
+    @unittest.skipIf(not url_support or not responses_available, 'requests and responses packages are required')
     @responses_activate
     def test_urls_http(self):
         existing = 'http://example.com/existing-url'
@@ -215,8 +255,7 @@ class PathTests(TempDirTestCase):
         with self.assertRaises(TypeError):
             Path('unsupported://'+existing, mode='sr')
 
-        fsspec = import_fsspec('test_fsspec')
-
+        import fsspec
         nonexisting = 'nonexisting.txt'
         path = Path('memory://'+nonexisting, mode='sw')
         with fsspec.open(path(), 'w') as f:
@@ -224,6 +263,83 @@ class PathTests(TempDirTestCase):
         self.assertEqual(existing_body, path.get_content())
 
 
+    @unittest.skipIf(not url_support, 'requests package is required')
+    def test_relative_path_context_url(self):
+        path1 = Path('http://example.com/nested/path/file1.txt', mode='u')
+        with path1.relative_path_context() as dir:
+            self.assertEqual('http://example.com/nested/path', dir)
+            path2 = Path('../file2.txt', mode='u')
+            self.assertEqual(path2(), 'http://example.com/nested/file2.txt')
+
+
+    @unittest.skipIf(not fsspec_support, 'fsspec package is required')
+    def test_relative_path_context_fsspec(self):
+        local_path = pathlib.Path(self.tmpdir) / 'file0.txt'
+        local_path.write_text('zero')
+
+        import fsspec
+        mem_path = Path('memory://one/two/file1.txt', mode='sc')
+        with fsspec.open(mem_path(), 'w') as f:
+            f.write('one')
+
+        with Path('memory://one/two/three/file1.txt', mode='sc').relative_path_context() as dir1:
+            self.assertEqual('memory://one/two/three', dir1)
+            self.assertEqual('memory://one/two/three', current_path_dir.get())
+
+            with self.subTest('absolute local path'):
+                path0 = Path(str(local_path), mode='fr')
+                self.assertEqual('zero', path0.get_content())
+
+            with self.subTest('relative fsspec path'):
+                path1 = Path('../file1.txt', mode='fsr')
+                self.assertEqual('one', path1.get_content())
+                self.assertEqual(str(path1), '../file1.txt')
+                self.assertEqual(path1(), 'memory://one/two/file1.txt')
+                self.assertIsNotNone(path1._url_data)
+
+            with self.subTest('nested fsspec dir'):
+                with path1.relative_path_context() as dir2:
+                    self.assertEqual('memory://one/two', dir2)
+                    path2 = Path('four/five/six/../file2.txt', mode='fsc')
+                    self.assertEqual(path2(), 'memory://one/two/four/five/file2.txt')
+
+            with self.subTest('non-fsspec path'):
+                path3 = Path('file3.txt', mode='fc')
+                self.assertEqual(path3(), str(pathlib.Path(self.tmpdir) / 'file3.txt'))
+
+        self.assertIsNone(current_path_dir.get())
+
+
+    def test_path_open_local(self):
+        pathlib.Path('file.txt').write_text('content')
+        path = Path('file.txt', mode='fr')
+        with path.open() as f:
+            self.assertEqual('content', f.read())
+
+
+    @unittest.skipIf(not url_support or not responses_available, 'requests and responses packages are required')
+    @responses_activate
+    def test_path_open_url(self):
+        import responses
+        url = 'http://example.com/file.txt'
+        responses.add(responses.GET, url, body='content', status=200)
+        responses.add(responses.HEAD, url, status=200)
+        path = Path(url, mode='ur')
+        with path.open() as f:
+            self.assertEqual('content', f.read())
+
+
+    @unittest.skipIf(not fsspec_support, 'fsspec package is required')
+    def test_path_open_fsspec(self):
+        import fsspec
+        path = Path('memory://nested/file.txt', mode='sc')
+        with fsspec.open(path(), 'w') as f:
+            f.write('content')
+        path = Path('memory://nested/file.txt', mode='sr')
+        with path.open() as f:
+            self.assertEqual('content', f.read())
+
+
 class LoggingPropertyTests(unittest.TestCase):
 
     def setUp(self):