libmambapy: use `Context` explicitly (#3309)

* All python singletons lifetime are bound to a unique `Context` instance lifetime

In `libmambapy`:
- bound singletons lifetime to the first `Context` instance created
- throw an error if more than one `Context` instance are created at any time
(this only applies to python and will be changed at another time)
- therefore the python user must create a `Context` before using any other functions

This specific change does not (yet) changes the other functions APIs to take
explicitly the `Context` object.

* simplify python expressions

* python API change, added requirement for a `Context` instance

affected functions:
- MultiPackageCache (constructor)
- SubdirData.create_repo
- SubdirIndex.create
- SubdirIndex.download
- clean
- transmute
- get_virtual_packages
- cancel_json_output

* libmamba loggers are now unregistered when Context instance is destroyed

* fixed: dont use `default` as a name

* remove quick-test file

* linter fixes

* Updated documentation

* temptative fix for potential data race in libmamba tests

* libmamba tests: added an assertion to be sure

* linter pass

* fixup libmambatests

* added log for helping understanding ci failure

* review fixes
This commit is contained in:
Klaim (Joël Lamotte) 2024-06-14 09:50:58 +02:00 committed by GitHub
parent d12f3711e6
commit ab088e5d8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 260 additions and 118 deletions

View File

@ -55,11 +55,23 @@ The Python bindings to the C++ ``libmamba`` library remain available through ``i
They are now considered the first class citizen to using Mamba in Python. They are now considered the first class citizen to using Mamba in Python.
Changes include: Changes include:
- The global ``Context``, previously available through ``Context()``, must now be accessed through - The global ``Context``, previously available through ``Context()``, must now be instantiated at least
``Context.instance()``. once before using ``libmambapy`` functions and types. Only one instance can exist at any time,
What's more, it is required to be passed explicitly in a few more functions. but that instance can be destroyed and another recreated later, for example if you use it in
Future version of ``libmambapy`` will continue in this direction until there are no global context. specific functions scopes.
What's more, it is required to be passed explicitly in a few more functions. Notably:
- ``MultiPackageCache`` (constructor)
- ``SubdirData.create_repo``
- ``SubdirIndex.create``
- ``SubdirIndex.download``
- ``clean``
- ``transmute``
- ``get_virtual_packages``
- ``cancel_json_output``
In version 2, ``Context()`` will throw an exception to allow catching errors more smoothly. In version 2, ``Context()`` will throw an exception to allow catching errors more smoothly.
- ``ChannelContext`` is no longer an implicit global variable. - ``ChannelContext`` is no longer an implicit global variable.
It must be constructed with one of ``ChannelContext.make_simple`` or It must be constructed with one of ``ChannelContext.make_simple`` or
``ChannelContext.make_conda_compatible`` (with ``Context.instance`` as argument in most cases) ``ChannelContext.make_conda_compatible`` (with ``Context.instance`` as argument in most cases)

View File

@ -272,7 +272,11 @@ namespace mamba
specs::AuthenticationDataBase m_authentication_info; specs::AuthenticationDataBase m_authentication_info;
bool m_authentication_infos_loaded = false; bool m_authentication_infos_loaded = false;
std::shared_ptr<Logger> logger; class ScopedLogger;
std::vector<ScopedLogger> loggers;
std::shared_ptr<Logger> main_logger();
void add_logger(std::shared_ptr<Logger>);
TaskSynchronizer tasksync; TaskSynchronizer tasksync;
}; };

View File

