Skip to content

Commit 2d3c169

Browse files
committed
Add 'uses' support
Signed-off-by: Matthew Ballance <matt.ballance@gmail.com>
1 parent e53c56b commit 2d3c169

14 files changed

+852
-75
lines changed

docs/source/dependency_sets.rst

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,91 @@ While you can use any names, IVPM recognizes these standard names:
9191
Development dependencies. Includes everything from ``default`` plus
9292
development tools. This is no longer special - any name can be used.
9393

94+
Dep-Set Inheritance with ``uses``
95+
----------------------------------
96+
97+
The ``uses`` field lets a dep-set inherit all packages from another dep-set
98+
defined in the same ``ivpm.yaml``. This avoids duplicating shared entries
99+
across multiple sets.
100+
101+
**Merge rules**
102+
103+
- Every package from the *base* dep-set is copied into the *child* dep-set.
104+
- If the same package name appears in both, the **child's definition wins**.
105+
- Inheritance is resolved at parse time, so there is no runtime overhead.
106+
- Chains of any depth are supported (``a`` uses ``b`` uses ``c`` …).
107+
- Cycles are detected and raise an error.
108+
- Definition order does not matter; the base may be defined after the child.
109+
110+
**Basic example** — ``default-dev`` extends ``default``:
111+
112+
.. code-block:: yaml
113+
114+
package:
115+
name: my-project
116+
default-dep-set: default-dev
117+
118+
dep-sets:
119+
- name: default
120+
deps:
121+
- name: runtime-lib
122+
url: https://github.com/org/runtime-lib.git
123+
124+
- name: default-dev
125+
uses: default # inherit runtime-lib from above
126+
deps:
127+
- name: pytest
128+
src: pypi
129+
- name: test-framework
130+
url: https://github.com/org/test-framework.git
131+
132+
Running ``ivpm update -d default-dev`` installs ``runtime-lib``, ``pytest``,
133+
and ``test-framework``. Running ``ivpm update -d default`` installs only
134+
``runtime-lib``.
135+
136+
**Overriding an inherited package** — pin a different version in the child:
137+
138+
.. code-block:: yaml
139+
140+
dep-sets:
141+
- name: default
142+
deps:
143+
- name: mylib
144+
url: https://github.com/org/mylib.git
145+
branch: v1.0
146+
147+
- name: default-dev
148+
uses: default
149+
deps:
150+
- name: mylib
151+
url: https://github.com/org/mylib.git
152+
branch: dev # overrides the v1.0 branch from 'default'
153+
- name: pytest
154+
src: pypi
155+
156+
**Multi-level inheritance**:
157+
158+
.. code-block:: yaml
159+
160+
dep-sets:
161+
- name: base
162+
deps:
163+
- name: core-lib
164+
src: pypi
165+
166+
- name: dev
167+
uses: base
168+
deps:
169+
- name: pytest
170+
src: pypi
171+
172+
- name: ci
173+
uses: dev
174+
deps:
175+
- name: coverage
176+
src: pypi
177+
# inherits core-lib (from base via dev) and pytest (from dev)
178+
94179
Using Dependency Sets
95180
=====================
96181

src/ivpm/handlers/package_handler_python.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from ..project_ops_info import ProjectUpdateInfo, ProjectBuildInfo
3131
from ..utils import note, fatal, get_venv_python
3232
from ..pkg_content_type import PythonTypeData
33+
from ..package import get_type_data
3334

