conda-features 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. conda_features/__init__.py +75 -0
  2. conda_features/cli/__init__.py +1 -0
  3. conda_features/cli/apply.py +26 -0
  4. conda_features/cli/cleanup.py +16 -0
  5. conda_features/cli/commands.py +35 -0
  6. conda_features/cli/common.py +90 -0
  7. conda_features/cli/configure.py +22 -0
  8. conda_features/cli/diagnose.py +13 -0
  9. conda_features/cli/disable.py +44 -0
  10. conda_features/cli/enable.py +22 -0
  11. conda_features/cli/explain.py +11 -0
  12. conda_features/cli/export.py +26 -0
  13. conda_features/cli/list.py +14 -0
  14. conda_features/cli/migrate.py +16 -0
  15. conda_features/cli/output.py +74 -0
  16. conda_features/cli/parser.py +50 -0
  17. conda_features/cli/repair.py +16 -0
  18. conda_features/cli/targets.py +28 -0
  19. conda_features/hooks.py +22 -0
  20. conda_features/models.py +291 -0
  21. conda_features/operations/__init__.py +1 -0
  22. conda_features/operations/configuration.py +193 -0
  23. conda_features/operations/dependencies.py +256 -0
  24. conda_features/operations/diagnostics.py +113 -0
  25. conda_features/operations/discovery.py +45 -0
  26. conda_features/operations/doctor.py +64 -0
  27. conda_features/operations/intent.py +250 -0
  28. conda_features/operations/lifecycle.py +146 -0
  29. conda_features/operations/settings.py +101 -0
  30. conda_features/operations/validation.py +120 -0
  31. conda_features/plugin.py +51 -0
  32. conda_features/steps/__init__.py +7 -0
  33. conda_features/steps/base.py +41 -0
  34. conda_features/steps/block.py +98 -0
  35. conda_features/steps/configuration.py +222 -0
  36. conda_features/steps/packages.py +107 -0
  37. conda_features/steps/runner.py +162 -0
  38. conda_features/steps/storage.py +85 -0
  39. conda_features-0.1.0.dist-info/METADATA +84 -0
  40. conda_features-0.1.0.dist-info/RECORD +43 -0
  41. conda_features-0.1.0.dist-info/WHEEL +4 -0
  42. conda_features-0.1.0.dist-info/entry_points.txt +2 -0
  43. conda_features-0.1.0.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,75 @@