@ -444,7 +444,9 @@ namespace mamba
{ {
using Request = solver::Request; using Request = solver::Request;
request.jobs.reserve(request.jobs.size() + (!no_pin) * ctx.pinned_packages.size() + !no_py_pin); const auto estimated_jobs_count = request.jobs.size()
+ (!no_pin) * ctx.pinned_packages.size() + !no_py_pin;
request.jobs.reserve(estimated_jobs_count);
if (!no_pin) if (!no_pin)
{ {
for (const auto& pin : file_pins(prefix_data.path() / "conda-meta" / "pinned")) for (const auto& pin : file_pins(prefix_data.path() / "conda-meta" / "pinned"))

View File

@ -26,6 +26,7 @@
namespace mamba namespace mamba
{ {
class Logger : public spdlog::logger class Logger : public spdlog::logger
{ {
public: public:
@ -63,6 +64,57 @@ namespace mamba
} }
} }
enum class logger_kind
{
normal_logger,
default_logger,
};
// Associate the registration of a logger to the lifetime of this object.
// This is used to help with making sure loggers are unregistered once
// their logical owner is destroyed.
class Context::ScopedLogger
{
std::shared_ptr<Logger> m_logger;
public:
explicit ScopedLogger(std::shared_ptr<Logger> new_logger, logger_kind kind = logger_kind::normal_logger)
: m_logger(std::move(new_logger))
{
assert(m_logger);
if (kind == logger_kind::default_logger)
{
spdlog::set_default_logger(m_logger);
}
else
{
spdlog::register_logger(m_logger);
}
}
~ScopedLogger()
{
if (m_logger)
{
spdlog::drop(m_logger->name());
}
}
std::shared_ptr<Logger> logger() const
{
assert(m_logger);
return m_logger;
}
ScopedLogger(ScopedLogger&&) = default;
ScopedLogger& operator=(ScopedLogger&&) = default;
ScopedLogger(const ScopedLogger&) = delete;
ScopedLogger& operator=(const ScopedLogger&) = delete;
};
spdlog::level::level_enum convert_log_level(log_level l) spdlog::level::level_enum convert_log_level(log_level l)
{ {
return static_cast<spdlog::level::level_enum>(l); return static_cast<spdlog::level::level_enum>(l);
@ -86,6 +138,16 @@ namespace mamba
} }
} }
std::shared_ptr<Logger> Context::main_logger()
{
if (loggers.empty())
{
return {};
}
return loggers.front().logger();
}
void Context::enable_logging_and_signal_handling(Context& context) void Context::enable_logging_and_signal_handling(Context& context)
{ {
if (use_default_signal_handler_val) if (use_default_signal_handler_val)
@ -93,26 +155,25 @@ namespace mamba
set_default_signal_handler(); set_default_signal_handler();
} }
context.logger = std::make_shared<Logger>("libmamba", context.output_params.log_pattern, "\n"); context.loggers.clear(); // Make sure we work with a known set of loggers, first one is
// always the default one.
context.loggers.emplace_back(
std::make_shared<Logger>("libmamba", context.output_params.log_pattern, "\n"),
logger_kind::default_logger
);
MainExecutor::instance().on_close( MainExecutor::instance().on_close(
context.tasksync.synchronized([&context] { context.logger->flush(); }) context.tasksync.synchronized([&context] { context.main_logger()->flush(); })
); );
std::shared_ptr<spdlog::logger> libcurl_logger = std::make_shared<Logger>( context.loggers.emplace_back(
"libcurl", std::make_shared<Logger>("libcurl", context.output_params.log_pattern, "")
context.output_params.log_pattern,
""
);
std::shared_ptr<spdlog::logger> libsolv_logger = std::make_shared<Logger>(
"libsolv",
context.output_params.log_pattern,
""
); );
spdlog::register_logger(libcurl_logger); context.loggers.emplace_back(
spdlog::register_logger(libsolv_logger); std::make_shared<Logger>("libsolv", context.output_params.log_pattern, "")
);
spdlog::set_default_logger(context.logger);
spdlog::set_level(convert_log_level(context.output_params.logging_level)); spdlog::set_level(convert_log_level(context.output_params.logging_level));
} }
@ -384,9 +445,9 @@ namespace mamba
void Context::dump_backtrace_no_guards() void Context::dump_backtrace_no_guards()
{ {
if (logger) // REVIEW: is this correct? if (main_logger()) // REVIEW: is this correct?
{ {
logger->dump_backtrace_no_guards(); main_logger()->dump_backtrace_no_guards();
} }
} }

View File