3435
from ..package import Package
3536
from .package_handler import PackageHandler
@@ -50,8 +51,8 @@ def process_pkg(self, pkg: Package):
5051
if pkg.src_type == "pypi":
5152
self.pypi_pkg_s.add(pkg.name)
5253
add = True
53-
elif isinstance(pkg.type_data, PythonTypeData):
54-
# Explicit type: python via 'with:' mechanism
54+
elif get_type_data(pkg, PythonTypeData) is not None:
55+
# Explicit type: python
5556
self.src_pkg_s.add(pkg.name)
5657
add = True
5758
elif pkg.pkg_type is not None and pkg.pkg_type == PackageHandlerPython.name:
@@ -416,13 +417,14 @@ def _write_requirements_txt(self,
416417
# Source package (git, dir, http, etc.)
417418
# Determine editability: type_data takes priority, then default True
418419
editable = True
419-
if isinstance(pkg.type_data, PythonTypeData) and pkg.type_data.editable is not None:
420-
editable = pkg.type_data.editable
420+
td = get_type_data(pkg, PythonTypeData)
421+
if td is not None and td.editable is not None:
422+
editable = td.editable
421423

422-
# Extras from type_data (for source packages declared with type: python + with: extras:)
424+
# Extras from type_data
423425
extras = None
424-
if isinstance(pkg.type_data, PythonTypeData):
425-
extras = pkg.type_data.extras
426+
if td is not None:
427+
extras = td.extras
426428
extras_str = "[%s]" % ",".join(extras) if extras else ""
427429

428430
pkg_path = "%s/%s" % (packages_dir.replace("\\","/"), pkg.name)
@@ -433,8 +435,9 @@ def _write_requirements_txt(self,
433435
else:
434436
# PyPi package — build PEP 508 specifier: name[extras]version
435437
# Extras: prefer type_data if present, fall back to pkg.extras (PackagePyPi)
436-
if isinstance(pkg.type_data, PythonTypeData) and pkg.type_data.extras is not None:
437-
extras = pkg.type_data.extras
438+
td = get_type_data(pkg, PythonTypeData)
439+
if td is not None and td.extras is not None:
440+
extras = td.extras
438441
else:
439442
extras = getattr(pkg, "extras", None)
440443
extras_str = "[%s]" % ",".join(extras) if extras else ""

src/ivpm/ivpm_yaml_reader.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .utils import fatal, getlocstr, warning
1616
from ivpm.package import Package, PackageType, SourceType
1717
from ivpm.packages_info import PackagesInfo
18+
from ivpm.pkg_content_type import parse_type_field
1819

1920

2021
class IvpmYamlReader(object):
@@ -50,6 +51,9 @@ def read(self, fp, name) -> 'ProjInfo':
5051
else:
5152
ret.version = None
5253

54+
if "type" in pkg.keys():
55+
ret.self_types = parse_type_field(pkg["type"])
56+
5357
# Specify where sub-packages are stored. Defaults to 'packages'
5458
if "deps-dir" in pkg.keys():
5559
ret.deps_dir = pkg["deps-dir"]
@@ -105,16 +109,64 @@ def read_dep_sets(self, info : 'ProjInfo', dep_sets):
105109
ds = PackagesInfo(ds_name)
106110
default_dep_set = None
107111

112+
if "uses" in ds_ent.keys():
113+
ds.uses = str(ds_ent["uses"])
114+
108115
if "default-dep-set" in ds_ent.keys():
109116
default_dep_set = ds_ent["default-dep-set"]
110117

111-
112118
deps = ds_ent["deps"]
113119

114120
if not isinstance(deps, list):
115121
raise Exception("deps is not a list")
116122
self.read_deps(ds, ds_ent["deps"], default_dep_set)
117123
info.set_dep_set(ds.name, ds)
124+
125+
self._resolve_dep_set_inheritance(info)
126+
127+
def _resolve_dep_set_inheritance(self, info: 'ProjInfo'):
128+
"""
129+
Merge inherited packages for every dep-set that declares a 'uses' base.
130+
The current dep-set's packages win on name collision.
131+
Detects cycles and unknown base names.
132+
"""
133+
dep_set_m = info.dep_set_m
134+
resolved = set()
135+
136+
def resolve(name, visiting):
137+
if name in resolved:
138+
return
139+
ds = dep_set_m[name]
140+
if ds.uses is None:
141+
resolved.add(name)
142+
return
143+
if name in visiting:
144+
cycle = " -> ".join(list(visiting) + [name])
145+
raise Exception(
146+
"Cyclic dep-set inheritance detected: %s" % cycle)
147+
base_name = ds.uses
148+
if base_name not in dep_set_m:
149+
raise Exception(
150+
"dep-set '%s' references unknown base dep-set '%s'"
151+
% (name, base_name))
152+
visiting.add(name)
153+
resolve(base_name, visiting)
154+
visiting.discard(name)
155+
156+
base_ds = dep_set_m[base_name]
157+
# Start with base packages, then let current overwrite
158+
merged_pkgs = base_ds.packages.copy()
159+
merged_pkgs.update(ds.packages)
160+
ds.packages = merged_pkgs
161+
162+
merged_opts = base_ds.options.copy()
163+
merged_opts.update(ds.options)
164+
ds.options = merged_opts
165+
166+
resolved.add(name)
167+
168+
for ds_name in list(dep_set_m.keys()):
169+
resolve(ds_name, set())
118170

119171

120172
def read_deps(self, ret : PackagesInfo, deps, default_dep_set):
@@ -168,19 +220,21 @@ def read_deps(self, ret : PackagesInfo, deps, default_dep_set):
168220
raise Exception("Package %s has unknown type %s" % (d["name"], src))
169221
pkg = PkgTypeRgy.inst().mkPackage(src, str(d["name"]), d, si)
170222

171-
# Resolve content type and validate 'with:' parameters
223+
# Resolve content type from 'type:' field (string, dict, or list form).
224+
# 'with:' is no longer supported; options are now inline in the type dict.
172225
ct_rgy = PkgContentTypeRgy.inst()
226+
if "with" in d.keys():
227+
fatal("Package '%s': 'with:' is no longer supported; "
228+
"use inline options instead, e.g. type: { python: { editable: false } } @ %s" % (
229+
pkg.name, getlocstr(d["with"])))
173230
if "type" in d.keys():
174-
type_name = str(d["type"])
175-
if not ct_rgy.has(type_name):
176-
fatal("Package '%s': unknown type '%s' @ %s ; known types: %s" % (
177-
pkg.name, type_name, getlocstr(d["type"]),
178-
", ".join(ct_rgy.names())))
179-
with_opts = d["with"] if "with" in d.keys() else {}
180-
pkg.type_data = ct_rgy.get(type_name).create_data(with_opts, si)
181-
elif "with" in d.keys():
182-
fatal("Package '%s': 'with:' is specified but 'type:' is not @ %s" % (
183-
pkg.name, getlocstr(d["with"])))
231+
raw = parse_type_field(d["type"])
232+
for type_name, opts in raw:
233+
if not ct_rgy.has(type_name):
234+
fatal("Package '%s': unknown type '%s' @ %s ; known types: %s" % (
235+
pkg.name, type_name, getlocstr(d["type"]),
236+
", ".join(ct_rgy.names())))
237+
pkg.type_data.append(ct_rgy.get(type_name).create_data(opts, si))
184238

185239
# Unless specified, load the same dep-set from sub-packages
186240
if pkg.dep_set is None:

src/ivpm/package.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import os
2525
import dataclasses as dc
2626
from enum import Enum, auto
27-
from typing import Dict, List, Set, Optional
27+
from typing import Dict, List, Set, Optional, Tuple
2828
from .project_ops_info import ProjectUpdateInfo
2929
from .utils import fatal, getlocstr
3030

@@ -97,9 +97,12 @@ class Package(object):
9797
path : str = None
9898
pkg_type : PackageType = None
9999
src_type : str = None
100-
# type_data holds validated, type-specific parameters from the 'with:' YAML key.
101-
# Set by IvpmYamlReader when 'type:' is present; None when type is auto-detected.
102-
type_data : Optional['TypeData'] = None
100+
# type_data holds the list of validated TypeData objects produced from the 'type:' field.
101+
# Set by IvpmYamlReader; empty list means no explicit type (auto-detection may still apply).
102+
type_data : List['TypeData'] = dc.field(default_factory=list)
103+
# self_types holds the raw (type_name, opts) pairs read from the package's own ivpm.yaml.
104+
# Populated during dep resolution by package_updater; empty until then.
105+
self_types : List[Tuple[str, dict]] = dc.field(default_factory=list)
103106

104107
process_deps : bool = True
105108
setup_deps : Set[str] = dc.field(default_factory=set)
@@ -152,19 +155,24 @@ def process_options(self, opts, si):
152155
fatal("Unknown value for 'deps': %s" % opts["deps"])
153156

154157
# Set pkg_type from 'type:' for backward compatibility.
155-
# The authoritative typed data is stored in type_data by IvpmYamlReader
156-
# after calling PkgContentTypeRgy. We still set pkg_type here so that
157-
# code that has not yet migrated to type_data continues to work.
158+
# The authoritative typed data is stored in type_data by IvpmYamlReader.
158159
if "type" in opts.keys():
159-
type_s = opts["type"]
160-
if type_s in Spec2PackageType.keys():
161-
self.pkg_type = Spec2PackageType[type_s]
162-
# Unknown type strings are tolerated here; the YAML reader validates them
163-
# via PkgContentTypeRgy and emits a proper error with source location.
160+
from .pkg_content_type import parse_type_field
161+
pairs = parse_type_field(opts["type"])
162+
if pairs:
163+
first_name = pairs[0][0]
164+
if first_name in Spec2PackageType.keys():
165+
self.pkg_type = Spec2PackageType[first_name]
164166

165167
@staticmethod
166168
def mk(name, opts, si) -> 'Package':
167169
raise NotImplementedError()
168-
169170

170171

172+
def get_type_data(pkg: 'Package', cls):
173+
"""Return the first TypeData entry in pkg.type_data that is an instance of cls, or None."""
174+
for td in pkg.type_data:
175+
if isinstance(td, cls):
176+
return td
177+
return None
178+

src/ivpm/package_updater.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,17 @@ def _update_pkg(self, pkg : Package) -> Tuple[Package, ProjInfo]:
212212
try:
213213
pkg.proj_info = pkg.update(self.update_info)
214214

215+
# Merge self-declared types from the dep's own ivpm.yaml into pkg.type_data.
216+
# Caller-specified types take priority; self-declared ones are appended only
217+
# if their type name is not already present.
218+
if pkg.proj_info is not None and pkg.proj_info.self_types:
219+
from .pkg_content_type_rgy import PkgContentTypeRgy
220+
ct_rgy = PkgContentTypeRgy.inst()
221+
caller_names = {td.type_name for td in pkg.type_data}
222+
for type_name, opts in pkg.proj_info.self_types:
223+
if type_name not in caller_names and ct_rgy.has(type_name):
224+
pkg.type_data.append(ct_rgy.get(type_name).create_data(opts, None))
225+
215226
# Notify the package handlers after the source is
216227
# loaded so they can take further action if required
217228
self.pkg_handler.process_pkg(pkg)

src/ivpm/packages_info.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#* Author: mballance
2020
#*
2121
#****************************************************************************
22-
from typing import Dict, List, Set
22+
from typing import Dict, List, Optional, Set
2323
from ivpm.package import Package
2424

2525
class PackagesInfo():
@@ -30,6 +30,9 @@ class PackagesInfo():
3030

3131
def __init__(self, name):
3232
self.name = name
33+
# Name of a dep-set in the same file to inherit packages from.
34+
# Populated during parsing; resolved (merged) before use.
35+
self.uses : Optional[str] = None
3336
self.packages : Dict[str,Package] = {}
3437

3538
# Map of package name to set of packages
@@ -63,6 +66,7 @@ def __setitem__(self, key, value):
6366

6467
def copy(self) -> 'PackagesInfo':
6568
ret = PackagesInfo(self.name)
69+
ret.uses = self.uses
6670
ret.packages = self.packages.copy()
6771
ret.options = self.options.copy()
6872

0 commit comments

Comments
 (0)