Repository: vyperlang/vyper

Issue:
Relative imports are broken beyond one parent level
The following code produces paths like `..../something.vy` instead of `../../../something.vy`:

https://github.com/ethereum/vyper/blob/c296b2d7532d913103aad494b749f8179a3acddc/vyper/signatures/interface.py#L225-L226

A path like `..../something.vy` is actually valid, so this can end up importing code from a surprising place. The expected behavior is [undocumented](https://vyper.readthedocs.io/en/latest/structure-of-a-contract.html#imports-via-import), so perhaps the intention was just to disallow more than two leading dots.


Discussion:

Resolving patch:
diff --git a/vyper/cli/vyper_compile.py b/vyper/cli/vyper_compile.py
--- a/vyper/cli/vyper_compile.py
+++ b/vyper/cli/vyper_compile.py
@@ -173,7 +173,10 @@ def get_interface_codes(root_path: Path, contract_sources: ContractCodes) -> Dic
             base_paths = [parent_path]
             if not interface_path.startswith('.') and root_path.joinpath(file_path).exists():
                 base_paths.append(root_path)
-            elif interface_path.startswith('../') and parent_path == root_path:
+            elif (
+                interface_path.startswith('../') and
+                len(Path(file_path).parent.parts) < Path(interface_path).parts.count('..')
+            ):
                 raise FileNotFoundError(
                     f"{file_path} - Cannot perform relative import outside of base folder"
                 )
diff --git a/vyper/cli/vyper_json.py b/vyper/cli/vyper_json.py
--- a/vyper/cli/vyper_json.py
+++ b/vyper/cli/vyper_json.py
@@ -264,7 +264,10 @@ def get_interface_codes(root_path: Union[Path, None],
     code = contract_sources[contract_path]
     interface_codes = extract_file_interface_imports(code)
     for interface_name, interface_path in interface_codes.items():
-        keys = [Path(contract_path).parent.joinpath(interface_path).as_posix(), interface_path]
+        path = Path(contract_path).parent.joinpath(interface_path).as_posix()
+        keys = [_standardize_path(path)]
+        if not interface_path.startswith('.'):
+            keys.append(interface_path)
 
         key = next((i for i in keys if i in interface_sources), None)
         if key:
@@ -286,7 +289,10 @@ def get_interface_codes(root_path: Union[Path, None],
         base_paths = [parent_path]
         if not interface_path.startswith('.'):
             base_paths.append(root_path)
-        elif interface_path.startswith('../') and parent_path == root_path:
+        elif (
+            interface_path.startswith('../') and
+            len(Path(contract_path).parent.parts) < Path(interface_path).parts.count('..')
+        ):
             raise FileNotFoundError(
                 f"{contract_path} - Cannot perform relative import outside of base folder"
             )
diff --git a/vyper/signatures/interface.py b/vyper/signatures/interface.py
--- a/vyper/signatures/interface.py
+++ b/vyper/signatures/interface.py
@@ -223,17 +223,21 @@ def extract_file_interface_imports(code: SourceCode) -> InterfaceImports:
             module = item.module or ""  # type: ignore
             if not level and module == 'vyper.interfaces':
                 continue
-            if level:
-                base_path = f"{'.'*level}/{module.replace('.','/')}"
-            else:
-                base_path = module.replace('.', '/')
+
+            base_path = ""
+            if level > 1:
+                base_path = "../" * (level-1)
+            elif level == 1:
+                base_path = "./"
+            base_path = f"{base_path}{module.replace('.','/')}/"
+
             for a_name in item.names:  # type: ignore
                 if a_name.name in imports_dict:
                     raise StructureException(
                         f'Interface with name {a_name.name} already exists',
                         item,
                     )
-                imports_dict[a_name.name] = f"{base_path}/{a_name.name}"
+                imports_dict[a_name.name] = f"{base_path}{a_name.name}"
 
     return imports_dict
 


Test patch:
diff --git a/tests/cli/vyper_compile/test_import_paths.py b/tests/cli/vyper_compile/test_import_paths.py
--- a/tests/cli/vyper_compile/test_import_paths.py
+++ b/tests/cli/vyper_compile/test_import_paths.py
@@ -96,10 +96,11 @@ def test_import_other_folder(import_stmt, tmp_path):
 
 def test_import_parent_folder(tmp_path, assert_compile_failed):
     tmp_path.joinpath('contracts').mkdir()
+    tmp_path.joinpath('contracts/baz').mkdir()
 
-    foo_path = tmp_path.joinpath('contracts/foo.vy')
+    foo_path = tmp_path.joinpath('contracts/baz/foo.vy')
     with foo_path.open('w') as fp:
-        fp.write(FOO_CODE.format("from .. import Bar"))
+        fp.write(FOO_CODE.format("from ... import Bar"))
 
     with tmp_path.joinpath('Bar.vy').open('w') as fp:
         fp.write(BAR_CODE)
diff --git a/tests/cli/vyper_json/test_compile_from_input_dict.py b/tests/cli/vyper_json/test_compile_from_input_dict.py
--- a/tests/cli/vyper_json/test_compile_from_input_dict.py
+++ b/tests/cli/vyper_json/test_compile_from_input_dict.py
@@ -134,6 +134,14 @@ def test_outputs():
     assert sorted(result['contracts/bar.vy'].keys()) == sorted(TRANSLATE_MAP.values())
 
 
+def test_relative_import_paths():
+    input_json = deepcopy(INPUT_JSON)
+    input_json['sources']['contracts/potato/baz/baz.vy'] = {'content': """from ... import foo"""}
+    input_json['sources']['contracts/potato/baz/potato.vy'] = {'content': """from . import baz"""}
+    input_json['sources']['contracts/potato/footato.vy'] = {'content': """from baz import baz"""}
+    compile_from_input_dict(input_json)
+
+
 def test_evm_version():
     # should compile differently because of SELFBALANCE
     input_json = deepcopy(INPUT_JSON)