Repository: linkml/linkml-runtime
Pull request #350: Ensure that relative imports can be imported without requiring `./` in front of the import file name
Author: login: ialarmedalien
Created: 2024-11-22T17:24:14Z | Merged: 2025-04-28T21:06:52Z

fixes https://github.com/linkml/linkml/issues/2422

Ensures that relative imports work correctly without having `./` in front of the import file name.


Diff:
diff --git a/README.md b/README.md
index d48c3cc2..585aade4 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Runtime support for linkml generated data classes.
 
 ## About
 
-This python library provides runtime support for [LinkML](https://linkml.io/linkml/) datamodels.
+This Python library provides runtime support for [LinkML](https://linkml.io/linkml/) datamodels.
 
 See the [LinkML repo](https://github.com/linkml/linkml) for the [Python Dataclass Generator](https://linkml.io/linkml/generators/python.html) which will convert a schema into a Python object model. That model will have dependencies on functionality in this library.
 
@@ -24,8 +24,8 @@ See [working with data](https://linkml.io/linkml/data/index.html) in the documen
 
 This repository also contains the Python dataclass representation of the [LinkML metamodel](https://github.com/linkml/linkml-model), and various utility functions that are useful for working with LinkML data and schemas.
 
-It also includes the [SchemaView](https://linkml.io/linkml/developers/manipulating-schemas.html) class for working with LinkML schemas
+It also includes the [SchemaView](https://linkml.io/linkml/developers/manipulating-schemas.html) class for working with LinkML schemas.
 
 ## Notebooks
 
-See the [notebooks](https://github.com/linkml/linkml-runtime/tree/main/notebooks) folder for examples
+See the [notebooks](https://github.com/linkml/linkml-runtime/tree/main/notebooks) folder for examples.
diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py
index bc76bcaa..f4d50a41 100644
--- a/linkml_runtime/utils/schemaview.py
+++ b/linkml_runtime/utils/schemaview.py
@@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
-        # File with URL schemes are not modified
-        return imported_sch
-    
-    if WINDOWS:
-        path = PurePath(os.path.normpath(PurePath(source_sch).parent / imported_sch)).as_posix()
-    else:
-        path = os.path.normpath(str(Path(source_sch).parent / imported_sch))
-
-    if imported_sch.startswith(".") and not path.startswith("."):
-        # Above condition handles cases where both source schema and imported schema are relative paths: these should remain relative
-        return f"./{path}"
-
-    return path
-
 
 @dataclass
 class SchemaUsage:
@@ -319,14 +300,24 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None,
                     # path, and the target import doesn't have : (as in a curie or a URI)
                     # we prepend the relative path. This WILL make the key in the `schema_map` not
                     # equal to the literal text specified in the importing schema, but this is
-                    # essential to sensible deduplication: eg. for
+                    # essential to sensible deduplication: e.g. for
                     # - main.yaml (imports ./types.yaml, ./subdir/subschema.yaml)
                     # - types.yaml
                     # - subdir/subschema.yaml (imports ./types.yaml)
                     # - subdir/types.yaml
                     # we should treat the two `types.yaml` as separate schemas from the POV of the
                     # origin schema.
-                    i = _resolve_import(sn, i)
+
+                    # if i is not a CURIE and sn looks like a path with at least one parent folder,
+                    # normalise i with respect to sn
+                    if "/" in sn and ":" not in i:
+                        if WINDOWS:
+                            # This cannot be simplified. os.path.normpath() must be called before .as_posix()
+                            i = PurePath(
+                                os.path.normpath(PurePath(sn).parent / i)
+                            ).as_posix()
+                        else:
+                            i = os.path.normpath(str(Path(sn).parent / i))
                     todo.append(i)
 
             # add item to closure
diff --git a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/four.yaml b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/four.yaml
new file mode 100644
index 00000000..153c1094
--- /dev/null
+++ b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/four.yaml
@@ -0,0 +1,13 @@
+id: four
+name: import_four
+title: Import Four
+description: |
+  Import loaded by the StepChild class.
+imports:
+  - linkml:types
+classes:
+  Four:
+    attributes:
+      value:
+        range: string
+        ifabsent: "Four"
diff --git a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/one.yaml b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/one.yaml
new file mode 100644
index 00000000..f234a431
--- /dev/null
+++ b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/one.yaml
@@ -0,0 +1,14 @@
+id: one
+name: import_one
+title: Import One
+description: |
+  Import loaded by the StepChild class.
+imports:
+  - linkml:types
+  - two
+classes:
+  One:
+    attributes:
+      value:
+        range: string
+        ifabsent: "One"
diff --git a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/stepchild.yaml b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/stepchild.yaml
new file mode 100644
index 00000000..08bc583c
--- /dev/null
+++ b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/stepchild.yaml
@@ -0,0 +1,16 @@
+id: stepchild
+name: stepchild
+title: stepchild
+description: |
+  Child class that imports files in the same directory as itself without consistently using `./` in the link notation.
+imports:
+  - linkml:types
+  - one
+  - two
+  - ./three
+classes:
+  StepChild:
+    attributes:
+      value:
+        range: string
+        ifabsent: "StepChild"
diff --git a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/three.yaml b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/three.yaml
new file mode 100644
index 00000000..18e31dcf
--- /dev/null
+++ b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/three.yaml
@@ -0,0 +1,14 @@
+id: three
+name: import_three
+title: Import Three
+description: |
+  Import loaded by the StepChild class.
+imports:
+  - linkml:types
+  - ./four
+classes:
+  Three:
+    attributes:
+      value:
+        range: string
+        ifabsent: "Three"
diff --git a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/two.yaml b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/two.yaml
new file mode 100644
index 00000000..859a3087
--- /dev/null
+++ b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/L2_0_0_2/two.yaml
@@ -0,0 +1,13 @@
+id: two
+name: import_two
+title: Import Two
+description: |
+  Import loaded by the StepChild class.
+imports:
+  - linkml:types
+classes:
+  Two:
+    attributes:
+      value:
+        range: string
+        ifabsent: "Two"
diff --git a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/main.yaml b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/main.yaml
index ad5c48a2..01add3d0 100644
--- a/tests/test_utils/input/imports_relative/L0_0/L1_0_0/main.yaml
+++ b/tests/test_utils/input/imports_relative/L0_0/L1_0_0/main.yaml
@@ -8,10 +8,11 @@ imports:
   - ../../L0_1/cousin
   - ./L2_0_0_0/child
   - ./L2_0_0_1/child
+  - L2_0_0_2/stepchild
 classes:
   Main:
     description: "Our intrepid main class!"
     attributes:
       value:
         range: string
-        ifabsent: "Main"
\ No newline at end of file
+        ifabsent: "Main"
diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py
index 2cca0cfa..86af5755 100644
--- a/tests/test_utils/test_schemaview.py
+++ b/tests/test_utils/test_schemaview.py
@@ -19,7 +19,7 @@
 
 SCHEMA_NO_IMPORTS = Path(INPUT_DIR) / 'kitchen_sink_noimports.yaml'
 SCHEMA_WITH_IMPORTS = Path(INPUT_DIR) / 'kitchen_sink.yaml'
-SCHEMA_WITH_STRUCTURED_PATTERNS = Path(INPUT_DIR) / "pattern-example.yaml"
+SCHEMA_WITH_STRUCTURED_PATTERNS = Path(INPUT_DIR) / 'pattern-example.yaml'
 SCHEMA_IMPORT_TREE = Path(INPUT_DIR) / 'imports' / 'main.yaml'
 SCHEMA_RELATIVE_IMPORT_TREE = Path(INPUT_DIR) / 'imports_relative' / 'L0_0' / 'L1_0_0' / 'main.yaml'
 SCHEMA_RELATIVE_IMPORT_TREE2 = Path(INPUT_DIR) / 'imports_relative' / 'L0_2' / 'main.yaml'
@@ -357,7 +357,7 @@ def test_caching():
     view.add_class(ClassDefinition('X'))
     assert len(['X']) == len(view.all_classes())
     view.add_class(ClassDefinition('Y'))
-    assert len(['X', 'Y']) ==  len(view.all_classes())
+    assert len(['X', 'Y']) == len(view.all_classes())
     # bypass view method and add directly to schema;
     # in general this is not recommended as the cache will
     # not be updated
@@ -546,6 +546,11 @@ def test_imports_relative():
         '../L1_0_1/dupe',
         './L2_0_0_0/child',
         './L2_0_0_1/child',
+        'L2_0_0_2/two',
+        'L2_0_0_2/one',
+        'L2_0_0_2/four',
+        'L2_0_0_2/three',
+        'L2_0_0_2/stepchild',
         'main'
     ]
 
@@ -716,6 +721,7 @@ def test_slot_inheritance():
     with pytest.raises(ValueError):
         view.slot_ancestors('s5')
 
+
 def test_attribute_inheritance():
     """
     Tests attribute inheritance edge cases.


Reviews:
user: login: sierra-moxon
state: APPROVED
author_association: MEMBER
submitted_at: 2025-02-04T17:55:31Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-10T03:29:37Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-18T16:57:17Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-19T23:53:00Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T00:38:30Z
user: login: sneakers-the-rat
body: thanks for fixing this. the `./` check always felt fragile to me. always love it when the fix is so simple because the stdlib tools are better than i knew they were!

no idea why upstream tests are failing, but doesn't look related to this. approving for the substance of the PR and hopefully that's uh just a temporary glitch
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-02-20T00:42:49Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T16:34:15Z
user: login: sneakers-the-rat
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T19:53:31Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T19:55:17Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T20:20:56Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T20:45:42Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:46:44Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T21:48:36Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T22:03:53Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T22:04:57Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T22:55:41Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T22:58:38Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T14:07:20Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T14:11:57Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T16:40:40Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T04:25:55Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T12:29:00Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T12:31:54Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T06:39:43Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T13:13:01Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T20:08:12Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T17:40:09Z
user: login: sneakers-the-rat
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T07:50:02Z
user: login: ialarmedalien
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-21T14:47:29Z

Review comments:
diff_hunk: @@ -303,7 +303,7 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None,
                     # - subdir/types.yaml
                     # we should treat the two `types.yaml` as separate schemas from the POV of the
                     # origin schema.
-                    if sn.startswith('.') and ':' not in i:
+                    if '/' in sn and ':' not in i:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: I think you want an additional check for something being an absolute path here:

```suggestion
                    if '/' in sn and ':' not in i and not PurePath(sn).is_absolute():
```

but otherwise this seems fine. the ':' check still protects against this breaking other protocol schemes.

only downside i can think of are that by allowing relative paths without leading `./` or `../`, they become less explicit, but shouldn't affect the way imports are normally used now. maybe a tiny chance of some weird json <-> yaml escaped slash problem, but that would be a json parsing bug.
created_at: 2025-02-10T03:29:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,7 +303,7 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None,
                     # - subdir/types.yaml
                     # we should treat the two `types.yaml` as separate schemas from the POV of the
                     # origin schema.
-                    if sn.startswith('.') and ':' not in i:
+                    if '/' in sn and ':' not in i:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: Can you suggest a test case to add where the importer will fail without the test for an absolute path? I tried to add as many variants of path representation as possible to the tests so I'd like to have at least one path that would fail if the suggested addition was not in place.
created_at: 2025-02-18T16:57:17Z
author_association: COLLABORATOR
diff_hunk: @@ -303,7 +303,7 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None,
                     # - subdir/types.yaml
                     # we should treat the two `types.yaml` as separate schemas from the POV of the
                     # origin schema.
-                    if sn.startswith('.') and ':' not in i:
+                    if '/' in sn and ':' not in i:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: sure - should be any second-level schema imported via absolute path. one second
created_at: 2025-02-19T23:52:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,7 +303,7 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None,
                     # - subdir/types.yaml
                     # we should treat the two `types.yaml` as separate schemas from the POV of the
                     # origin schema.
-                    if sn.startswith('.') and ':' not in i:
+                    if '/' in sn and ':' not in i:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: oh! pleasantly surprised to learn that an absolute path causes the thing it's being appended to to be ignored. learned something new :). Disregard this.
created_at: 2025-02-20T00:38:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,7 +303,7 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None,
                     # - subdir/types.yaml
                     # we should treat the two `types.yaml` as separate schemas from the POV of the
                     # origin schema.
-                    if sn.startswith('.') and ':' not in i:
+                    if '/' in sn and ':' not in i:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: Thanks for checking - glad I could provide a surprising learning experience for the day!
created_at: 2025-02-20T16:34:15Z
author_association: COLLABORATOR
diff_hunk: @@ -544,8 +544,13 @@ def test_imports_relative():
         '../../L0_1/L1_1_0/index',
         '../../L0_1/cousin',
         '../L1_0_1/dupe',
-        './L2_0_0_0/child',
-        './L2_0_0_1/child',
+        'L2_0_0_0/child',
path: tests/test_utils/test_schemaview.py
user: login: sneakers-the-rat
body: merged impl ensures `./` prepended to relative child paths in the internal representation but doesn't require them in input
created_at: 2025-03-31T19:55:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -544,8 +544,13 @@ def test_imports_relative():
         '../../L0_1/L1_1_0/index',
         '../../L0_1/cousin',
         '../L1_0_1/dupe',
-        './L2_0_0_0/child',
-        './L2_0_0_1/child',
+        'L2_0_0_0/child',
path: tests/test_utils/test_schemaview.py
user: login: ialarmedalien
body: Thanks. I am looking over the changes that were made because I'm suspicious that the "fix" (specifically, using `urlparse` and then checking for `scheme`) isn't working how people think it is, but rather as a side effect. `urlparse` is extremely lax in what it accepts and will parse; it does not validate whether or not what you give it is an URL.
created_at: 2025-03-31T20:20:56Z
author_association: COLLABORATOR
diff_hunk: @@ -544,8 +544,13 @@ def test_imports_relative():
         '../../L0_1/L1_1_0/index',
         '../../L0_1/cousin',
         '../L1_0_1/dupe',
-        './L2_0_0_0/child',
-        './L2_0_0_1/child',
+        'L2_0_0_0/child',
path: tests/test_utils/test_schemaview.py
user: login: sneakers-the-rat
body: well, the only thing it's used for there is to check if there is a scheme component, so passing other strings is desired behavior? but yeah if you find some bug then by all means patch it
created_at: 2025-03-31T20:45:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: `urlparse` does not check for URL validity, which isn't obvious if you haven't read the docs for the function. A better test here would be for `:`, which should only occur in well-formed URLs and CURIEs, but not in file system paths.
created_at: 2025-03-31T21:46:43Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
-        # File with URL schemes are not modified
-        return imported_sch
-    
-    if WINDOWS:
-        path = PurePath(os.path.normpath(PurePath(source_sch).parent / imported_sch)).as_posix()
-    else:
-        path = os.path.normpath(str(Path(source_sch).parent / imported_sch))
-
-    if imported_sch.startswith(".") and not path.startswith("."):
-        # Above condition handles cases where both source schema and imported schema are relative paths: these should remain relative
-        return f"./{path}"
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: Why?

It looks like this was put in to fudge test results -- that is not a good reason to keep it in.
created_at: 2025-03-31T21:48:36Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: Also -- `imported_sch` starting with `file://` and `file:///` do not resolve and `http` / `https` / etc. will be rejected unless there's a mapping in the `prefixes` section of the schema.
created_at: 2025-03-31T22:03:53Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
-        # File with URL schemes are not modified
-        return imported_sch
-    
-    if WINDOWS:
-        path = PurePath(os.path.normpath(PurePath(source_sch).parent / imported_sch)).as_posix()
-    else:
-        path = os.path.normpath(str(Path(source_sch).parent / imported_sch))
-
-    if imported_sch.startswith(".") and not path.startswith("."):
-        # Above condition handles cases where both source schema and imported schema are relative paths: these should remain relative
-        return f"./{path}"
-
-    return path
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: summary of logic in this function:

- if the `imported_sch` path is absolute or is URI / CURIE-like, leave as-is
- normalise the `imported_sch` path, assuming that `source_sch` and `imported_sch` are siblings
- add `./` to the normalised `imported_sch` path if it originally started with `./` (not really necessary)

==> this can be refactored and simplified
created_at: 2025-03-31T22:04:57Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: plz add failing tests illustrating these cases and then fix :)
created_at: 2025-03-31T22:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
-        # File with URL schemes are not modified
-        return imported_sch
-    
-    if WINDOWS:
-        path = PurePath(os.path.normpath(PurePath(source_sch).parent / imported_sch)).as_posix()
-    else:
-        path = os.path.normpath(str(Path(source_sch).parent / imported_sch))
-
-    if imported_sch.startswith(".") and not path.startswith("."):
-        # Above condition handles cases where both source schema and imported schema are relative paths: these should remain relative
-        return f"./{path}"
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: see https://github.com/linkml/linkml-runtime/pull/368#discussion_r1957565053

they are functionally equivalent. i mildly prefer having explicit relative path annotations, or some way of telling that these are relative paths/paths at all, but yes string conventions are a weak way of doing that compared to proper typing
created_at: 2025-03-31T22:58:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: It isn't clear to me what the desired behaviour is here, which is the main reason why I didn't do anything. I thought that the point of the `prefixes` section was so that external resources could be referred to without using URLs - specify the URL there and the importer Does The Right Thing automatically. `file://` (or `file:///`) don't work at all due to bugs in the underlying `hbreader` package. I don't know whether this is a problem throughout the codebase, but I think I would consider ditching the package and using something more standard if so.

The docs/code are a bit ambiguous as the code enforces prefixes but the docs (or at least the [`imports` slot range, `uriorcurie`](https://linkml.io/linkml-model/latest/docs/imports/)) suggest URLs are OK. The [documentation page on imports](https://linkml.io/linkml/schemas/imports.html#imports) only talks about CURIEs or local paths, so I don't think that URIs should be allowed.


created_at: 2025-04-01T14:07:20Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
-        # File with URL schemes are not modified
-        return imported_sch
-    
-    if WINDOWS:
-        path = PurePath(os.path.normpath(PurePath(source_sch).parent / imported_sch)).as_posix()
-    else:
-        path = os.path.normpath(str(Path(source_sch).parent / imported_sch))
-
-    if imported_sch.startswith(".") and not path.startswith("."):
-        # Above condition handles cases where both source schema and imported schema are relative paths: these should remain relative
-        return f"./{path}"
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: I agree with your (linked) comment about wanting the explicit paths -- when I first looked at this code, I was wondering about the possibility of using `Path` objects but that turned out to be a non-trivial exercise to implement. More than wanting explicit paths, I would prefer everything to be treated uniformly: either keep everything as it is in the source, or convert everything to absolute paths. The previous approach altered some paths but not others, which didn't sit well with a pedant like me!
created_at: 2025-04-01T14:11:56Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: ETA: I've just realised it's a code comment that's leading me astray:
```
                    # origin schema. Imports can be a URI or Curie, and imports from the same
                    # directory don't require a ./, so if the current (sn) import is a relative
```
 The docs are pretty clear that the field contents should be local paths or CURIEs.
created_at: 2025-04-01T16:40:40Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: I would assume the metamodel spec of xsd:anyURI is correct over the docs, since the metamodel is a formal specification? what makes you say the docs are clear(er than the metamodel?)
created_at: 2025-04-02T04:25:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: See https://linkml.io/linkml/schemas/imports.html#imports:

> LinkML encourages modular schema development. You can split you schema into modules, and even reuse other peoples modules on the web
>
> A schema can have a list of [imports](https://w3id.org/linkml/imports) associated with it. **These are specified as CURIEs or local imports.**

(emphasis added)

created_at: 2025-04-02T12:28:59Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: The section below describes how to import external schemas (e.g. from the web) by assigning them a prefix, giving the example of the `linkml:types` schema.
created_at: 2025-04-02T12:31:54Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: right, but in general i would think that a formal specification would be the source of truth, and docs are just a description of it that can be imprecise. for example it's not really clear what a "local import" is. Since i'm a language user i interpret that as meaning "local path," but the spec has no such ambiguity. the same thing that allows the prefix notation is what would allow URIs - the range is `URIOrCurie` and those curie prefixes expand into URIs. 

edit: asked in chat since i'm not a core maintainer or anything and don't want to lead us off base here
created_at: 2025-04-03T06:39:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: Thanks for asking in slack.

I should emphasise that the implementation assumes that anything curie-like is treated as a prefixed entity with an entry for that prefix in the `prefixes` section -- i.e. if you have `http://example.com/schema` and `ftp://my-fave-schemas.com/path/to/file` in your `imports`, it expects there to be entries for `http` and `ftp` in the `prefixes` section, and throws an error if there are not.
created_at: 2025-04-03T13:13:01Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: Well that's certainly a bug
created_at: 2025-04-16T20:08:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: I think we need a core LinkML person to chime in here.
created_at: 2025-04-18T17:40:09Z
author_association: COLLABORATOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: sneakers-the-rat
body: I personally would say that is out of scope for this PR, i don't think you need to fix all of URI resolution rn. i think the shortcomings of the hbreader are known, and also think that the only way could feasibly resolve the problem is make a plugin architecture that allowed people to handle all the various schemes that one might want to use to resolve a URI.
created_at: 2025-04-19T07:50:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,25 +108,6 @@ def is_absolute_path(path: str) -> bool:
     drive, tail = os.path.splitdrive(norm_path)
     return bool(drive and tail)
 
-def _resolve_import(source_sch: str, imported_sch: str) -> str:
-    if os.path.isabs(imported_sch):
-        # Absolute import paths are not modified
-        return imported_sch
-    if urlparse(imported_sch).scheme:
path: linkml_runtime/utils/schemaview.py
user: login: ialarmedalien
body: Yeah, it did seem like a bit too much for this PR -- I wasn't sure from your comment whether or not you thought it needed fixing now or whether it was something that could be added to the TODO list.
created_at: 2025-04-21T14:47:29Z
author_association: COLLABORATOR

PR comments:
user: login: ialarmedalien
created_at: 2025-01-31T20:23:14Z
body: @sierra-moxon any chance of a review? should be pretty simple.

author_association: COLLABORATOR
user: login: dalito
created_at: 2025-02-10T12:14:33Z
body: It seems that it [breaks Windows](https://github.com/linkml/linkml-runtime/actions/runs/13080389360/job/36502402693?pr=350#step:6:74) but you don't see it because #360 is not yet merged.

Also running your PR-branch locally on Win10 gives an error:

<details>

```shell
(.venv) λ pytest -k schemaview
======================================= test session starts =======================================
platform win32 -- Python 3.10.11, pytest-8.0.1, pluggy-1.4.0
rootdir: C:\Users\dlinke\MyProg_local\gh-dalito\linkml-runtime
collected 663 items / 630 deselected / 33 selected

tests\test_issues\test_linkml_runtime_issue_1317.py .s.                                      [  9%]
tests\test_utils\test_schemaview.py ............F.s...............                           [100%]

============================================ FAILURES =============================================
______________________________________ test_imports_relative ______________________________________

    def test_imports_relative():
        """Relative imports from relative imports should evaluate relative to the *importing* schema."""
        sv = SchemaView(SCHEMA_RELATIVE_IMPORT_TREE)
>       closure = sv.imports_closure(imports=True)

tests\test_utils\test_schemaview.py:524:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
linkml_runtime\utils\schemaview.py:281: in imports_closure
    imported_schema = self.load_import(sn)
linkml_runtime\utils\schemaview.py:227: in load_import
    schema = load_schema_wrap(sname + '.yaml', base_dir=base_dir)
linkml_runtime\utils\schemaview.py:84: in load_schema_wrap
    schema = yaml_loader.load(path, target_class=SchemaDefinition, **kwargs)
linkml_runtime\loaders\loader_root.py:76: in load
    results = self.load_any(*args, **kwargs)
linkml_runtime\loaders\yaml_loader.py:41: in load_any
    data_as_dict = self.load_as_dict(source, base_dir=base_dir, metadata=metadata)
linkml_runtime\loaders\yaml_loader.py:27: in load_as_dict
    data = self._read_source(source, base_dir=base_dir, metadata=metadata, accept_header="text/yaml, application/yaml;q=0.9")
linkml_runtime\loaders\loader_root.py:167: in _read_source
    data = hbread(source, metadata, base_dir, accept_header)
.venv\lib\site-packages\hbreader\__init__.py:260: in hbread
    with hbopen(source, open_info, base_path, accept_header, is_actual_data, read_codec) as f:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

source = './four.yaml'
open_info = FileInfo(source_file=None, source_file_date=None, source_file_size=None, base_path='C:\\Users\\dlinke\\MyProg_local\\gh-dalito\\linkml-runtime\\tests\\test_utils\\input\\imports_relative\\L0_0\\L1_0_0')
base_path = 'C:\\Users\\dlinke\\MyProg_local\\gh-dalito\\linkml-runtime\\tests\\test_utils\\input\\imports_relative\\L0_0\\L1_0_0'
accept_header = 'text/yaml, application/yaml;q=0.9'
is_actual_data = <function default_str_tester at 0x00000199BCE57760>, read_codec = None

    def hbopen(source: HB_TYPE,
               open_info: Optional[FileInfo] = None,
               base_path: Optional[str] = None,
               accept_header: Optional[str] = None,
               is_actual_data: Optional[Callable[[str], bool]] = default_str_tester,
               read_codec: str = None) -> TextIO:
        """
        Return an open IO representation of source
        :param source: anything that can be construed to be a string, a URL, a file name or an open file handle
        :param open_info: what we learned about source in the process of converting it
        :param base_path: Base to use if source is a relative URL or file name
        :param accept_header: Accept header to use if it turns out to be a URL
        :param is_actual_data: Function to differentiate plain text from URL or file name
        :param read_codec: Name of codec to use if bytes being read. (URL only)
        :return: TextIO representation of open file
        """
        source_type = detect_type(source, base_path, is_actual_data)
        if source_type is HBType.STRINGABLE:
            source_as_string = str(source)
        elif source_type is HBType.DECODABLE:
            # TODO: Tie this into the autodetect machinery
            source_as_string = source.decode()
        elif source_type is HBType.STRING:
            source_as_string = source
        else:
            source_as_string = None

        # source is a URL or a file name
        if source_as_string:
            if open_info:
                assert open_info.source_file is None, "source_file parameter not allowed if data is a file or URL"
                assert open_info.source_file_date is None, "source_file_date parameter not allowed if data is a file or URL"
                open_info.source_file_size = len(source_as_string)
            return StringIO(source_as_string)

        if source_type is HBType.URL:
            url = source if '://' in source else urljoin(base_path + ('' if base_path.endswith('/') else '/'),
                                                         source, allow_fragments=True)
            req = Request(quote(url, '/:'))
            if accept_header:
                req.add_header("Accept", accept_header)
            try:
                response = urlopen(req, context=ssl._create_unverified_context())
            except HTTPError as e:
                # This is here because the message out of urllib doesn't include the file name
                e.msg = f"{e.filename}"
                raise e
            if open_info:
                open_info.source_file = response.url
                open_info.source_file_date = response.headers['Last-Modified']
                if not open_info.source_file_date:
                    open_info.source_file_date = response.headers['Date']
                open_info.source_file_size = response.headers['Content-Length']
                parts = urlsplit(response.url)
                open_info.base_path = urlunsplit((parts.scheme, parts.netloc, os.path.dirname(parts.path),
                                                 parts.query, None))
            # Auto convert byte stream to
            return _to_textio(response, response.fp.mode, read_codec)

        if source_type is HBType.FILENAME:
            if not base_path:
                fname = os.path.abspath(source)
            else:
                fname = source if os.path.isabs(source) else os.path.abspath(os.path.join(base_path, source))
>           f = open(fname, encoding=read_codec if read_codec else 'utf-8')
E           FileNotFoundError: [Errno 2] No such file or directory: 'C:\\Users\\dlinke\\MyProg_local\\gh-dalito\\linkml-runtime\\tests\\test_utils\\input\\imports_relative\\L0_0\\L1_0_0\\four.yaml'

.venv\lib\site-packages\hbreader\__init__.py:206: FileNotFoundError

===================================== short test summary info =====================================
FAILED tests/test_utils/test_schemaview.py::test_imports_relative - FileNotFoundError: [Errno 2] No such file or directory: 'C:\\Users\\dlinke\\MyProg_local\\gh-da...
============== 1 failed, 30 passed, 2 skipped, 630 deselected, 37 warnings in 9.81s ===============
```
</details>
author_association: MEMBER
user: login: ialarmedalien
created_at: 2025-02-10T22:16:56Z
body: @dalito is #360 going to be merged? Not sure what the order of precedence of these PRs is.
author_association: COLLABORATOR
user: login: dalito
created_at: 2025-02-10T23:29:57Z
body: I have asked on dev-channel if #360 can be merged.
author_association: MEMBER
user: login: cmungall
created_at: 2025-02-13T03:08:11Z
body: #360 now merged! I think it's best if one of you two handle the resulting conflict...
author_association: MEMBER
user: login: ialarmedalien
created_at: 2025-02-18T16:52:44Z
body: @dalito it looks like the GA tests passed; would you be able to see if they pass locally on Win10? I don't have a windows box available.
author_association: COLLABORATOR
user: login: dalito
created_at: 2025-02-18T17:43:59Z
body: @ialarmedalien - The tests pass now on Win10. Great!
author_association: MEMBER
user: login: ialarmedalien
created_at: 2025-02-21T16:25:42Z
body: I don't have merge privs so can someone who does press the magic button (or make a mental note to press the magic button if there are other PRs that depend on this)?

@dalito @sneakers-the-rat 
author_association: COLLABORATOR
user: login: sierra-moxon
created_at: 2025-02-21T17:23:22Z
body: Before we merge, lets make sure we address the upstream failing tests (as an out here, we could post a note to why they are failing in this PR and we can get someone to fix separately).  (been burned so many times by merging without the green checks that I am def hesitant to do that - esp on Fridays! :P ) 
author_association: MEMBER
user: login: sneakers-the-rat
created_at: 2025-02-21T23:14:56Z
body: the failures are truly mysterious to me, it's swapping out `owl:DataRange` for `rdfs:Datatype` ... and i can't fathom how this PR would have affected that (except for on windows in python 3.12). can anyone with privs trigger a re-run of the failed actions to see if it was a weird fluke?
author_association: CONTRIBUTOR
user: login: dalito
created_at: 2025-02-21T23:35:38Z
body: I just triggered a re-run of failed jobs. Let's see.
author_association: MEMBER
user: login: dalito
created_at: 2025-02-22T00:10:51Z
body: The mysterious errors are gone. The remaining errors are probably related to the changes made in https://github.com/linkml/linkml-runtime/pull/357

Can it be that this PR just needs to be rebased on current main? The error happens in parts of code that were fixed in #357.
author_association: MEMBER
user: login: ialarmedalien
created_at: 2025-02-24T15:30:29Z
body: The issue comes from the [linkml/linkml](https://github.com/linkml/linkml) repo, and more specifically in the compliance tests, where there are tests failing on py 3.8, 3.9, and 3.10. py3.11 added support for parsing more datetime formats to [datetime.fromisoformat](https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat) and the tests in question assume that this support is in place.
author_association: COLLABORATOR
user: login: sneakers-the-rat
created_at: 2025-03-31T19:54:07Z
body: sorry, approving the idea of adding more tests for this, once they pass
author_association: CONTRIBUTOR
user: login: ialarmedalien
created_at: 2025-04-19T16:46:20Z
body: Tests are failing due to upstream package resolution conflicts.
author_association: COLLABORATOR
user: login: ialarmedalien
created_at: 2025-04-26T00:36:51Z
body: Please can someone merge this? @amc-corey-cox ?
author_association: COLLABORATOR