@ -18,26 +18,34 @@ namespace mamba
void void
execute_tasks_from_concurrent_threads(std::size_t task_count, std::size_t tasks_per_thread, Func work) execute_tasks_from_concurrent_threads(std::size_t task_count, std::size_t tasks_per_thread, Func work)
{ {
std::vector<std::thread> producers; const auto estimated_thread_count = (task_count / tasks_per_thread) * 2;
std::vector<std::thread> producers(estimated_thread_count);
std::size_t tasks_left_to_launch = task_count; std::size_t tasks_left_to_launch = task_count;
std::size_t thread_idx = 0;
while (tasks_left_to_launch > 0) while (tasks_left_to_launch > 0)
{ {
const std::size_t tasks_to_generate = std::min(tasks_per_thread, tasks_left_to_launch); const std::size_t tasks_to_generate = std::min(tasks_per_thread, tasks_left_to_launch);
producers.emplace_back( producers[thread_idx] = std::thread{ [=]
[=]
{ {
for (std::size_t i = 0; i < tasks_to_generate; ++i) for (std::size_t i = 0; i < tasks_to_generate;
++i)
{ {
work(); work();
} }
} } };
);
tasks_left_to_launch -= tasks_to_generate; tasks_left_to_launch -= tasks_to_generate;
++thread_idx;
assert(thread_idx < producers.size());
} }
// Make sure all the producers are finished before continuing.
for (auto&& t : producers) for (auto&& t : producers)
{ {
t.join(); // Make sure all the producers are finished before continuing. if (t.joinable())
{
t.join();
}
} }
} }

View File

@ -6,6 +6,7 @@ __all__ = [
"Channel", "Channel",
"ChannelPriority", "ChannelPriority",
"CompressedProblemsGraph", "CompressedProblemsGraph",
"ContextOptions",
"Context", "Context",
"ExtraPkgInfo", "ExtraPkgInfo",
"History", "History",
@ -363,6 +364,26 @@ class CompressedProblemsGraph:
def tree_message(self) -> str: ... def tree_message(self) -> str: ...
pass pass
class ContextOptions:
def __init__(self) -> None: ...
@property
def json(self) -> bool:
"""
:type: bool
"""
@json.setter
def json(self, arg0: bool) -> None:
pass
@property
def enable_logging_and_signal_handling(self) -> bool:
"""
:type: bool
"""
@enable_logging_and_signal_handling.setter
def enable_logging_and_signal_handling(self, arg0: bool) -> None:
pass
pass
class Context: class Context:
class OutputParams: class OutputParams:
def __init__(self) -> None: ... def __init__(self) -> None: ...
@ -500,7 +521,7 @@ class Context:
pass pass
pass pass
def __init__(self) -> None: ... def __init__(self, options: ContextOptions = ContextOptions()) -> None: ...
def set_log_level(self, arg0: LogLevel) -> None: ... def set_log_level(self, arg0: LogLevel) -> None: ...
def set_verbosity(self, arg0: int) -> None: ... def set_verbosity(self, arg0: int) -> None: ...
@property @property
@ -966,7 +987,7 @@ class MatchSpec:
pass pass
class MultiPackageCache: class MultiPackageCache:
def __init__(self, arg0: typing.List[Path]) -> None: ... def __init__(self, context: Context, arg0: typing.List[Path]) -> None: ...
def get_tarball_path(self, arg0: PackageInfo, arg1: bool) -> Path: ... def get_tarball_path(self, arg0: PackageInfo, arg1: bool) -> Path: ...
@property @property
def first_writable_path(self) -> Path: def first_writable_path(self) -> Path:
@ -1579,7 +1600,7 @@ class SpecImpl(SpecBase):
class SubdirData: class SubdirData:
def cache_path(self) -> str: ... def cache_path(self) -> str: ...
def create_repo(self, arg0: Pool) -> Repo: ... def create_repo(self, context: Context, arg0: Pool) -> Repo: ...
def loaded(self) -> bool: ... def loaded(self) -> bool: ...
pass pass
@ -1590,6 +1611,7 @@ class SubdirIndex:
def __len__(self) -> int: ... def __len__(self) -> int: ...
def create( def create(
self, self,
context: Context,
arg0: Channel, arg0: Channel,
arg1: str, arg1: str,
arg2: str, arg2: str,
@ -1597,7 +1619,7 @@ class SubdirIndex:
arg4: str, arg4: str,
arg5: str, arg5: str,
) -> None: ... ) -> None: ...
def download(self) -> bool: ... def download(self, context: Context) -> bool: ...
pass pass
class SubdirIndexEntry: class SubdirIndexEntry:
@ -1669,10 +1691,10 @@ class ostream_redirect:
def cache_fn_url(arg0: str) -> str: def cache_fn_url(arg0: str) -> str:
pass pass
def cancel_json_output() -> None: def cancel_json_output(context: Context) -> None:
pass pass
def clean(arg0: int) -> None: def clean(context: Context, arg0: int) -> None:
pass pass
def create_cache_dir(arg0: Path) -> str: def create_cache_dir(arg0: Path) -> str:
@ -1684,7 +1706,7 @@ def generate_ed25519_keypair() -> typing.Tuple[str, str]:
def get_channels(arg0: typing.List[str]) -> typing.List[Channel]: def get_channels(arg0: typing.List[str]) -> typing.List[Channel]:
pass pass
def get_virtual_packages() -> typing.List[PackageInfo]: def get_virtual_packages(context: Context) -> typing.List[PackageInfo]:
pass pass
def init_console() -> None: def init_console() -> None:
@ -1697,6 +1719,7 @@ def simplify_conflicts(arg0: ProblemsGraph) -> ProblemsGraph:
pass pass
def transmute( def transmute(
context: Context,
source_package: Path, source_package: Path,
destination_package: Path, destination_package: Path,
compression_level: int, compression_level: int,

View File

@ -54,10 +54,29 @@ deprecated(std::string_view message, std::string_view since_version = "1.5")
namespace mambapy namespace mambapy
{ {
// When using this library we for now still need to have a few singletons available
// to avoid the python code to have to create 3-4 objects before starting to work with
// mamba functions. Instead, here, we associate the lifetime of all the necessary
// singletons to the lifetime of the Context. This is to provide to the user explicit
// control over the lifetime and construction options of the Context and library
// resources, preventing issues related to default configuration/options.
// In the future, we might remove all singletons and provide a simple way to start
// working with mamba, but the C++ side needs to be made 100% singleton-less first.
//
// In the code below we provide a mechanism to associate the lifetime of the
// necessary singletons to the lifetime of one Context instance and forbid more
// instances in the case of Python (it is theoretically allowed by the C++ api).
class Singletons class Singletons
{ {
public: public:
explicit Singletons(mamba::ContextOptions options)
: m_context(std::move(options))
{
}
mamba::MainExecutor& main_executor() mamba::MainExecutor& main_executor()
{ {
return m_main_executor; return m_main_executor;
@ -80,34 +99,32 @@ namespace mambapy
private: private:
template <class T, class D, class Factory>
T& init_once(std::unique_ptr<T, D>& ptr, Factory&& factory)
{
static std::once_flag init_flag;
std::call_once(init_flag, [&] { ptr = std::make_unique<T>(factory()); });
if (!ptr)
{
throw mamba::mamba_error(
fmt::format(
"attempt to use {} singleton instance after destruction",
typeid(T).name()
),
mamba::mamba_error_code::internal_failure
);
}
return *ptr;
}
mamba::MainExecutor m_main_executor; mamba::MainExecutor m_main_executor;
mamba::Context m_context{ { /* .enable_logging_and_signal_handling = */ true } }; mamba::Context m_context;
mamba::Console m_console{ m_context }; mamba::Console m_console{ m_context };
// ChannelContext needs to be lazy initialized, to ensure the Context has been initialized
// before
std::unique_ptr<mamba::ChannelContext> p_channel_context = nullptr;
mamba::Configuration m_config{ m_context }; mamba::Configuration m_config{ m_context };
}; };
Singletons singletons; std::unique_ptr<Singletons> current_singletons;
Singletons& singletons()
{
if (current_singletons == nullptr)
{
throw std::runtime_error("Context instance must be created first");
}
return *current_singletons;
}
struct destroy_singleton
{
template <class... Args>
void operator()(Args&&...) noexcept
{
current_singletons.reset();
}
};
// MSubdirData objects are movable only, and they need to be moved into // MSubdirData objects are movable only, and they need to be moved into
// a std::vector before we call MSudbirData::download. Since we cannot // a std::vector before we call MSudbirData::download. Since we cannot
@ -152,12 +169,11 @@ namespace mambapy
} }
} }
bool download() bool download(mamba::Context& ctx)
{ {
using namespace mamba; using namespace mamba;
// TODO: expose SubdirDataMonitor to libmambapy and remove this // TODO: expose SubdirDataMonitor to libmambapy and remove this
// logic // logic
Context& ctx = mambapy::singletons.context();
expected_t<void> download_res; expected_t<void> download_res;
if (SubdirDataMonitor::can_monitor(ctx)) if (SubdirDataMonitor::can_monitor(ctx))
{ {
@ -415,15 +431,19 @@ bind_submodule_impl(pybind11::module_ m)
); );
py::class_<MultiPackageCache>(m, "MultiPackageCache") py::class_<MultiPackageCache>(m, "MultiPackageCache")
.def(py::init<>( .def(
[](const std::vector<fs::u8path>& pkgs_dirs) py::init<>(
[](Context& context, const std::vector<fs::u8path>& pkgs_dirs)
{ {
return MultiPackageCache{ return MultiPackageCache{
pkgs_dirs, pkgs_dirs,
mambapy::singletons.context().validation_params, context.validation_params,
}; };
} }
)) ),
py::arg("context"),
py::arg("pkgs_dirs")
)
.def("get_tarball_path", &MultiPackageCache::get_tarball_path) .def("get_tarball_path", &MultiPackageCache::get_tarball_path)
.def_property_readonly("first_writable_path", &MultiPackageCache::first_writable_path); .def_property_readonly("first_writable_path", &MultiPackageCache::first_writable_path);
@ -490,11 +510,14 @@ bind_submodule_impl(pybind11::module_ m)
py::class_<SubdirData>(m, "SubdirData") py::class_<SubdirData>(m, "SubdirData")
.def( .def(
"create_repo", "create_repo",
[](SubdirData& subdir, solver::libsolv::Database& db) -> solver::libsolv::RepoInfo [](SubdirData& self, Context& context, solver::libsolv::Database& db
) -> solver::libsolv::RepoInfo
{ {
deprecated("Use libmambapy.load_subdir_in_database instead", "2.0"); deprecated("Use libmambapy.load_subdir_in_database instead", "2.0");
return extract(load_subdir_in_database(mambapy::singletons.context(), db, subdir)); return extract(load_subdir_in_database(context, db, self));
} },
py::arg("context"),
py::arg("db")
) )
.def("loaded", &SubdirData::is_loaded) .def("loaded", &SubdirData::is_loaded)
.def( .def(
@ -547,25 +570,17 @@ bind_submodule_impl(pybind11::module_ m)
.def( .def(
"create", "create",
[](SubdirIndex& self, [](SubdirIndex& self,
Context& context,
ChannelContext& channel_context, ChannelContext& channel_context,
const specs::Channel& channel, const specs::Channel& channel,
const std::string& platform, const std::string& platform,
const std::string& full_url, const std::string& full_url,
MultiPackageCache& caches, MultiPackageCache& caches,
const std::string& repodata_fn, const std::string& repodata_fn,
const std::string& url) const std::string& url) {
{ self.create(context, channel_context, channel, platform, full_url, caches, repodata_fn, url);
self.create(
mambapy::singletons.context(),
channel_context,
channel,
platform,
full_url,
caches,
repodata_fn,
url
);
}, },
py::arg("context"),
py::arg("channel_context"), py::arg("channel_context"),
py::arg("channel"), py::arg("channel"),
py::arg("platform"), py::arg("platform"),
@ -640,24 +655,35 @@ bind_submodule_impl(pybind11::module_ m)
.def_readwrite("no_progress_bars", &Context::GraphicsParams::no_progress_bars) .def_readwrite("no_progress_bars", &Context::GraphicsParams::no_progress_bars)
.def_readwrite("palette", &Context::GraphicsParams::palette); .def_readwrite("palette", &Context::GraphicsParams::palette);
py::class_<Context, std::unique_ptr<Context, py::nodelete>> ctx(m, "Context"); py::class_<ContextOptions>(m, "ContextOptions")
ctx // .def(
.def_static( py::init([](bool enable_logging_and_signal_handling = true)
// Still need a singleton as long as mambatest::singleton::context is used { return ContextOptions{ enable_logging_and_signal_handling }; }),
"instance", py::arg("enable_logging_and_signal_handling") = true
[]() -> auto& { return mambapy::singletons.context(); },
py::return_value_policy::reference
) )
.def(py::init( .def_readwrite(
// Deprecating would lead to confusing error. Better to make sure people stop using it. "enable_logging_and_signal_handling",
[]() -> std::unique_ptr<Context, py::nodelete> &ContextOptions::enable_logging_and_signal_handling
{
throw std::invalid_argument( //
"Context() will create a new Context object in the future.\n"
"Use Context.instance() to access the global singleton."
); );
// The lifetime of the unique Context instance will determine the lifetime of the other
// singletons.
using context_ptr = std::unique_ptr<Context, mambapy::destroy_singleton>;
auto context_constructor = [](ContextOptions options = {}) -> context_ptr
{
if (mambapy::current_singletons)
{
throw std::runtime_error("Only one Context instance can exist at any time");
} }
))
mambapy::current_singletons = std::make_unique<mambapy::Singletons>(options);
assert(&mambapy::singletons() == mambapy::current_singletons.get());
return context_ptr(&mambapy::singletons().context());
};
py::class_<Context, context_ptr> ctx(m, "Context");
ctx.def(py::init(context_constructor), py::arg("options") = ContextOptions{ true })
.def_static("use_default_signal_handler", &Context::use_default_signal_handler) .def_static("use_default_signal_handler", &Context::use_default_signal_handler)
.def_readwrite("graphics_params", &Context::graphics_params) .def_readwrite("graphics_params", &Context::graphics_params)
.def_readwrite("offline", &Context::offline) .def_readwrite("offline", &Context::offline)
@ -1144,18 +1170,25 @@ bind_submodule_impl(pybind11::module_ m)
py::arg("json_str") py::arg("json_str")
); );
m.def("clean", [](int flags) { return clean(mambapy::singletons.config(), flags); }); m.def(
"clean",
[](Context&, int flags) { return clean(mambapy::singletons().config(), flags); },
py::arg("context"),
py::arg("flags")
);
m.def( m.def(
"transmute", "transmute",
+[](const fs::u8path& pkg_file, const fs::u8path& target, int compression_level, int compression_threads +[](Context& context,
) const fs::u8path& pkg_file,
const fs::u8path& target,
int compression_level,
int compression_threads)
{ {
const auto extract_options = mamba::ExtractOptions::from_context( const auto extract_options = mamba::ExtractOptions::from_context(context);
mambapy::singletons.context()
);
return transmute(pkg_file, target, compression_level, compression_threads, extract_options); return transmute(pkg_file, target, compression_level, compression_threads, extract_options);
}, },
py::arg("context"),
py::arg("source_package"), py::arg("source_package"),
py::arg("destination_package"), py::arg("destination_package"),
py::arg("compression_level"), py::arg("compression_level"),
@ -1171,9 +1204,9 @@ bind_submodule_impl(pybind11::module_ m)
// py::arg("out_package"), py::arg("compression_level"), py::arg("compression_threads") = 1); // py::arg("out_package"), py::arg("compression_level"), py::arg("compression_threads") = 1);
m.def("get_virtual_packages", [] { return get_virtual_packages(mambapy::singletons.context()); }); m.def("get_virtual_packages", [](Context& context) { return get_virtual_packages(context); });
m.def("cancel_json_output", [] { Console::instance().cancel_json_print(); }); m.def("cancel_json_output", [](Context&) { mambapy::singletons().console().cancel_json_print(); });
// CLEAN FLAGS // CLEAN FLAGS
m.attr("MAMBA_CLEAN_ALL") = MAMBA_CLEAN_ALL; m.attr("MAMBA_CLEAN_ALL") = MAMBA_CLEAN_ALL;

View File

@ -1,14 +1,13 @@
import libmambapy import libmambapy
def test_context_singleton(): def test_context_instance_scoped():
libmambapy.Context.instance().platform = "mambaos-64" ctx = libmambapy.Context() # Initialize and then terminate libmamba internals
ctx = libmambapy.Context.instance() return ctx
assert ctx.platform == "mambaos-64"
def test_channel_context(): def test_channel_context():
ctx = libmambapy.Context.instance() ctx = libmambapy.Context()
cc = libmambapy.ChannelContext.make_conda_compatible(ctx) cc = libmambapy.ChannelContext.make_conda_compatible(ctx)
assert cc.make_channel("pkgs/main")[0].url.str() == "https://repo.anaconda.com/pkgs/main" assert cc.make_channel("pkgs/main")[0].url.str() == "https://repo.anaconda.com/pkgs/main"