Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/environment propagate #8534

Merged
merged 40 commits into from Mar 23, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
75ff512
proposal
memsharded Feb 21, 2021
3a585e5
build-host contexts poc
memsharded Feb 23, 2021
5f323b1
added CMakeGen integration
memsharded Feb 24, 2021
16863a1
fixing Macos tests
memsharded Feb 24, 2021
3fd4633
review
memsharded Feb 24, 2021
de6787e
make sure profile compose and include() correctly
memsharded Feb 24, 2021
7f86beb
Merge branch 'develop' into feature/environment_propagate
memsharded Feb 24, 2021
a96287c
minor interfaces
memsharded Feb 25, 2021
004ab8b
accesing Node
memsharded Feb 26, 2021
3d318e3
fixing tests
memsharded Feb 26, 2021
247f98c
fix py27 test
memsharded Feb 26, 2021
997936b
trying with shell only, not bash
memsharded Mar 1, 2021
fa0ca3d
not necessary to escape quotes
memsharded Mar 1, 2021
e63890e
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 1, 2021
1a0abdf
merged develop
memsharded Mar 1, 2021
1f66a6c
poc for self.run() env definition
memsharded Mar 3, 2021
f78169a
fix test
memsharded Mar 3, 2021
b6dcb07
renaming files
memsharded Mar 3, 2021
e3a3cf0
change default to opt-out
memsharded Mar 4, 2021
1482923
minor changes
memsharded Mar 4, 2021
1d42450
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 5, 2021
935b3d0
more complete real test
memsharded Mar 6, 2021
15dbf07
fix test
memsharded Mar 7, 2021
4221d32
fix v2_cmake template for shared libs in OSX with run_environment=True
memsharded Mar 8, 2021
3798c78
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 8, 2021
4dbe0c0
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 9, 2021
e1bf328
improved v2_cmake new template with environment
memsharded Mar 9, 2021
c43ed63
cpp_info.exes do not exist yet
memsharded Mar 9, 2021
3198d92
fix tests
memsharded Mar 9, 2021
add3b79
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 11, 2021
9bfe52b
merged develop
memsharded Mar 18, 2021
ac00b00
remove blanks
memsharded Mar 18, 2021
64f427d
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 21, 2021
ac44190
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 21, 2021
5247a74
better visiting with more test
memsharded Mar 22, 2021
d1be0f6
fix test
memsharded Mar 22, 2021
dca53ca
fix test and new test for buildenv from requires
memsharded Mar 22, 2021
d89fe97
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 23, 2021
e8d5515
Merge branch 'develop' into feature/environment_propagate
memsharded Mar 23, 2021
41bd889
making VirtualEnv automatic opt-in
memsharded Mar 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Expand Up @@ -7,7 +7,6 @@ __pycache__/

# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
Expand Down
2 changes: 2 additions & 0 deletions conan/tools/env/__init__.py
@@ -0,0 +1,2 @@
from conan.tools.env.environment import Environment
from conan.tools.env.virtualenv import VirtualEnv
230 changes: 230 additions & 0 deletions conan/tools/env/environment.py
@@ -0,0 +1,230 @@
import fnmatch
import os
import textwrap
from collections import OrderedDict

from conans.errors import ConanException
from conans.util.files import save

_ENV_VAR_PLACEHOLDER = "$PREVIOUS_ENV_VAR_VALUE%"
memsharded marked this conversation as resolved.
Show resolved Hide resolved
_PATHSEP = "$CONAN_PATHSEP%"


class Environment:
def __init__(self):
# TODO: Maybe we need to pass conanfile to get the [conf]
# It being ordered allows for Windows case-insensitive composition
self._values = OrderedDict() # {var_name: [] of values, including separators}

def vars(self):
return list(self._values.keys())

def value(self, name, placeholder="{}", pathsep=os.pathsep):
return self._format_value(name, self._values[name], placeholder, pathsep)

@staticmethod
def _format_value(name, varvalues, placeholder, pathsep):
values = []
for v in varvalues:
if v == _ENV_VAR_PLACEHOLDER:
values.append(placeholder.format(name=name))
elif v == _PATHSEP:
values.append(pathsep)
else:
values.append(v)
return "".join(values)

@staticmethod
def _list_value(value, separator):
if isinstance(value, list):
result = []
for v in value[:-1]:
result.append(v)
result.append(separator)
result.extend(value[-1:])
return result
else:
return [value]

def define(self, name, value, separator=" "):
value = self._list_value(value, separator)
self._values[name] = value

def define_path(self, name, value):
self.define(name, value, _PATHSEP)

