Skip to content

Commit 3361a90

Browse files
Added support for inheritance in importing repository files.
Signed-off-by: Leander Stephen D'Souza <[email protected]>
1 parent e700793 commit 3361a90

File tree

7 files changed

+217
-9
lines changed

7 files changed

+217
-9
lines changed

README.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@ Only for this command vcs2l supports the pseudo clients ``tar`` and ``zip`` whic
105105
For those two types the ``version`` key is optional.
106106
If specified only entries from the archive which are in the subfolder specified by the version value are being extracted.
107107

108+
Import with extends functionality
109+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
110+
111+
The ``vcs import`` command supports an ``extends`` key at the top level of the YAML file. The value of that key is a path or URL to another YAML file which is imported first.
112+
This parent file can itself also contain the key to chain multiple files. The child is given precedence over the parent in case of duplicate repository entries.
113+
In order to avoid infinite loops in case of circular imports the tool detects already imported files and raises an error if such a file is encountered again.
114+
115+
.. code-block:: yaml
116+
117+
# parent_repos.yaml
118+
repositories:
119+
vcs2l:
120+
type: git
121+
url: https://github.com/ros-infrastructure/vcs2l.git
122+
version: main
123+
124+
# child_repos.yaml
125+
extends: parent_repos.yaml
126+
repositories:
127+
vcs2l:
128+
type: git
129+
url: https://github.com/ros-infrastructure/vcs2l.git
130+
version: 1.1.3
131+
108132

109133
Validate repositories file
110134
~~~~~~~~~~~~~~~~~~~~~~~~~~

test/import_extends.txt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
......
2+
=== ./immutable/hash (git) ===
3+
Cloning into '.'...
4+
Note: switching to 'e700793cb2b8d25ce83a611561bd167293fd66eb'.
5+
6+
You are in 'detached HEAD' state. You can look around, make experimental
7+
changes and commit them, and you can discard any commits you make in this
8+
state without impacting any branches by switching back to a branch.
9+
10+
If you want to create a new branch to retain commits you create, you may
11+
do so (now or later) by using -c with the switch command. Example:
12+
13+
git switch -c <new-branch-name>
14+
15+
Or undo this operation with:
16+
17+
git switch -
18+
19+
Turn off this advice by setting config variable advice.detachedHead to false
20+
21+
HEAD is now at e700793 1.1.3
22+
=== ./immutable/hash_tar (tar) ===
23+
Downloaded tarball from 'https://github.com/ros-infrastructure/vcs2l/archive/377d5b3d03c212f015cc832fdb368f4534d0d583.tar.gz' and unpacked it
24+
=== ./immutable/hash_zip (zip) ===
25+
Downloaded zipfile from 'https://github.com/ros-infrastructure/vcs2l/archive/377d5b3d03c212f015cc832fdb368f4534d0d583.zip' and unpacked it
26+
=== ./immutable/tag (git) ===
27+
Cloning into '.'...
28+
Note: switching to 'tags/1.1.3'.
29+
30+
You are in 'detached HEAD' state. You can look around, make experimental
31+
changes and commit them, and you can discard any commits you make in this
32+
state without impacting any branches by switching back to a branch.
33+
34+
If you want to create a new branch to retain commits you create, you may
35+
do so (now or later) by using -c with the switch command. Example:
36+
37+
git switch -c <new-branch-name>
38+
39+
Or undo this operation with:
40+
41+
git switch -
42+
43+
Turn off this advice by setting config variable advice.detachedHead to false
44+
45+
HEAD is now at e700793 1.1.3
46+
=== ./vcs2l (git) ===
47+
Cloning into '.'...
48+
Note: switching to '1.1.3'.
49+
50+
You are in 'detached HEAD' state. You can look around, make experimental
51+
changes and commit them, and you can discard any commits you make in this
52+
state without impacting any branches by switching back to a branch.
53+
54+
If you want to create a new branch to retain commits you create, you may
55+
do so (now or later) by using -c with the switch command. Example:
56+
57+
git switch -c <new-branch-name>
58+
59+
Or undo this operation with:
60+
61+
git switch -
62+
63+
Turn off this advice by setting config variable advice.detachedHead to false
64+
65+
HEAD is now at e700793 1.1.3
66+
=== ./without_version (git) ===
67+
Cloning into '.'...

