Skip to content

Conversation

@orlitzky
Copy link
Contributor

@orlitzky orlitzky commented Jan 28, 2026

Implement Option 3 from #41067.

  1. Add a new meson option defer_feature_checks that defaults to false in meson, and true in the sage distro (for backwards compatibility). Record its value in sage/config.py.
  2. For all of the existing features in meson.options, define variables in sage/config.py stating whether or not they were enabled in the build.
  3. Add a new sage.features.build_feature.BuildFeature class to handle the processing.
  4. Subclass the new BuildFeature in the sage features for things in meson.options.
  5. Add missing features for eclib, mwrank, and rankwidth.
  6. Don't spam build-only features in the output from sage -t.

All permutations of the options should work, but for an example using the meson build, meson setup -Dauto_features=disabled ... will disable everything at build-time, and not check for it again at run-time. You should be able to see the values foo_enabled = 0 in sage/config.py. The feature objects (BuildFeature subclasses) will consult these variables to determine if the feature is enabled.

An example of a feature that can be detected at runtime is the mwrank executable. In the current scenario, its BuildFeature checks sage.config.eclib_enabled, so even if eclib is installed, the mwank executable will not be used. However, if you rebuild with meson setup -Ddefer_feature_checks=true ..., then mwrank will be detected at run-time.

The other features can also be detected at run-time, but they are linked to various python modules at build time. To test the runtime detection, one option is to uninstall the library that the feature uses (say, sirocco). This will cause the import test of sage.libs.sirocco to fail at runtime, leading to the feature being disabled. Reinstall sirocco and the feature should re-enable itself.

Relevant docs:

Following the discussion on,

  sagemath#41067

we add an option to defer (forthcoming) build-time feature checks to
runtime.
Record the value of the "defer_feature_checks" meson option in the
sage config file, for use by the sage/features subsystem.
@github-actions
Copy link

github-actions bot commented Jan 28, 2026

Documentation preview for this PR (built with commit c90afeb; changes) is ready! 🎉
This preview will update shortly after each push to this PR.

@orlitzky orlitzky force-pushed the build-time-features branch 2 times, most recently from 2b669f7 to d045aee Compare January 29, 2026 14:23
@orlitzky orlitzky force-pushed the build-time-features branch from d045aee to 5b776af Compare January 29, 2026 17:46
Implement Option 3 from the dissussion at

  sagemath#41067

