Skip to content

Commit

Permalink
Implement middleware support (conan-io#9636)
Browse files Browse the repository at this point in the history
  • Loading branch information
gmeeker committed Oct 24, 2021
1 parent 799a36b commit a174cdf
Show file tree
Hide file tree
Showing 9 changed files with 482 additions and 12 deletions.
16 changes: 16 additions & 0 deletions conans/client/cache/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
HOOKS_FOLDER = "hooks"
TEMPLATES_FOLDER = "templates"
GENERATORS_FOLDER = "generators"
MIDDLEWARE_FOLDER = "middleware"


def _is_case_insensitive_os():
Expand Down Expand Up @@ -194,6 +195,10 @@ def settings_path(self):
def generators_path(self):
return os.path.join(self.cache_folder, GENERATORS_FOLDER)

@property
def middleware_path(self):
return os.path.join(self.cache_folder, MIDDLEWARE_FOLDER)

@property
def default_profile_path(self):
if os.path.isabs(self.config.default_profile):
Expand Down Expand Up @@ -246,6 +251,17 @@ def generators(self):
generators.append(generator)
return generators

@property
def middleware(self):
"""Returns a list of middleware paths inside the middleware folder"""
middleware = []
if os.path.exists(self.middleware_path):
for path in os.listdir(self.middleware_path):
middleware_path = os.path.join(self.middleware_path, path)
if os.path.isfile(middleware_path) and middleware_path.endswith(".py"):
middleware.append(middleware_path)
return middleware

def delete_empty_dirs(self, deleted_refs):
""" Method called by ConanRemover.remove() to clean up from the cache empty folders
:param deleted_refs: The recipe references that the remove() has been removed
Expand Down
7 changes: 6 additions & 1 deletion conans/client/conan_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from conans.client.conanfile.package import run_package_method
from conans.client.conf.required_version import check_required_conan_version
from conans.client.generators import GeneratorManager
from conans.client.middleware import MiddlewareManager
from conans.client.graph.graph import RECIPE_EDITABLE
from conans.client.graph.graph_binaries import GraphBinariesAnalyzer
from conans.client.graph.graph_manager import GraphManager
Expand Down Expand Up @@ -201,8 +202,11 @@ def __init__(self, cache_folder, user_io, http_requester=None, runner=None, quie
self.python_requires = ConanPythonRequire(self.proxy, self.range_resolver,
self.generator_manager)
self.pyreq_loader = PyRequireLoader(self.proxy, self.range_resolver)
self.middleware_manager = MiddlewareManager(self.proxy, self.range_resolver)
self.loader = ConanFileLoader(self.runner, self.out, self.python_requires,
self.generator_manager, self.pyreq_loader, self.requester)
self.generator_manager, self.pyreq_loader, self.requester,
middleware_manager=self.middleware_manager)
self.loader.load_middleware_from_cache(self.cache)

self.binaries_analyzer = GraphBinariesAnalyzer(self.cache, self.out, self.remote_manager)
self.graph_manager = GraphManager(self.out, self.cache, self.remote_manager, self.loader,
Expand All @@ -215,6 +219,7 @@ def load_remotes(self, remote_name=None, update=False, check_updates=False):
self.python_requires.enable_remotes(update=update, check_updates=check_updates,
remotes=remotes)
self.pyreq_loader.enable_remotes(update=update, check_updates=check_updates, remotes=remotes)
self.middleware_manager.enable_remotes(update=update, check_updates=check_updates, remotes=remotes)
return remotes


Expand Down
65 changes: 55 additions & 10 deletions conans/client/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
conanfile_exception_formatter
from conans.model.conan_file import ConanFile
from conans.model.conan_generator import Generator
from conans.model.conan_middleware import Middleware
from conans.model.options import OptionsValues
from conans.model.ref import ConanFileReference
from conans.model.settings import Settings
Expand All @@ -24,30 +25,40 @@
class ConanFileLoader(object):

def __init__(self, runner, output, python_requires, generator_manager=None, pyreq_loader=None,
requester=None):
requester=None, middleware_manager=None):
self._runner = runner
self._generator_manager = generator_manager
self._middleware_manager = middleware_manager
self._output = output
self._pyreq_loader = pyreq_loader
self._python_requires = python_requires
sys.modules["conans"].python_requires = python_requires
self._cached_conanfile_classes = {}
self._requester = requester

def apply_middleware(self, conanfile_path, conanfile, user=None, channel=None,
profile=None, consumer=False):
if not profile:
return conanfile
return self._middleware_manager.apply_middleware(conanfile_path, conanfile, user, channel,
consumer, profile, self)

def load_basic(self, conanfile_path, lock_python_requires=None, user=None, channel=None,
display=""):
display="", profile=None):
""" loads a conanfile basic object without evaluating anything
"""
return self.load_basic_module(conanfile_path, lock_python_requires, user, channel,
display)[0]
display, profile)[0]

def load_basic_module(self, conanfile_path, lock_python_requires=None, user=None, channel=None,
display=""):
display="", profile=None, consumer=False):
""" loads a conanfile basic object without evaluating anything, returns the module too
"""
cached = self._cached_conanfile_classes.get(conanfile_path)
if cached and cached[1] == lock_python_requires:
conanfile = cached[0](self._output, self._runner, display, user, channel)
recipe_class = self.apply_middleware(conanfile_path, cached[0], user, channel,
profile, consumer)
conanfile = recipe_class(self._output, self._runner, display, user, channel)
conanfile._conan_requester = self._requester
if hasattr(conanfile, "init") and callable(conanfile.init):
with conanfile_exception_formatter(str(conanfile), "init"):
Expand Down Expand Up @@ -83,6 +94,8 @@ def load_basic_module(self, conanfile_path, lock_python_requires=None, user=None
if scm_data:
conanfile.scm.update(scm_data)

conanfile = self.apply_middleware(conanfile_path, conanfile, user, channel,
profile, consumer)
self._cached_conanfile_classes[conanfile_path] = (conanfile, lock_python_requires,
module)
result = conanfile(self._output, self._runner, display, user, channel)
Expand Down Expand Up @@ -110,6 +123,35 @@ def load_generators(self, conanfile_path):
if issubclass(attr, Generator) and attr != Generator:
self._generator_manager.add(attr.__name__, attr, custom=True)

def load_middleware(self, conanfile_path):
""" Load middleware classes from a module. Any non-middleware classes
will be ignored. python_requires is not processed.
"""
""" Parses a python in-memory module and adds any middleware found
to the provided middleware list
@param conanfile_module: the module to be processed
"""
conanfile_module, module_id = _parse_conanfile(conanfile_path)
middleware = None
for name, attr in conanfile_module.__dict__.items():
if (name.startswith("_") or not inspect.isclass(attr) or
attr.__dict__.get("__module__") != module_id):
continue
if issubclass(attr, Middleware) and attr != Middleware:
self._middleware_manager.add(attr.__name__, attr, custom=True)
if middleware is None:
middleware = attr
if middleware is None:
raise ConanException("No subclass of Middleware in the file")
return middleware, conanfile_module

def load_middleware_from_cache(self, cache):
# Load custom middleware from the cache.
# Middleware loaded here from the cache will have precedence
# and overwrite possible middleware loaded from packages.
for middleware_path in cache.middleware:
self.load_middleware(middleware_path)

@staticmethod
def _load_data(conanfile_path):
data_path = os.path.join(os.path.dirname(conanfile_path), DATA_YML)
Expand All @@ -123,10 +165,12 @@ def _load_data(conanfile_path):

return data or {}

def load_named(self, conanfile_path, name, version, user, channel, lock_python_requires=None):
def load_named(self, conanfile_path, name, version, user, channel, lock_python_requires=None,
profile=None, consumer=False):
""" loads the basic conanfile object and evaluates its name and version
"""
conanfile, _ = self.load_basic_module(conanfile_path, lock_python_requires, user, channel)
conanfile, _ = self.load_basic_module(conanfile_path, lock_python_requires, user, channel,
profile=profile, consumer=consumer)

# Export does a check on existing name & version
if name:
Expand Down Expand Up @@ -207,7 +251,7 @@ def load_consumer(self, conanfile_path, profile_host, name=None, version=None, u
""" loads a conanfile.py in user space. Might have name/version or not
"""
conanfile = self.load_named(conanfile_path, name, version, user, channel,
lock_python_requires)
lock_python_requires, profile_host, consumer=True)

ref = ConanFileReference(conanfile.name, conanfile.version, user, channel, validate=False)
if str(ref):
Expand Down Expand Up @@ -243,7 +287,7 @@ def load_conanfile(self, conanfile_path, profile, ref, lock_python_requires=None
"""
try:
conanfile, _ = self.load_basic_module(conanfile_path, lock_python_requires,
ref.user, ref.channel, str(ref))
ref.user, ref.channel, str(ref), profile)
except Exception as e:
raise ConanException("%s: Cannot load recipe.\n%s" % (str(ref), str(e)))

Expand Down Expand Up @@ -272,7 +316,8 @@ def load_conanfile_txt(self, conan_txt_path, profile_host, ref=None):
return conanfile

def _parse_conan_txt(self, contents, path, display_name, profile):
conanfile = ConanFile(self._output, self._runner, display_name)
conanfile = self.apply_middleware(path, ConanFile, profile=profile, consumer=True)
conanfile = conanfile(self._output, self._runner, display_name)
tmp_settings = profile.processed_settings.copy()
package_settings_values = profile.package_settings_values
if "&" in package_settings_values:
Expand Down
178 changes: 178 additions & 0 deletions conans/client/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import fnmatch
import os

from conans.client.recorder.action_recorder import ActionRecorder
from conans.errors import ConanException
from conans.model.ref import ConanFileReference
from conans.model.requires import Requirement


class MiddlewareLoader(object):
def __init__(self, proxy, range_resolver):
self._proxy = proxy
self._range_resolver = range_resolver
self._cached_modules = {}

def enable_remotes(self, check_updates=False, update=False, remotes=None):
self._check_updates = check_updates
self._update = update
self._remotes = remotes

def resolve_middleware_requires(self, loader, conanfile, profile, user, channel, consumer=True):
ref = ConanFileReference(conanfile.name, conanfile.version, user, channel, validate=False)
ref_str = str(ref)
recipe_middleware_requires = []
for pattern, refs in profile.middleware_requires.items():
consumer = False
if ((consumer and pattern == "&") or
(not consumer and pattern == "&!") or
fnmatch.fnmatch(ref_str, pattern)):
for ref in refs:
if ref not in recipe_middleware_requires:
recipe_middleware_requires.append(ref)
self._resolve_middleware_requires(recipe_middleware_requires, loader)
return recipe_middleware_requires