test/list_extends.repos

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Check for hash, tag, and branch imports with extends functionality
2+
---
3+
extends: list.repos
4+
repositories:
5+
immutable/hash:
6+
type: git
7+
url: https://github.com/ros-infrastructure/vcs2l.git
8+
version: e700793cb2b8d25ce83a611561bd167293fd66eb # 1.1.3
9+
immutable/tag:
10+
type: git
11+
url: https://github.com/ros-infrastructure/vcs2l.git
12+
version: tags/1.1.3
13+
vcs2l:
14+
type: git
15+
url: https://github.com/ros-infrastructure/vcs2l.git
16+
version: 1.1.3

test/list_extends_loop_child.repos

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Child file that creates an extends a circular import on its parent.
2+
---
3+
extends: list_extends_loop_parent.repos
4+
repositories:
5+
vcs2l:
6+
type: git
7+
url: https://github.com/ros-infrastructure/vcs2l.git
8+
version: 1.1.3
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Parent file that creates an extends a circular import on its child.
2+
---
3+
extends: list_extends_loop_child.repos
4+
repositories:
5+
vcs2l:
6+
type: git
7+
url: https://github.com/ros-infrastructure/vcs2l.git
8+
version: main

test/test_commands.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
REPOS_FILE = os.path.join(os.path.dirname(__file__), 'list.repos')
1515
REPOS_FILE_URL = file_uri_scheme + REPOS_FILE
1616
REPOS2_FILE = os.path.join(os.path.dirname(__file__), 'list2.repos')
17+
REPOS_EXTENDS_FILE = os.path.join(os.path.dirname(__file__), 'list_extends.repos')
18+
REPOS_EXTENDS_LOOP_FILE = os.path.join(
19+
os.path.dirname(__file__), 'list_extends_loop_child.repos'
20+
)
1721
BAD_REPOS_FILE = os.path.join(os.path.dirname(__file__), 'bad.repos')
1822
TEST_WORKSPACE = os.path.join(
1923
os.path.dirname(os.path.dirname(__file__)), 'test_workspace'
@@ -312,23 +316,43 @@ def test_import_shallow(self):
312316
finally:
313317
rmtree(workdir)
314318

315-
def test_import_url(self):
316-
workdir = os.path.join(TEST_WORKSPACE, 'import-url')
319+
def import_common(self, import_file, repos_file):
320+
"""Common test function for import operations
321+
322+
Args:
323+
import_file: Assertion expected output file name (without .txt)
324+
repos_file: path to the .repos file to use
325+
"""
326+
workdir = os.path.join(TEST_WORKSPACE, import_file)
317327
os.makedirs(workdir)
318328
try:
319329
output = run_command(
320-
'import', ['--input', REPOS_FILE_URL, '.'], subfolder='import-url'
330+
'import', ['--input', repos_file, '.'], subfolder=import_file
321331
)
322332
# the actual output contains absolute paths
323333
output = output.replace(
324334
b'repository in ' + workdir.encode() + b'/', b'repository in ./'
325335
)
326-
expected = get_expected_output('import')
336+
expected = get_expected_output(import_file)
327337
# newer git versions don't append ... after the commit hash
328338
assert output == expected or output == expected.replace(b'... ', b' ')
329339
finally:
330340
rmtree(workdir)
331341

342+
def test_import_file_url(self):
343+
"""Test import from file URL."""
344+
self.import_common('import', REPOS_FILE_URL)
345+
346+
def test_import_extends(self):
347+
"""Test import with extends functionality."""
348+
self.import_common('import_extends', REPOS_EXTENDS_FILE)
349+
350+
def test_import_extends_loop(self):
351+
"""Test import with extends functionality that creates a circular import."""
352+
with self.assertRaises(subprocess.CalledProcessError) as e:
353+
run_command('import', ['--input', REPOS_EXTENDS_LOOP_FILE, '.'])
354+
self.assertIn(b'Circular import detected:', e.exception.output)
355+
332356
def test_validate(self):
333357
output = run_command('validate', ['--input', REPOS_FILE])
334358
expected = get_expected_output('validate')

vcs2l/commands/import_.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,23 +86,84 @@ def file_or_url_type(value):
8686
return request.Request(value, headers={'User-Agent': 'vcs2l/' + vcs2l_version})
8787

8888

89-
def get_repositories(yaml_file):
89+
def load_yaml_file(yaml_file):
90+
"""Load and parse a YAML file."""
9091
try:
91-
root = yaml.safe_load(yaml_file)
92+
return yaml.safe_load(yaml_file)
9293
except yaml.YAMLError as e:
93-
raise RuntimeError('Input data is not valid yaml format: %s' % e)
94+
raise RuntimeError('Input data is not valid yaml format: %s' % e) from e
9495

96+
97+
def get_repositories_from_root(root):
98+
"""Extract repositories from the parsed YAML root object."""
9599
try:
96100
repositories = root['repositories']
97101
return get_repos_in_vcs2l_format(repositories)
98102
except KeyError as e:
99-
raise RuntimeError('Input data is not valid format: %s' % e)
103+
raise RuntimeError('Input data is not valid format: %s' % e) from e
100104
except TypeError as e:
101105
# try rosinstall file format
102106
try:
103107
return get_repos_in_rosinstall_format(root)
104108
except Exception:
105-
raise RuntimeError('Input data is not valid format: %s' % e)
109+
raise RuntimeError('Input data is not valid format: %s' % e) from e
110+
111+
112+
def get_repositories(yaml_file, visited_files=None):
113+
"""Recursively get repositories from a YAML file, handling inheritance."""
114+
if visited_files is None:
115+
visited_files = set()
116+
117+
# Get absolute path to handle relative paths consistently
118+
current_file_path = os.path.abspath(yaml_file.name)
119+
120+
if current_file_path in visited_files:
121+
raise RuntimeError('Circular import detected: %s' % current_file_path)
122+
123+
visited_files.add(current_file_path)
124+
125+
try:
126+
root = load_yaml_file(yaml_file)
127+
128+
combined_repos = {}
129+
130+
if 'extends' in root:
131+
parent_file = root['extends']
132+
133+
# If absolute path is not valid, try relative to current file
134+
if not os.path.isabs(parent_file):
135+
current_dir = os.path.dirname(current_file_path)
136+
parent_file = os.path.join(current_dir, parent_file)
137+
138+
if not os.path.exists(parent_file):
139+
raise RuntimeError('Parent file not found: %s' % parent_file)
140+
141+
try:
142+
# Recursively get repositories from parent file
143+
with open(parent_file, 'r', encoding='utf-8') as parent_f:
144+
parent_repos = get_repositories(parent_f, visited_files.copy())
145+
combined_repos.update(parent_repos)
146+
except Exception as e:
147+
if str(e).startswith('Circular import detected:'):
148+
raise
149+
raise RuntimeError(
150+
'Error reading parent file %s: \n%s' % (parent_file, str(e))
151+
) from e
152+
153+
current_repos = get_repositories_from_root(root)
154+
combined_repos.update(current_repos)
155+
156+
return combined_repos
157+
158+
except FileNotFoundError as e:
159+
raise RuntimeError('File not found: %s' % yaml_file) from e
160+
except yaml.YAMLError as e:
161+
raise RuntimeError(
162+
'Error parsing YAML file %s: %s' % (yaml_file, str(e))
163+
) from e
164+
finally:
165+
# Remove current file from visited set when leaving this call
166+
visited_files.discard(current_file_path)
106167

107168

108169
def get_repos_in_vcs2l_format(repositories):

0 commit comments

Comments
 (0)