1
+ """Feature management for conda.
2
+
3
+ Install this distribution in the Python environment running conda. During
4
+ development, ``pixi run python -m conda feature --help`` uses that environment.
5
+
6
+ User commands::
7
+
8
+ conda feature list
9
+ conda feature explain sample
10
+ conda feature enable sample --dry-run
11
+ conda feature enable sample --yes
12
+ conda feature configure sample plugins.sample_limit=4 --yes
13
+ conda feature disable sample --yes
14
+ conda feature disable --all --yes
15
+
16
+ Use ``--prefix PATH`` for an existing environment, ``--system`` for the conda
17
+ installation, or ``--user`` (the default). Providers declare supported scopes.
18
+ ``--file PATH`` selects a standalone user-scope file, with environment-variable
19
+ overrides still applied. Use a conda-recognized name (``.condarc``, ``condarc``,
20
+ or a YAML extension). Its parent directory must exist. Other scopes use
21
+ conda's normal configuration search path. ``--json`` produces one JSON result.
22
+ Exit codes are 0 for success or preview, 1 for an operation error, and 2 for
23
+ argument parsing errors. Noninteractive mutations require ``--yes``.
24
+
25
+ Providers register their settings with conda's ``conda_settings`` hook and
26
+ yield ``conda_features.models.Feature`` objects from a Pluggy hook. Register
27
+ the provider module in their pyproject.toml::
28
+
29
+ [project.entry-points.conda_features]
30
+ my-provider = "my_provider.features"
31
+
32
+ An example declaration::
33
+
34
+ from conda_features.models import Feature
35
+ from conda_features.hooks import hookimpl
36
+
37
+ @hookimpl
38
+ def conda_features():
39
+ yield Feature(
40
+ name="sample",
41
+ description="Enable the provider's optional behavior",
42
+ enabled_setting="plugins.sample_enabled",
43
+ settings=("plugins.sample_limit",),
44
+ )
45
+
46
+ The provider reads its boolean setting to control behavior. This plugin does
47
+ not intercept provider execution. Declarations must be cheap to import and
48
+ must not access the network, credentials, or files. Installed providers are
49
+ trusted Python code, not sandboxed data. Only expose non-secret settings.
50
+ Required settings check for missing values, not login or service availability.
51
+ Status reports the selected file's saved choice separately from the effective
52
+ boolean setting and readiness. Runtime activity is unknown, even when a setting
53
+ is enabled and a prerequisite is blocked. Providers enforce their own checks.
54
+
55
+ Enable refuses unmet dependencies instead of implicitly opting into them.
56
+ Disable preserves other settings and installed packages. Changes take effect
57
+ on the next invocation. Repeating a persisted operation requires no write.
58
+ An explicit disable is saved even if a temporary override already disables it.
59
+ Writes preserve comments and unrelated settings, use atomic file replacement,
60
+ and reject previews whose destination changed. A sidecar lock coordinates this
61
+ plugin's writers, but external editors do not honor it.
62
+
63
+ Deployment providers can omit enabled_setting and declare steps such as
64
+ InstallPackages, SetSetting, and EnsureChannel. Feature coordinates their
65
+ planning, application, reverse-order disable, and recovery receipts.
66
+ Provider-specific steps implement Step or ManagedBlock. Plans carry public
67
+ changes and private apply data. The CLI confirms before applying. The separate
68
+ conda-features-examples package deploys real PyPI support and shell completion,
69
+ including installation of missing prerequisites. Bulk disable handles boolean
70
+ features and identifies deployments that need individual disable commands.
71
+
72
+ Without an installed provider, list reports an empty catalog. Authentication,
73
+ cross-environment dependency scanning, telemetry, migrations, export, and
74
+ doctor integration are not implemented.
75
+ """
@@ -0,0 +1 @@
1
+ """Command-line parsing, dispatch, and rendering."""
@@ -0,0 +1,26 @@
1
+ """Apply a portable intent document to an explicitly selected destination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def configure_parser(parser):
10
+ parser.add_argument("manifest", type=Path)
11
+
12
+
13
+ def execute(args, catalog, ctx, path, scope):
14
+ from ..operations.intent import IntentError, plan_intent
15
+ from .common import confirm_and_apply
16
+
17
+ if not (args.user or args.system or args.prefix or args.file):
18
+ raise IntentError(
19
+ "Applying intent requires an explicit --user, --system, --prefix, or --file destination."
20
+ )
21
+ try:
22
+ document = json.loads(args.manifest.read_text())
23
+ except (OSError, ValueError) as exc:
24
+ raise IntentError("Cannot read feature intent JSON.") from exc
25
+ plan = plan_intent(document, catalog, ctx, path, scope)
26
+ return confirm_and_apply(args, catalog, ctx, plan)
@@ -0,0 +1,16 @@
1
+ """Remove obsolete opt-ins for unconditional graduated features."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def configure_parser(parser):
7
+ parser.add_argument("feature")
8
+
9
+
10
+ def execute(args, catalog, ctx, path, scope):
11
+ from .common import confirm_and_apply, select_feature
12
+
13
+ feature = select_feature(catalog, args.feature)
14
+ return confirm_and_apply(
15
+ args, catalog, ctx, feature.cleanup(catalog, ctx, path, scope)
16
+ )
@@ -0,0 +1,35 @@
1
+ """Load the selected subcommand and render its result."""
2
+
3
+ import json
4
+ import sys
5
+ from argparse import Namespace
6
+ from importlib import import_module
7
+
8
+ from conda import CondaError
9
+ from conda.base.context import context
10
+
11
+ from ..operations.discovery import discover
12
+ from ..operations.intent import IntentError
13
+ from ..operations.validation import validate_catalog
14
+ from .output import render
15
+ from .targets import target
16
+
17
+
18
+ def execute(args: Namespace) -> int:
19
+ try:
20
+ catalog = discover()
21
+ path, scope, ctx = target(args)
22
+ validate_catalog(catalog, ctx)
23
+ command = import_module(f".{args.operation}", package=__package__)
24
+ result = command.execute(args, catalog, ctx, path, scope)
25
+ except (CondaError, OSError, ValueError, TypeError, OverflowError) as exc:
26
+ message = str(exc)
27
+ if args.operation in ("export", "apply") and not isinstance(exc, IntentError):
28
+ message = "Intent operation failed. Inspect the local configuration and providers. Review with --dry-run and pass --yes only after approving the changes. Configuration values are omitted from this error."
29
+ if context.json:
30
+ print(json.dumps({"error": message}))
31
+ else:
32
+ print(message, file=sys.stderr)
33
+ return 1
34
+ render(result)
35
+ return 0
@@ -0,0 +1,90 @@
1
+ """Shared feature selection and confirmed configuration writes."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from conda import CondaError
7
+ from conda.base.context import context
8
+ from conda.common.configuration import YamlRawParameter
9
+
10
+ from ..models import Catalog, Feature
11
+ from ..operations.configuration import apply_change
12
+ from ..operations.dependencies import guard_plan, projected_context, verify_guard
13
+ from ..operations.settings import fingerprint
14
+ from .targets import target
15
+
16
+
17
+ def select_feature(catalog: Catalog, name: str) -> Feature:
18
+ if name not in catalog:
19
+ raise CondaError(f"Unknown feature: {name}")
20
+ return catalog[name][1]
21
+
22
+
23
+ def confirm_and_apply(args, catalog, ctx, plan):
24
+ guard_plan(args, catalog, plan)
25
+ result = {
26
+ key: plan[key]
27
+ for key in (
28
+ "operation",
29
+ "features",
30
+ "scope",
31
+ "path",
32
+ "changes",
33
+ "checked_environments",
34
+ "discovery",
35
+ "remove",
36
+ )
37
+ if key in plan
38
+ }
39
+ if args.dry_run:
40
+ result["message"] = "Preview only. No changes made."
41
+ elif not plan["changes"]:
42
+ result["message"] = "No changes needed."
43
+ else:
44
+ if not context.always_yes:
45
+ if context.json or not sys.stdin.isatty():
46
+ raise CondaError(
47
+ "Review with --dry-run, then pass --yes to apply noninteractively."
48
+ )
49
+ print(f"Configuration: {plan['path']} ({plan['scope']})", file=sys.stderr)
50
+ if plan["checked_environments"]:
51
+ print(
52
+ "Checked environments: " + ", ".join(plan["checked_environments"]),
53
+ file=sys.stderr,
54
+ )
55
+ print(plan["discovery"], file=sys.stderr)
56
+ for key, new_value in plan["changes"].items():
57
+ print(f" {key} = {new_value}", file=sys.stderr)
58
+ print("Proceed? [y/N] ", end="", file=sys.stderr, flush=True)
59
+ if sys.stdin.readline().strip().lower() not in ("y", "yes"):
60
+ raise CondaError("Cancelled. No changes made.")
61
+ verify_guard(plan)
62
+ if "plans" in plan:
63
+ current = ctx
64
+ expected = plan["fingerprint"]
65
+ for child in plan["plans"]:
66
+ if fingerprint(Path(child["path"])) != expected:
67
+ raise CondaError(
68
+ "Configuration changed after preview. Review the plan again."
69
+ )
70
+ child["fingerprint"] = expected
71
+ next_context = projected_context(child, current)
72
+ catalog[child["features"][-1]][1].apply(child, current)
73
+ expected = fingerprint(Path(child["path"]))
74
+ current = next_context
75
+ elif len(plan["features"]) == 1:
76
+ catalog[plan["features"][0]][1].apply(plan, ctx)
77
+ else:
78
+ apply_change(plan, ctx)
79
+ YamlRawParameter.cache_clear()
80
+ _, _, updated = target(args)
81
+ result["status"] = [
82
+ catalog[name][1].status(
83
+ catalog, updated, plan["scope"], path=Path(plan["path"])
84
+ )
85
+ for name in plan["features"]
86
+ ]
87
+ result["message"] = (
88
+ "Configuration updated. Takes effect in the next conda invocation."
89
+ )
90
+ return result
@@ -0,0 +1,22 @@
1
+ """Validate and change a feature's declared settings."""
2
+
3
+ from conda import CondaError
4
+
5
+
6
+ def configure_parser(parser):
7
+ parser.add_argument("feature")
8
+ parser.add_argument("settings", nargs="+", metavar="KEY=VALUE")
9
+
10
+
11
+ def execute(args, catalog, ctx, path, scope):
12
+ from .common import confirm_and_apply, select_feature
13
+
14
+ feature = select_feature(catalog, args.feature)
15
+ settings = {}
16
+ for item in args.settings:
17
+ key, separator, new_value = item.partition("=")
18
+ if not separator or not key or key in settings:
19
+ raise CondaError("Provide distinct declared settings as KEY=VALUE.")
20
+ settings[key] = new_value
21
+ plan = feature.configure(catalog, ctx, path, scope, settings)
22
+ return confirm_and_apply(args, catalog, ctx, plan)
@@ -0,0 +1,13 @@
1
+ """Inspect local feature readiness and retained deployment receipts."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def configure_parser(parser):
7
+ pass
8
+
9
+
10
+ def execute(args, catalog, ctx, path, scope):
11
+ from ..operations.diagnostics import diagnose
12
+
13
+ return diagnose(catalog, ctx, path, scope)
@@ -0,0 +1,44 @@
1
+ """Preview and disable one feature or all features at a scope."""
2
+
3
+
4
+ def configure_parser(parser):
5
+ parser.add_argument("feature", nargs="?")
6
+ parser.add_argument(
7
+ "--all",
8
+ action="store_true",
9
+ help="Disable all declared features at this scope.",
10
+ )
11
+
12
+
13
+ def execute(args, catalog, ctx, path, scope):
14
+ from ..operations.configuration import plan_change
15
+ from .common import confirm_and_apply, select_feature
16
+
17
+ if args.all:
18
+ deployments = [
19
+ name
20
+ for name, (_, feature) in catalog.items()
21
+ if feature.enabled_setting is None and scope in feature.scopes
22
+ ]
23
+ boolean_catalog = {
24
+ name: item
25
+ for name, item in catalog.items()
26
+ if item[1].enabled_setting is not None
27
+ }
28
+ plan = plan_change(
29
+ boolean_catalog,
30
+ ctx,
31
+ path,
32
+ scope,
33
+ "disable",
34
+ args.feature,
35
+ all_features=True,
36
+ )
37
+ else:
38
+ plan = select_feature(catalog, args.feature).disable(catalog, ctx, path, scope)
39
+ result = confirm_and_apply(args, catalog, ctx, plan)
40
+ if args.all and deployments:
41
+ result["message"] += (
42
+ " Disable deployment features individually: " + ", ".join(deployments) + "."
43
+ )
44
+ return result
@@ -0,0 +1,22 @@
1
+ """Preview and enable an installed feature."""
2
+
3
+
4
+ def configure_parser(parser):
5
+ parser.add_argument("feature")
6
+ parser.add_argument(
7
+ "--with-dependencies",
8
+ action="store_true",
9
+ help="Preview prerequisite opt-ins at the selected scope.",
10
+ )
11
+
12
+
13
+ def execute(args, catalog, ctx, path, scope):
14
+ from .common import confirm_and_apply, select_feature
15
+
16
+ feature = select_feature(catalog, args.feature)
17
+ plan = (
18
+ feature.enable(catalog, ctx, path, scope, with_dependencies=True)
19
+ if args.with_dependencies
20
+ else feature.enable(catalog, ctx, path, scope)
21
+ )
22
+ return confirm_and_apply(args, catalog, ctx, plan)
@@ -0,0 +1,11 @@
1
+ """Explain a feature's configured state and prerequisites."""
2
+
3
+
4
+ def configure_parser(parser):
5
+ parser.add_argument("feature")
6
+
7
+
8
+ def execute(args, catalog, ctx, path, scope):
9
+ from .common import select_feature
10
+
11
+ return select_feature(catalog, args.feature).status(catalog, ctx, scope, path=path)
@@ -0,0 +1,26 @@
1
+ """Export portable choices as one versioned JSON document."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def configure_parser(parser):
7
+ parser.add_argument(
8
+ "features",
9
+ nargs="*",
10
+ help="Feature IDs to export. Defaults to all saved choices at this scope.",
11
+ )
12
+
13
+
14
+ def execute(args, catalog, ctx, path, scope):
15
+ from .common import select_feature
16
+
17
+ names = args.features or list(catalog)
18
+ entries = [
19
+ select_feature(catalog, name).export_intent(catalog, ctx, path, scope)
20
+ for name in names
21
+ ]
22
+ return {
23
+ "version": 1,
24
+ "scope": scope,
25
+ "features": [entry for entry in entries if entry is not None],
26
+ }
@@ -0,0 +1,14 @@
1
+ """List installed feature declarations and their state."""
2
+
3
+
4
+ def configure_parser(parser):
5
+ pass
6
+
7
+
8
+ def execute(args, catalog, ctx, path, scope):
9
+ return {
10
+ "features": [
11
+ feature.status(catalog, ctx, scope, path=path)
12
+ for _, feature in catalog.values()
13
+ ]
14
+ }
@@ -0,0 +1,16 @@
1
+ """Preview migration to a provider-declared replacement."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def configure_parser(parser):
7
+ parser.add_argument("feature")
8
+
9
+
10
+ def execute(args, catalog, ctx, path, scope):
11
+ from .common import confirm_and_apply, select_feature
12
+
13
+ feature = select_feature(catalog, args.feature)
14
+ return confirm_and_apply(
15
+ args, catalog, ctx, feature.migrate(catalog, ctx, path, scope)
16
+ )
@@ -0,0 +1,74 @@
1
+ """Human-readable and JSON command output."""
2
+
3
+ import json
4
+
5
+ from conda.base.context import context
6
+
7
+
8
+ def render(result: dict) -> None:
9
+ if context.json or (
10
+ result.get("version") == 1 and "features" in result and "scope" in result
11
+ ):
12
+ print(json.dumps(result, default=str))
13
+ elif "problems" in result:
14
+ for problem in result["problems"]:
15
+ print(f"{problem['feature']}: {problem['message']} {problem['action']}")
16
+ if not result["problems"]:
17
+ print("No local feature readiness problems found.")
18
+ print(result["message"])
19
+ elif "changes" in result:
20
+ print(f"Configuration: {result['path']} ({result['scope']})")
21
+ if "checked_environments" in result:
22
+ print(
23
+ "Checked environments: "
24
+ + (", ".join(result["checked_environments"]) or "none")
25
+ )
26
+ print(result["discovery"])
27
+ for key, new_value in result["changes"].items():
28
+ print(f" {key} = {new_value}")
29
+ print(result["message"])
30
+ elif isinstance(result.get("features"), list):
31
+ if not result["features"]:
32
+ print("No feature providers installed.")
33
+ else:
34
+ print(
35
+ "Feature Maturity Requested Effective setting Readiness Description"
36
+ )
37
+ for feature in result["features"]:
38
+ print(
39
+ " ".join(
40
+ str(feature[key])
41
+ for key in (
42
+ "name",
43
+ "maturity",
44
+ "requested",
45
+ "effective",
46
+ "readiness",
47
+ "description",
48
+ )
49
+ )
50
+ )
51
+ else:
52
+ print(f"{result['name']}: {result['description']}")
53
+ for key in ("provider", "maturity", "requested", "readiness"):
54
+ print(f"{key.capitalize()}: {result[key]}")
55
+ print(f"Effective setting: {result['effective']}")
56
+ print("Runtime: unknown (provider execution is not observed)")
57
+ if result.get("lifecycle") not in (None, "available"):
58
+ print(f"Lifecycle: {result['lifecycle']}")
59
+ if result.get("replacement"):
60
+ print(
61
+ f"Replacement: {result['replacement']}. Use feature migrate to preview the change."
62
+ )
63
+ if result.get("graduation") == "unconditional":
64
+ print(
65
+ "Functionality is now unconditional. Cleanup removes only the obsolete opt-in, not the functionality."
66
+ )
67
+ for key, sources in result["sources"].items():
68
+ print(f"{key}: sources {', '.join(sources) or 'default'}")
69
+ for reason in result["reasons"]:
70
+ print(reason)
71
+ if result["settings"]:
72
+ print("Configurable settings: " + ", ".join(result["settings"]))
73
+ if result["guidance"]:
74
+ print(result["guidance"])
@@ -0,0 +1,50 @@
1
+ """Argument definitions for the feature subcommand."""
2
+
3
+ from argparse import ArgumentParser
4
+ from importlib import import_module
5
+
6
+ from conda.cli.helpers import add_output_and_prompt_options
7
+
8
+
9
+ def configure_parser(parser: ArgumentParser) -> None:
10
+ parser.description = "Manage features declared by installed providers. Deployment providers may install prerequisites after confirmation."
11
+ parser.epilog = "Providers register a conda_features hook in the conda_features entry-point group. See help(conda_features) in Python for setup and usage."
12
+ commands = parser.add_subparsers(dest="operation", required=True)
13
+ for name in (
14
+ "list",
15
+ "explain",
16
+ "enable",
17
+ "disable",
18
+ "configure",
19
+ "diagnose",
20
+ "repair",
21
+ "migrate",
22
+ "cleanup",
23
+ "export",
24
+ "apply",
25
+ ):
26
+ command = commands.add_parser(name)
27
+ add_output_and_prompt_options(command)
28
+ command.add_argument(
29
+ "--check-prefix",
30
+ action="append",
31
+ default=[],
32
+ metavar="PATH",
33
+ help="Also check this environment before mutation. Does not write to it.",
34
+ )
35
+ scope = command.add_mutually_exclusive_group()
36
+ scope.add_argument(
37
+ "--user", action="store_true", help="Use user configuration (default)."
38
+ )
39
+ scope.add_argument(
40
+ "--system",
41
+ action="store_true",
42
+ help="Use this conda installation's configuration.",
43
+ )
44
+ scope.add_argument(
45
+ "-p", "--prefix", help="Target an existing conda environment."
46
+ )
47
+ scope.add_argument(
48
+ "--file", help="Use an explicit user-scope configuration file."
49
+ )
50
+ import_module(f".{name}", package=__package__).configure_parser(command)
@@ -0,0 +1,16 @@
1
+ """Retry a saved feature choice using its normal deployment plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def configure_parser(parser):
7
+ parser.add_argument("feature")
8
+
9
+
10
+ def execute(args, catalog, ctx, path, scope):
11
+ from .common import confirm_and_apply, select_feature
12
+
13
+ feature = select_feature(catalog, args.feature)
14
+ return confirm_and_apply(
15
+ args, catalog, ctx, feature.repair(catalog, ctx, path, scope)
16
+ )
@@ -0,0 +1,28 @@
1
+ """Resolve configuration destinations using conda contexts."""
2
+
3
+ from argparse import Namespace
4
+ from pathlib import Path
5
+
6
+ from conda import CondaError
7
+ from conda.base.context import Context, context, user_rc_path
8
+ from conda.common.configuration import CONDARC_FILENAMES, YAML_EXTENSIONS
9
+
10
+
11
+ def target(args: Namespace) -> tuple[Path, str, Context]:
12
+ if args.file:
13
+ path = Path(args.file).expanduser().absolute()
14
+ if path.name not in CONDARC_FILENAMES and path.suffix not in YAML_EXTENSIONS:
15
+ raise CondaError(
16
+ "Configuration filename must be .condarc, condarc, or have a YAML extension."
17
+ )
18
+ return path, "user", Context(search_path=(path,))
19
+ if args.prefix:
20
+ prefix = Path(args.prefix).expanduser().absolute()
21
+ if not (prefix / "conda-meta").is_dir():
22
+ raise CondaError("The target prefix must be an existing conda environment.")
23
+ path, scope = prefix / ".condarc", "environment"
24
+ elif args.system:
25
+ path, scope = Path(context.root_prefix) / ".condarc", "system"
26
+ else:
27
+ path, scope = Path(user_rc_path).expanduser().absolute(), "user"
28
+ return path, scope, Context(argparse_args=args)
@@ -0,0 +1,22 @@
1
+ """Pluggy interface for installed feature providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pluggy import HookimplMarker, HookspecMarker
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+
12
+ from .models import Feature
13
+
14
+ hookimpl = HookimplMarker("conda_features")
15
+ hookspec = HookspecMarker("conda_features")
16
+
17
+
18
+ class FeatureSpecs:
19
+ @hookspec
20
+ def conda_features(self) -> Iterable[Feature]:
21
+ """Yield local feature declarations without performing setup or I/O."""
22
+ raise NotImplementedError