def _resolve_middleware_requires(self, middleware_refs, loader):
for ref in middleware_refs:
ref_str = str(ref)
if ref_str not in self._cached_modules:
self._cached_modules[ref_str] = self._load_middleware_conanfile(loader, ref)

def _load_middleware_conanfile(self, loader, ref):
requirement = Requirement(ref)
self._range_resolver.resolve(requirement, "middleware", update=self._update,
remotes=self._remotes)
new_ref = requirement.ref
recipe = self._proxy.get_recipe(new_ref, self._check_updates, self._update,
remotes=self._remotes, recorder=ActionRecorder())
path, _, _, new_ref = recipe
middleware, module = loader.load_middleware(path)
middleware.name = new_ref.name
# FIXME Conan 2.0 version should be a string, not a Version object
middleware.version = new_ref.version

if getattr(middleware, "alias", None):
ref = ConanFileReference.loads(middleware.alias)
requirement = Requirement(ref)
alias = requirement.alias
if alias is not None:
ref = alias
return self._load_middleware_conanfile(loader, ref)
return middleware, module, new_ref, os.path.dirname(path)


class MiddlewareManager(object):
def __init__(self, proxy, range_resolver):
self._loader = MiddlewareLoader(proxy, range_resolver)
# Future built-in middleware go here.
self._middleware = {}

def enable_remotes(self, *args, **kwargs):
self._loader.enable_remotes(*args, **kwargs)

def add(self, name, middleware_class, custom=False):
if name not in self._middleware or custom:
self._middleware[name] = middleware_class

def __contains__(self, name):
return name in self._middleware

def __getitem__(self, key):
return self._middleware[key]

@staticmethod
def _initialize_middleware(middleware, profile):
# Prepare the settings for the loaded middleware
# Mixing the global settings with the specified for that name if exist
tmp_settings = profile.processed_settings.copy()
package_settings_values = profile.package_settings_values
ref = ConanFileReference(middleware.name or middleware.__class__.__name__,
middleware.version or "1.0",
user=None, channel=None, validate=False)
ref_str = str(ref)
if package_settings_values:
# First, try to get a match directly by name (without needing *)
# TODO: Conan 2.0: We probably want to remove this, and leave a pure fnmatch
pkg_settings = package_settings_values.get(middleware.name)

if middleware.develop and "&" in package_settings_values:
# "&" overrides the "name" scoped settings.
pkg_settings = package_settings_values.get("&")

if pkg_settings is None: # If there is not exact match by package name, do fnmatch
for pattern, settings in package_settings_values.items():
if fnmatch.fnmatchcase(ref_str, pattern):
pkg_settings = settings
break
if pkg_settings:
tmp_settings.update_values(pkg_settings)

middleware.initialize(tmp_settings, profile.env_values, profile.buildenv)
middleware.conf = profile.conf.get_conanfile_conf(ref_str)

def find_middleware(self, middleware_name):
try:
return self._middleware[middleware_name]
except KeyError:
available = list(self._middleware.keys())
raise ConanException("Invalid middleware '%s'. Available types: %s" %
(middleware_name, ", ".join(available)))

def new_middleware(self, middleware_name, profile, loader):
cache = profile.cached_middleware
if middleware_name in cache.middleware:
return cache.middleware[middleware_name]
middleware_class = self.find_middleware(middleware_name)
middleware = middleware_class(loader._output, middleware_name)
middleware.output.scope = middleware.display_name
self._initialize_middleware(middleware, profile)
cache.middleware[middleware_name] = middleware
return middleware

def _apply_middleware(self, middleware, conanfile_path, conanfile, user, channel, profile, loader):
result = conanfile
applied = []
for mw in middleware:
if mw.should_apply(result):
result = mw(result)
applied.append(mw)
if applied:
# This might be useful if multiple middleware needs to work together?
result._middleware = applied
return result

def apply_middleware(self, conanfile_path, conanfile, user, channel, consumer, profile, loader):
if not profile:
return conanfile
if not profile.middleware:
return conanfile
# Don't re-apply middleware
if hasattr(conanfile, "_middleware"):
return conanfile
if conanfile_path in profile.cached_middleware:
return profile.cached_middleware[conanfile_path]
# Load middleware_requires first
self._loader.resolve_middleware_requires(loader, conanfile, profile, user, channel, consumer)
# Find middleware by conanfile and profile, then instantiate with profile settings
middleware = self.get_recipe_middleware(conanfile, profile, user, channel, consumer)
middleware = [self.new_middleware(mw, profile, loader) for mw in middleware]
# Subclass conanfile
result = self._apply_middleware(middleware, conanfile_path, conanfile,
user, channel, profile, loader)
profile.cached_middleware[conanfile_path] = result
return result

def get_recipe_middleware(self, conanfile, profile, user, channel, consumer=True):
ref = ConanFileReference(conanfile.name, conanfile.version, user, channel, validate=False)
ref_str = str(ref)
recipe_middleware = []
for pattern, middleware in profile.middleware.items():
consumer = False
if ((consumer and pattern == "&") or
(not consumer and pattern == "&!") or
fnmatch.fnmatch(ref_str, pattern)):
for mw in middleware:
if mw not in recipe_middleware:
recipe_middleware.append(mw)
return recipe_middleware
Loading

0 comments on commit a174cdf

Please sign in to comment.