Skip to content

Explicit build-time features#41550

Open
orlitzky wants to merge 36 commits intosagemath:developfrom
orlitzky:build-time-features
Open

Explicit build-time features#41550
orlitzky wants to merge 36 commits intosagemath:developfrom
orlitzky:build-time-features

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:

@github-actions
Copy link

github-actions bot commented Jan 28, 2026

Documentation preview for this PR (built with commit bead86a; 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
@orlitzky orlitzky force-pushed the build-time-features branch 3 times, most recently from cc97f87 to fba22ba Compare January 30, 2026 20:19
@orlitzky orlitzky marked this pull request as ready for review January 30, 2026 20:20
@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).

@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)

@orlitzky
Copy link
Contributor Author

orlitzky commented Jan 31, 2026 via email

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.
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.
When a feature is not runtime-detectable, don't include it in the list
of features to be detected. This keeps the list,

  $ sage -t src
  ...
  Features to be detected: 32_bit,4ti2,benzene,buckygen, ...
  ...
  Features detected for doctesting: database_ellcurves, ...

free of misleading information and clutter.
If the feature for sage.libs.foo is enabled, we shouldn't skip over
files that fail to collect with an ImportError for sage.libs.foo.
When sage.libs.eclib doesn't exist, pytest will still try to collect
*.py files that import it, leading to an ImportError. We add eclib to
the list of feature-backed modules with workarounds for this issue.
For backwards compatibility, defer all possible build-time feature
checks to run-time. (For now this only affects the detection of the
mwrank program.)
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().
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.
Add the "needs rankwidth" tags for the doctests in this module, all of
which need rankwidth. At first a one-line magic sage.doctest header
appears to make more sense, but pytest does not understand these and
needs workarounds (see conftest.py). Given the small number of tests
in this module, individual annotations seem like less of a hassle.
@orlitzky orlitzky force-pushed the build-time-features branch from a947492 to bead86a Compare February 20, 2026 13:12
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.

4 participants