def unset(self, name):
"""
clears the variable, equivalent to a unset or set XXX=
"""
self._values[name] = []

def append(self, name, value, separator=" "):
value = self._list_value(value, separator)
self._values[name] = [_ENV_VAR_PLACEHOLDER] + [separator] + value

def append_path(self, name, value):
self.append(name, value, _PATHSEP)

def prepend(self, name, value, separator=" "):
value = self._list_value(value, separator)
self._values[name] = value + [separator] + [_ENV_VAR_PLACEHOLDER]

def prepend_path(self, name, value):
self.prepend(name, value, _PATHSEP)

def save_bat(self, filename, generate_deactivate=True, pathsep=os.pathsep):
deactivate = textwrap.dedent("""\
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another point about Jinja: if you have those templates then you can just go through the list of templates and generate each one. This will be customizeable and easily extensible. For example add setting:
virtualenv.templates=buildenv.sh.j2,runenv.sh.j2,buildenv.fish.j2
(Also modules / blocks can be loaded from config file location).

I rewrote my generator in Jinja. The template is arguably easier to read but python code is significantly simpler without all if'ed logic.

Another benefit of such user-extensibility is that it would be easier to write plain .env files as @solvingj asked and which could be used in IDE (at least many VS Code plugins start supporting .env files: python, C++ debugger).

Also doing this as a template doesn't mean immediate decision to publish as a user-facing contract, but still (I believe) will reduce complexity of python code.

The only trick is to carefully handle this for pyinstaller / zip-friendlyness.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree it would be great to provide templates for this, and provide user extensibility. At this point my priority is to define the new paradigm:

  • How generators access dependencies information via a visitor (ConanFileDependencies)
  • The new separation of build and run envs
  • The explicit generation of environment scripts for handling env-vars instead of doing everything dynamically in Conan (and thus not allowing many user flows)
  • A clear and explicit mechanism to define env-var operations: append, prepend, clear, define, and how these compose for multiple levels (profile, build-requires, toolchains...)

Still the problem of .env files is not fully clear. In Conan we clearly have the user demand for those different operations: append, prepend, define, unset, and those .env files cannot do that. Also, in Conan users might need to concatenate more than one environment definition (for example the definition coming from profiles and build-requires, and another definition coming from a toolchain), that both will need to perform different operations like appending. Those .env files cannot do this either, up to my knowledge.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still the problem of .env files is not fully clear.

At least in VS Code many plugins can use .env files instead of having settings. For example, python extension appends PYTHONPATH from it to its internal search path, C++ extension has similar option in debug configuration (for example to run program with modified LD_LIBRARY_PATH).

It may not even be possible to run IDE in such an activated environment, because, for example for workspace each folder (repo / project) has own settings / environment.

I'm not suggesting adding this by default, but some users may find it useful to customize and produce own files, even if they for example capture complete variables at the install time.

echo Capturing current environment in deactivate_{filename}
setlocal
echo @echo off > "deactivate_{filename}"
echo echo Restoring environment >> "deactivate_{filename}"
for %%v in ({vars}) do (
set foundenvvar=
for /f "delims== tokens=1,2" %%a in ('set') do (
if "%%a" == "%%v" (
echo set %%a=%%b>> "deactivate_{filename}"
set foundenvvar=1
)
)
if not defined foundenvvar (
echo set %%v=>> "deactivate_{filename}"
)
)
endlocal

""").format(filename=filename, vars=" ".join(self._values.keys()))
capture = textwrap.dedent("""\
@echo off
{deactivate}
echo Configuring environment variables
""").format(deactivate=deactivate if generate_deactivate else "")
result = [capture]
for varname, varvalues in self._values.items():
value = self._format_value(varname, varvalues, "%{name}%", pathsep)
result.append('set {}={}'.format(varname, value))

content = "\n".join(result)
save(filename, content)

def save_ps1(self, filename, generate_deactivate=True, pathsep=os.pathsep):
# FIXME: This is broken and doesnt work
deactivate = ""
capture = textwrap.dedent("""\
{deactivate}
""").format(deactivate=deactivate if generate_deactivate else "")
result = [capture]
for varname, varvalues in self._values.items():
value = self._format_value(varname, varvalues, "$env:{name}", pathsep)
result.append('Write-Output "Error: whatever message {}"'.format(varname))
result.append('$env:{}={}'.format(varname, value))

content = "\n".join(result)
save(filename, content)

def save_sh(self, filename, pathsep=os.pathsep):
capture = textwrap.dedent("""\
echo Capturing current environment in deactivate_{filename}
echo echo Restoring variables >> deactivate_{filename}
for v in {vars}
do
value=${{!v}}
if [ -n "$value" ]
then
echo export "$v=$value" >> deactivate_{filename}
else
echo unset $v >> deactivate_{filename}
fi
done
echo Configuring environment variables
""".format(filename=filename, vars=" ".join(self._values.keys())))
result = [capture]
for varname, varvalues in self._values.items():
value = self._format_value(varname, varvalues, "${name}", pathsep)
if value:
result.append('export {}="{}"'.format(varname, value))
else:
result.append('unset {}'.format(varname))

content = "\n".join(result)
save(filename, content)

def compose(self, other):
"""
:type other: Environment
"""
for k, v in other._values.items():
existing = self._values.get(k)
if existing is None:
self._values[k] = v
else:
try:
index = v.index(_ENV_VAR_PLACEHOLDER)
except ValueError: # The other doesn't have placeholder, overwrites
self._values[k] = v
else:
new_value = v[:] # do a copy
new_value[index:index + 1] = existing # replace the placeholder
self._values[k] = new_value

return self


class ProfileEnvironment:
def __init__(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may ctor receive arguments as loads? I see usage pattern is x = ProfileEnvironment(); x.load(something)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, lets change it to ProfileEnvirionment.loads(something) staticmethod, as it will be more aligned with the rest of the codebase.

self._environments = OrderedDict()

def get_env(self, ref):
# TODO: Maybe we want to make this lazy, so this is not evaluated for every package
result = Environment()
for pattern, env in self._environments.items():
if pattern is None or fnmatch.fnmatch(str(ref), pattern):
env = self._environments[pattern]
result = result.compose(env)
return result

def compose(self, other):
"""
:type other: ProfileEnvironment
"""
for pattern, environment in other._environments.items():
existing = self._environments.get(pattern)
if existing is not None:
self._environments[pattern] = existing.compose(environment)
else:
self._environments[pattern] = environment

def loads(self, text):
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
for op, method in (("+=", "append"), ("=+", "prepend"),
("=!", "unset"), ("=", "define")):
tokens = line.split(op, 1)
if len(tokens) != 2:
continue
pattern_name, value = tokens
pattern_name = pattern_name.split(":", 1)
if len(pattern_name) == 2:
pattern, name = pattern_name
else:
pattern, name = None, pattern_name[0]

env = Environment()
if method == "unset":
env.unset(name)
else:
if value.startswith("(path)"):
value = value[6:]
method = method + "_path"
getattr(env, method)(name, value)

existing = self._environments.get(pattern)
if existing is None:
self._environments[pattern] = env
else:
self._environments[pattern] = existing.compose(env)
break
else:
raise ConanException("Bad env defintion: {}".format(line))
98 changes: 98 additions & 0 deletions conan/tools/env/virtualenv.py
@@ -0,0 +1,98 @@
import platform

from conan.tools.env import Environment
from conans.client.graph.graph import CONTEXT_BUILD


class VirtualEnv:
""" captures the conanfile environment that is defined from its
dependencies, and also from profiles
"""

def __init__(self, conanfile):
self._conanfile = conanfile

def build_environment(self):
""" collects the buildtime information from dependencies. This is the typical use case
of build_requires defining information for consumers
"""
# Visit all dependencies
deps_env = Environment()
# TODO: The visitor of dependencies needs to be implemented correctly
# TODO: The environment should probably be composed with direct dependencies first
# TODO: in paths, but this is the opposite
for dep in self._conanfile.dependencies.all:
# environ_info is always "build"
dep_env = dep.buildenv_info
if dep_env is not None:
deps_env.compose(dep_env)
if dep.context == CONTEXT_BUILD:
runenv = self._runenv_from_cpp_info(dep)
deps_env.compose(runenv)

# The profile environment has precedence, applied last
profile_env = self._conanfile.buildenv
deps_env.compose(profile_env)
return deps_env

def autorun_environment(self):
""" automatically collects the runtime environment from 'cpp_info' from dependencies
By default is enabled and will be captured in 'runenv.xxx' but maybe can be disabled
by parameter or [conf]
"""
dyn_runenv = Environment()
for dep in self._conanfile.dependencies.all:
if dep.context == CONTEXT_BUILD: # Build environment cannot happen in runtime
continue
env = self._runenv_from_cpp_info(dep)
dyn_runenv.compose(env)
return dyn_runenv

@staticmethod
def _runenv_from_cpp_info(conanfile_dep):
""" return an Environment deducing the runtime information from a cpp_info
"""
dyn_runenv = Environment()
cpp_info = conanfile_dep.cpp_info
if cpp_info.exes:
dyn_runenv.prepend_path("PATH", cpp_info.bin_paths)
# If it is a build_require this will be the build-os, otherwise it will be the host-os
os_ = conanfile_dep.settings.get_safe("os")
if cpp_info.lib_paths:
if os_ == "Linux":
dyn_runenv.prepend_path("LD_LIBRARY_PATH", cpp_info.lib_paths)
elif os_ == "Macos":
dyn_runenv.prepend_path("DYLD_LIBRARY_PATH", cpp_info.lib_paths)
if cpp_info.framework_paths:
dyn_runenv.prepend_path("DYLD_FRAMEWORK_PATH", cpp_info.framework_paths)
return dyn_runenv

def run_environment(self):
""" collects the runtime information from dependencies. For normal libraries should be
very occasional
"""
# Visit all dependencies
deps_env = Environment()
for dep in self._conanfile.dependencies.all:
# run_environ_info is always "host"
dep_env = dep.runenv_info
if dep_env is not None:
deps_env.compose(dep_env)

autorun = self.autorun_environment()
deps_env.compose(autorun)
# FIXME: Missing profile info
result = deps_env
return result

def generate(self):
build_env = self.build_environment()
run_env = self.run_environment()
# FIXME: Use settings, not platform Not always defined :(
# os_ = self._conanfile.settings_build.get_safe("os")
if platform.system() == "Windows":
build_env.save_bat("buildenv.bat")
run_env.save_bat("runenv.bat")
else:
build_env.save_sh("buildenv.sh")
run_env.save_sh("runenv.sh")
8 changes: 5 additions & 3 deletions conans/client/generators/__init__.py
Expand Up @@ -66,7 +66,8 @@ def __init__(self):
"deploy": DeployGenerator,
"markdown": MarkdownGenerator}
self._new_generators = ["CMakeToolchain", "CMakeDeps", "MakeToolchain", "MSBuildToolchain",
"MesonToolchain", "MSBuildDeps", "QbsToolchain", "msbuild"]
"MesonToolchain", "MSBuildDeps", "QbsToolchain", "msbuild",
"VirtualEnv"]

def add(self, name, generator_class, custom=False):
if name not in self._generators or custom:
Expand Down Expand Up @@ -110,6 +111,9 @@ def _new_generator(self, generator_name, output):
elif generator_name == "QbsToolchain":
from conan.tools.qbs.qbstoolchain import QbsToolchain
return QbsToolchain
elif generator_name == "VirtualEnv":
from conan.tools.env.virtualenv import VirtualEnv
return VirtualEnv
else:
raise ConanException("Internal Conan error: Generator '{}' "
"not commplete".format(generator_name))
Expand Down Expand Up @@ -195,5 +199,3 @@ def write_toolchain(conanfile, path, output):
with chdir(path):
with conanfile_exception_formatter(str(conanfile), "generate"):
conanfile.generate()

# TODO: Lets discuss what to do with the environment
5 changes: 4 additions & 1 deletion conans/client/graph/graph_builder.py
Expand Up @@ -53,6 +53,7 @@ def load_graph(self, root_node, check_updates, update, remotes, profile_host, pr
initial = graph_lock.initial_counter if graph_lock else None
dep_graph = DepsGraph(initial_node_id=initial)
# compute the conanfile entry point for this dependency graph
root_node.conanfile.context = CONTEXT_HOST # FIXME: Is this always true?
root_node.public_closure.add(root_node)
root_node.public_deps.add(root_node)
root_node.transitive_closure[root_node.name] = root_node
Expand Down Expand Up @@ -205,7 +206,8 @@ def _expand_require(self, require, node, graph, check_updates, update, remotes,
remotes, profile_host, profile_build, graph_lock,
context_switch=context_switch,
populate_settings_target=populate_settings_target)

new_node.conanfile._conan_context = context
node.conanfile.dependencies.add(new_node.conanfile)
# The closure of a new node starts with just itself
new_node.public_closure.add(new_node)
new_node.transitive_closure[new_node.name] = new_node
Expand Down Expand Up @@ -237,6 +239,7 @@ def _expand_require(self, require, node, graph, check_updates, update, remotes,
node.transitive_closure[name] = n

else: # a public node already exist with this name
node.conanfile.dependencies.add(previous.conanfile)
self._resolve_cached_alias([require], graph)
# As we are closing a diamond, there can be conflicts. This will raise if conflicts
conflict = self._conflicting_references(previous, require.ref, node.ref)
Expand Down