with a new BuildFeature class. Features that can be detected at
build-time should

  1. Subclass this
  2. Set self._enabled_in_build to a value from sage.config that
     says whether or not the feature was enabled at build time.
  3. Implement is_present_at_runtime() if it makes sense to
     do so. (Without this, deferring the check to run-time
     won't help.)
Add foo_enabled variables for the existing features defined in
meson.options that are detectable at build-time.
For all of the build-time features defined in meson.options, record
whether or not the feature (and its dependencies) were found by meson.
The associated config variables were added to src/sage/config.py.in in
an earlier commit; here we just substitute the correct values.
@orlitzky orlitzky force-pushed the build-time-features branch from 5b776af to f93d1a6 Compare January 30, 2026 17:52
The meson build system is now capable of building sagelib without
linking to libec, which means that the mwrank program may not be
installed. Here we add a new feature to represent it. In particular
this allows us to use "needs mwrank" in tests.
@orlitzky orlitzky force-pushed the build-time-features branch from f93d1a6 to cc97f87 Compare January 30, 2026 20:15
The meson build system is now capable of building sagelib without
sage.libs.eclib. Here we add a new feature to represent it. In
particular this allows us to use "needs sage.libs.eclib" in tests.
@orlitzky
Copy link
Contributor Author

The distro CI is hosed, but I think this works now and there are no major problems with the docs. Please play around and let me know your thoughts. The implementation was straightforward so far, but I haven't tried to convert any other runtime features yet, only the ones with existing meson options/checks.

@antonio-rojas
Copy link
Contributor

antonio-rojas commented Jan 30, 2026

I would expect defer_feature_checks=true to keep the current status quo, that is, runtime detection of all optional features, including linked ones. Otherwise this is an important regression for my packaging use case: I build all optional features and then users can optionally enable or disable them at runtime.

@antonio-rojas
Copy link
Contributor

This doesn't look right:

sage: from sage.features.mwrank import Mwrank
sage: Mwrank().is_present()
---------------------------------------------------------------------------
RecursionError                            Traceback (most recent call last)
Cell In[2], line 1
----> 1 Mwrank().is_present()

File /usr/lib/python3.14/site-packages/sage/features/__init__.py:211, in Feature.is_present(self)
    208 # We do not use @cached_method here because we wish to use
    209 # Feature early in the build system of sagelib.
    210 if self._cache_is_present is None:
--> 211     res = self._is_present()
    212     if not isinstance(res, FeatureTestResult):
    213         res = FeatureTestResult(self, res)

File /usr/lib/python3.14/site-packages/sage/features/build_feature.py:130, in BuildFeature._is_present(self)
    108 r"""
    109 Default presence check for build features.
    110 
   (...)    127 
    128 """
    129 if self.is_runtime_detectable():
--> 130     return self.is_present_at_runtime()
    131 else:
    132     import sage.config

File /usr/lib/python3.14/site-packages/sage/features/mwrank.py:39, in Mwrank.is_present_at_runtime(self)
     38 def is_present_at_runtime(self):
---> 39     return Executable.is_present(self)

File /usr/lib/python3.14/site-packages/sage/features/__init__.py:211, in Feature.is_present(self)
    208 # We do not use @cached_method here because we wish to use
    209 # Feature early in the build system of sagelib.
    210 if self._cache_is_present is None:
--> 211     res = self._is_present()
    212     if not isinstance(res, FeatureTestResult):
    213         res = FeatureTestResult(self, res)

File /usr/lib/python3.14/site-packages/sage/features/build_feature.py:130, in BuildFeature._is_present(self)
    108 r"""
    109 Default presence check for build features.
    110 
   (...)    127 
    128 """
    129 if self.is_runtime_detectable():
--> 130     return self.is_present_at_runtime()
    131 else:
    132     import sage.config

File /usr/lib/python3.14/site-packages/sage/features/mwrank.py:39, in Mwrank.is_present_at_runtime(self)
     38 def is_present_at_runtime(self):
---> 39     return Executable.is_present(self)

    [... skipping similar frames: Feature.is_present at line 211 (991 times), BuildFeature._is_present at line 130 (990 times), Mwrank.is_present_at_runtime at line 39 (990 times)]

File /usr/lib/python3.14/site-packages/sage/features/build_feature.py:130, in BuildFeature._is_present(self)
    108 r"""
    109 Default presence check for build features.
    110 
   (...)    127 
    128 """
    129 if self.is_runtime_detectable():
--> 130     return self.is_present_at_runtime()
    131 else:
    132     import sage.config

File /usr/lib/python3.14/site-packages/sage/features/mwrank.py:39, in Mwrank.is_present_at_runtime(self)
     38 def is_present_at_runtime(self):
---> 39     return Executable.is_present(self)

File /usr/lib/python3.14/site-packages/sage/features/__init__.py:211, in Feature.is_present(self)
    208 # We do not use @cached_method here because we wish to use
    209 # Feature early in the build system of sagelib.
    210 if self._cache_is_present is None:
--> 211     res = self._is_present()
    212     if not isinstance(res, FeatureTestResult):
    213         res = FeatureTestResult(self, res)

File /usr/lib/python3.14/site-packages/sage/features/build_feature.py:129, in BuildFeature._is_present(self)
    107 def _is_present(self):
    108     r"""
    109     Default presence check for build features.
    110 
   (...)    127 
    128     """
--> 129     if self.is_runtime_detectable():
    130         return self.is_present_at_runtime()
    131     else:

RecursionError: maximum recursion depth exceeded

@orlitzky
Copy link
Contributor Author

I would expect defer_feature_checks=true to keep the current status quo, that is, runtime detection of all optional features, including linked ones. Otherwise this is an important regression for my packaging use case: I build all optional features and then users can optionally enable or disable them at runtime.

No problem, I didn't realize anyone was doing this yet. How is it implemented, are you packaging the optional python modules (like sage.libs.sirocco) separately? If so I'll put back the python module check as is_present_at_runtime() and that should be enough.

This doesn't look right:

Ugh, no, I'll take a look. I have a feeling that this was caused by changing from . import Executable to from sage.features import Executable, which should be a no-op, but was needed to fix a heisenbug metaclass TypeError in pytest.

@antonio-rojas
Copy link
Contributor

I would expect defer_feature_checks=true to keep the current status quo, that is, runtime detection of all optional features, including linked ones. Otherwise this is an important regression for my packaging use case: I build all optional features and then users can optionally enable or disable them at runtime.

No problem, I didn't realize anyone was doing this yet. How is it implemented, are you packaging the optional python modules (like sage.libs.sirocco) separately? If so I'll put back the python module check as is_present_at_runtime() and that should be enough.

For now I'm shipping everything together (and the features are enabled by installing the corresponding libraries), but I may move to split packages soon as our tooling is moving towards automatic detection of link libraries at packaging time. Since sage Features check for the modules loadability (as opposed to their presence), this works fine either way (currently).

A trick is needed to dynamically add a method to an object.
Call Executable._is_present() instead of Executable.is_present() to
avoid infinite recursion. Somehow this worked before I replaced the
relative import of Executable with an absolute one.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The result of _is_present() is cached and we want to check for a
module with a different name than that of the feature, so we create a
new PythonModule on the fly for this.
The module name that we check for in this case is the same as the
feature name, so we can subclass the feature from PythonModule
and use PythonModule's _is_present() in is_present_at_runtime().
@orlitzky
Copy link
Contributor Author

Ok, runtime detection probably works. Tested with the ones I happen to have installed (bliss, brial, eclib, rankwidth).

@orlitzky
Copy link
Contributor Author

(And the Mrwank bug is fixed.)

@antonio-rojas
Copy link
Contributor

Thanks. Besides the coxeter issue, everything works fine here with defer_feature_checks=true (as in, same as before)

To detect coxeter3 at runtime, we should check for a module that won't
be present when coxeter3 support is disabled. This feature now checks
for a conditionally-compiled cython module beneath sage.libs.coxeter3,
rather than for sage.libs.coxeter3 itself.
@orlitzky
Copy link
Contributor Author

orlitzky commented Jan 31, 2026 via email

Comment on lines +58 to +61
# The build system installs the sage/libs/coxeter3 source code
# even when coxeter3 support is disabled, so a naive import of
# that module will actually succeed. We check for a
# conditionally-compiled cython module instead.
Copy link
Contributor

Choose a reason for hiding this comment

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

@antonio-rojas @orlitzky I probably misunderstood something in your discussion, but if you check here for a cython module that may be disabled by the coexeter dependency (in meson) - then why not simply embed via meson the value of coxeter.is_found here?


I was hoping we could replace the needs sage.libs.coexeter3 annotations by simply coexeter3 (as the cython module is only a proxy anyway, and it shouldn't really matter for the feature if it's a runtime or compile time dependency that is missing).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's the is_present_at_runtime() method and is only used if you build sage with -Ddefer_feature_checks=true. Otherwise, the "found" value from meson is used.

@antonio-rojas will build sage with all of the features enabled at build time but with feature checks deferred. (This essentially ignores the values recorded in the config file.) The resulting distro package won't have a hard dependency on coxeter3... if the user installs it, the deferred import sage.libs.coxeter3.coxeter3 will work; otherwise, it will just crash and the feature will be turned off at runtime. So even though it's a build requirement from the maintainer's perspective, the fact that the damage is limited to one compiled module lets us test for it at runtime with an import. But again, this is only for people who request it (and for the sage distro, for backwards compatibility).

This is controlled with needs coxeter3 by the way. There are others where the feature name includes the module name like eclib and brial, but there's no technical reason for that. Going back over my comments on #41042, it looks like I chose needs sage.libs.eclib to reflect what the feature is actually looking for. Though now that we are literally using the value of eclib.found() from the build system, that's less of a selling point.

from sage.features.build_feature import BuildFeature


class Mwrank(BuildFeature, Executable):
Copy link
Contributor

Choose a reason for hiding this comment

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

Meson will now check for the presence of the mwrank executable in a certain list of paths and then enables/disables the feature here. But the Exectuable feature uses a different search algorithm and thus may not actually found the exe that meson was able to find (say you change PATH temporarily for building sage, but not for running it).

In the case of exes it would probably make more sense to embed the full path via meson, instead of using only a simple boolean toggle. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought of this at the time but meson doesn't actually check for mwrank yet. It should be easy to add later on with a subclass that returns getattr(sage.config, self.name + '_path') for the path.

Comment on lines +42 to +44
``foo_enabled`` is written to ``sage.config``. In your subclass,
you should set the member variable ``_enabled_in_build`` to the
value of that config variable.
Copy link
Contributor

Choose a reason for hiding this comment

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

Just as an idea in the spirit of convention-over-configuration: you could query in _is_present below always the variable <name of feature>_enabled from sage.config. Then you don't have to set _enabled_in_build for each feature.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did this at first and talked myself out of it! With the current implementation, the downside to doing it by convention is that when the convention fails (e.g. mwrank, brial), you have to override the whole _is_present() method leading to duplicated code.

Given that you have to create a whole new subclass for every new feature and give it a name anyway, just spelling out the variable name in one line felt simpler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants