afterpython 0.0.1.dev2__py3-none-any.whl → 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 (66) hide show
  1. afterpython/__init__.py +13 -0
  2. afterpython/_io/toml.py +105 -0
  3. afterpython/_io/yaml.py +26 -0
  4. afterpython/_paths.py +39 -0
  5. afterpython/_typing.py +4 -0
  6. afterpython/builders/__init__.py +17 -0
  7. afterpython/builders/content_json.py +131 -0
  8. afterpython/builders/index_md.py +94 -0
  9. afterpython/builders/jupyter_notebook.py +293 -0
  10. afterpython/builders/markdown.py +271 -0
  11. afterpython/builders/metadata.py +50 -0
  12. afterpython/cli/__init__.py +3 -0
  13. afterpython/cli/commands/add.py +67 -0
  14. afterpython/cli/commands/build.py +260 -0
  15. afterpython/cli/commands/bump.py +118 -0
  16. afterpython/cli/commands/check.py +36 -0
  17. afterpython/cli/commands/clean.py +85 -0
  18. afterpython/cli/commands/commit.py +52 -0
  19. afterpython/cli/commands/commitizen.py +32 -0
  20. afterpython/cli/commands/dev.py +213 -0
  21. afterpython/cli/commands/format.py +28 -0
  22. afterpython/cli/commands/init.py +107 -0
  23. afterpython/cli/commands/init_branch_rules.py +9 -0
  24. afterpython/cli/commands/install.py +28 -0
  25. afterpython/cli/commands/lock.py +23 -0
  26. afterpython/cli/commands/pre_commit.py +66 -0
  27. afterpython/cli/commands/preview.py +22 -0
  28. afterpython/cli/commands/release.py +67 -0
  29. afterpython/cli/commands/remove.py +67 -0
  30. afterpython/cli/commands/start.py +144 -0
  31. afterpython/cli/commands/sync.py +152 -0
  32. afterpython/cli/commands/update.py +176 -0
  33. afterpython/cli/main.py +132 -0
  34. afterpython/const.py +4 -0
  35. afterpython/main.py +25 -0
  36. afterpython/pcu.py +166 -0
  37. afterpython/templates/ci-workflow-template.yml +197 -0
  38. afterpython/templates/cz-template.toml +7 -0
  39. afterpython/templates/dependabot-template.yml +30 -0
  40. afterpython/templates/deploy-workflow-template.yml +88 -0
  41. afterpython/templates/pre-commit-config-template.yaml +48 -0
  42. afterpython/templates/release-workflow-template.yml +56 -0
  43. afterpython/templates/ruff-template.toml +32 -0
  44. afterpython/tools/_afterpython.py +128 -0
  45. afterpython/tools/_git.py +137 -0
  46. afterpython/tools/branch_rules.py +106 -0
  47. afterpython/tools/commitizen.py +13 -0
  48. afterpython/tools/github_actions.py +48 -0
  49. afterpython/tools/myst.py +208 -0
  50. afterpython/tools/pre_commit.py +37 -0
  51. afterpython/tools/pyproject.py +110 -0
  52. afterpython/utils.py +299 -0
  53. afterpython/version.py +39 -0
  54. afterpython-0.1.0.dist-info/METADATA +131 -0
  55. afterpython-0.1.0.dist-info/RECORD +57 -0
  56. afterpython-0.1.0.dist-info/WHEEL +4 -0
  57. afterpython-0.1.0.dist-info/entry_points.txt +4 -0
  58. afterpython/README.py +0 -158
  59. afterpython/images/favicon.ico +0 -0
  60. afterpython/images/logo-trace.svg +0 -3
  61. afterpython/images/logo.png +0 -0
  62. afterpython/images/logo.svg +0 -2
  63. afterpython-0.0.1.dev2.dist-info/LICENSE +0 -201
  64. afterpython-0.0.1.dev2.dist-info/METADATA +0 -20
  65. afterpython-0.0.1.dev2.dist-info/RECORD +0 -9
  66. afterpython-0.0.1.dev2.dist-info/WHEEL +0 -4
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from afterpython._typing import NodeEnv
7
+
8
+ import subprocess
9
+ import time
10
+
11
+ import click
12
+ from click.exceptions import Exit
13
+
14
+ from afterpython.cli.commands.build import postbuild, prebuild
15
+ from afterpython.const import CONTENT_TYPES
16
+ from afterpython.utils import find_available_port, find_node_env
17
+
18
+
19
+ @click.command(
20
+ add_help_option=False, # disable click's --help option so that ap dev --help can work
21
+ context_settings=dict(
22
+ ignore_unknown_options=True,
23
+ allow_extra_args=True,
24
+ ),
25
+ )
26
+ @click.pass_context
27
+ @click.option(
28
+ "--all",
29
+ is_flag=True,
30
+ help="Start the development server for all content types and the project website",
31
+ )
32
+ @click.option(
33
+ "--doc",
34
+ is_flag=True,
35
+ help="Start the development server for documentation content",
36
+ )
37
+ @click.option(
38
+ "--blog",
39
+ is_flag=True,
40
+ help="Start the development server for blog content",
41
+ )
42
+ @click.option(
43
+ "--tutorial",
44
+ is_flag=True,
45
+ help="Start the development server for tutorial content",
46
+ )
47
+ @click.option(
48
+ "--example",
49
+ is_flag=True,
50
+ help="Start the development server for example content",
51
+ )
52
+ @click.option(
53
+ "--guide",
54
+ is_flag=True,
55
+ help="Start the development server for guide content",
56
+ )
57
+ @click.option(
58
+ "--execute", is_flag=True, help="Execute Jupyter notebooks for all content types"
59
+ )
60
+ @click.option(
61
+ "--no-website",
62
+ "-n",
63
+ is_flag=True,
64
+ help="Skip running the website dev server (pnpm dev). Useful when you want to run pnpm dev manually with custom options.",
65
+ )
66
+ def dev(
67
+ ctx,
68
+ all: bool,
69
+ doc: bool,
70
+ blog: bool,
71
+ tutorial: bool,
72
+ example: bool,
73
+ guide: bool,
74
+ execute: bool,
75
+ no_website: bool,
76
+ ):
77
+ """Run the development server for the project website.
78
+
79
+ By default, runs only the website without any content servers.
80
+ Use --all to start all content types, or specify individual content types with --doc, --blog, etc.
81
+
82
+ Examples:
83
+ ap dev # Website only
84
+ ap dev --all # Website + all content types
85
+ ap dev --doc # Website + doc content
86
+ ap dev --doc --blog # Website + doc and blog content
87
+
88
+ Any extra arguments are passed to the MyST servers (via 'ap doc/blog/tutorial/example/guide' commands).
89
+ See "myst start --help" for more details.
90
+
91
+ Use --execute to execute Jupyter notebooks for all content types.
92
+
93
+ Use --no-website to skip the automatic 'pnpm dev' command, allowing you to run it manually
94
+ with custom Vite options in the afterpython/_website directory.
95
+ """
96
+
97
+ from afterpython.utils import handle_passthrough_help
98
+
99
+ # Show both our options and myst's help and exit
100
+ handle_passthrough_help(
101
+ ctx,
102
+ ["myst", "start"],
103
+ show_underlying=True,
104
+ )
105
+
106
+ # Track all MyST processes for cleanup
107
+ myst_processes = []
108
+
109
+ paths = ctx.obj["paths"]
110
+
111
+ def cleanup_processes():
112
+ """Clean up all MyST server processes"""
113
+ click.echo("\nShutting down MyST servers...")
114
+ for proc in myst_processes:
115
+ try:
116
+ proc.terminate()
117
+ proc.wait(timeout=5)
118
+ except subprocess.TimeoutExpired:
119
+ proc.kill()
120
+ except Exception:
121
+ pass
122
+
123
+ # Determine which content types to run
124
+ if all:
125
+ enabled_content_types = set(CONTENT_TYPES)
126
+ else:
127
+ # Check individual flags
128
+ content_flags = {
129
+ "doc": doc,
130
+ "blog": blog,
131
+ "tutorial": tutorial,
132
+ "example": example,
133
+ "guide": guide,
134
+ }
135
+ assert set(content_flags.keys()) == set(CONTENT_TYPES), (
136
+ "Incomplete content flags"
137
+ )
138
+ enabled_content_types = {ct for ct, flag in content_flags.items() if flag}
139
+
140
+ try:
141
+ prebuild()
142
+
143
+ # Clear .env.development before writing new ports
144
+ env_file = paths.website_path / ".env.development"
145
+ env_file.write_text("") # Clear existing content
146
+
147
+ # myst development servers
148
+ if enabled_content_types:
149
+ next_port = 3000
150
+ for content_type in CONTENT_TYPES:
151
+ # Skip content types that are not enabled
152
+ if content_type not in enabled_content_types:
153
+ continue
154
+
155
+ # Find available port for MyST server
156
+ myst_port = find_available_port(start_port=next_port)
157
+ next_port = myst_port + 1
158
+ click.echo(
159
+ click.style(
160
+ f"Starting MyST {content_type} server on port {myst_port}...",
161
+ fg="green",
162
+ )
163
+ )
164
+
165
+ # Append port to .env.development for SvelteKit
166
+ with open(env_file, "a") as f:
167
+ f.write(
168
+ f"PUBLIC_{content_type.upper()}_URL=http://localhost:{myst_port}\n"
169
+ )
170
+
171
+ myst_process = subprocess.Popen(
172
+ [
173
+ "ap",
174
+ f"{content_type}",
175
+ "--port",
176
+ str(myst_port),
177
+ *(["--execute"] if execute else []),
178
+ *ctx.args,
179
+ ],
180
+ # stdout=subprocess.DEVNULL, # Suppress output (optional)
181
+ # stderr=subprocess.DEVNULL, # Suppress errors (optional)
182
+ )
183
+ myst_processes.append(myst_process)
184
+
185
+ # NOTE: MyST internally uses additional ports beyond the one specified by --port.
186
+ # Without this delay, multiple MyST servers may attempt to bind to the same internal port,
187
+ # causing "address already in use" errors.
188
+ time.sleep(3)
189
+
190
+ postbuild(dev_build=True)
191
+
192
+ if not no_website:
193
+ node_env: NodeEnv = find_node_env()
194
+ click.echo("Running the web dev server...")
195
+ result = subprocess.run(
196
+ ["pnpm", "dev"], cwd=paths.website_path, env=node_env, check=False
197
+ )
198
+ if result.returncode != 0:
199
+ raise Exit(result.returncode)
200
+ else:
201
+ click.echo(
202
+ "Skipping website dev server (--no-website flag). Run 'pnpm dev' manually in afterpython/_website/ with your custom options."
203
+ )
204
+ if enabled_content_types:
205
+ # Keep the process running to maintain MyST servers
206
+ click.echo("Press Ctrl+C to stop MyST servers...")
207
+ while True:
208
+ time.sleep(1)
209
+ except KeyboardInterrupt:
210
+ # Handle Ctrl+C during subprocess.run
211
+ pass
212
+ finally:
213
+ cleanup_processes()
@@ -0,0 +1,28 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+
7
+ @click.command(
8
+ add_help_option=False, # disable click's --help option so that ruff format --help can work
9
+ context_settings=dict(
10
+ ignore_unknown_options=True,
11
+ allow_extra_args=True,
12
+ ),
13
+ )
14
+ @click.pass_context
15
+ def format(ctx):
16
+ """Run ruff formatter"""
17
+ from afterpython.utils import handle_passthrough_help
18
+
19
+ # Show both our options and ruff's help and exit
20
+ handle_passthrough_help(
21
+ ctx,
22
+ ["ruff", "format"],
23
+ show_underlying=True,
24
+ )
25
+
26
+ result = subprocess.run(["ruff", "format", *ctx.args], check=False)
27
+ if result.returncode != 0:
28
+ raise Exit(result.returncode)
@@ -0,0 +1,107 @@
1
+ import shutil
2
+ import subprocess
3
+
4
+ import click
5
+
6
+ import afterpython as ap
7
+
8
+
9
+ def init_ruff_toml():
10
+ ruff_toml_path = ap.paths.afterpython_path / "ruff.toml"
11
+ if ruff_toml_path.exists():
12
+ click.echo(f"Ruff configuration file {ruff_toml_path} already exists")
13
+ return
14
+ ruff_template_path = ap.paths.templates_path / "ruff-template.toml"
15
+ shutil.copy(ruff_template_path, ruff_toml_path)
16
+ click.echo(f"Created {ruff_toml_path}")
17
+
18
+
19
+ def init_website():
20
+ click.echo(f"Initializing project website template in {ap.paths.website_path}...")
21
+ subprocess.run(["ap", "update", "website"])
22
+
23
+
24
+ def init_py_typed():
25
+ from afterpython.tools.pyproject import find_package_directory
26
+
27
+ try:
28
+ package_dir = find_package_directory()
29
+ except FileNotFoundError:
30
+ click.echo(
31
+ "Could not find package directory (__init__.py not found), skipping py-typed initialization"
32
+ )
33
+ return
34
+ py_typed_path = package_dir / "py.typed"
35
+ if py_typed_path.exists():
36
+ click.echo(f"py.typed file already exists at {py_typed_path}")
37
+ return
38
+ py_typed_path.touch()
39
+ click.echo(f"Created {py_typed_path}")
40
+
41
+
42
+ @click.command()
43
+ @click.option(
44
+ "--yes",
45
+ "-y",
46
+ is_flag=True,
47
+ help="Automatically answer yes to all prompts",
48
+ )
49
+ @click.pass_context
50
+ def init(ctx, yes):
51
+ """Initialize AfterPython project structure and website template"""
52
+ from afterpython.tools._afterpython import init_afterpython
53
+ from afterpython.tools.commitizen import init_commitizen
54
+ from afterpython.tools.github_actions import (
55
+ create_dependabot,
56
+ create_workflow,
57
+ )
58
+ from afterpython.tools.myst import init_myst
59
+ from afterpython.tools.pre_commit import init_pre_commit
60
+ from afterpython.tools.pyproject import init_pyproject
61
+
62
+ paths = ctx.obj["paths"]
63
+ click.echo("Initializing afterpython...")
64
+ afterpython_path = paths.afterpython_path
65
+ static_path = paths.static_path
66
+
67
+ afterpython_path.mkdir(parents=True, exist_ok=True)
68
+ static_path.mkdir(parents=True, exist_ok=True)
69
+
70
+ init_pyproject()
71
+
72
+ init_afterpython()
73
+
74
+ init_myst()
75
+
76
+ # TODO: init faq.yml
77
+
78
+ init_website()
79
+
80
+ # TODO: add type checking related stuff here
81
+ init_py_typed()
82
+
83
+ create_workflow("deploy")
84
+ create_workflow("ci")
85
+
86
+ if yes or click.confirm(
87
+ f"\nCreate .pre-commit-config.yaml in {afterpython_path}?", default=True
88
+ ):
89
+ init_pre_commit()
90
+
91
+ if yes or click.confirm(f"\nCreate ruff.toml in {afterpython_path}?", default=True):
92
+ init_ruff_toml()
93
+
94
+ if yes or click.confirm(
95
+ f"\nCreate commitizen configuration (cz.toml) in {afterpython_path} "
96
+ f"and release workflow in .github/workflows/release.yml?",
97
+ default=True,
98
+ ):
99
+ init_commitizen()
100
+ create_workflow("release")
101
+
102
+ if yes or click.confirm(
103
+ "\nCreate Dependabot configuration (.github/dependabot.yml) "
104
+ "to auto-update GitHub Actions versions?",
105
+ default=True,
106
+ ):
107
+ create_dependabot()
@@ -0,0 +1,9 @@
1
+ import click
2
+
3
+
4
+ @click.command()
5
+ def init_branch_rules():
6
+ """Create default branch protection rules for the current repository"""
7
+ from afterpython.tools.branch_rules import create_default_branch_rules
8
+
9
+ create_default_branch_rules()
@@ -0,0 +1,28 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+ from afterpython.utils import has_pixi, has_uv
7
+
8
+
9
+ @click.command(
10
+ context_settings=dict(
11
+ ignore_unknown_options=True,
12
+ allow_extra_args=True,
13
+ )
14
+ )
15
+ def install():
16
+ """Install all project dependencies (manages both uv and pixi if present)"""
17
+ if not has_uv():
18
+ click.echo("uv not found. Please install uv first.")
19
+ return
20
+
21
+ result = subprocess.run(["uv", "sync", "--all-extras", "--all-groups"], check=False)
22
+ if result.returncode != 0:
23
+ raise Exit(result.returncode)
24
+
25
+ if has_pixi():
26
+ result = subprocess.run(["pixi", "install"], check=False)
27
+ if result.returncode != 0:
28
+ raise Exit(result.returncode)
@@ -0,0 +1,23 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+ from afterpython.utils import has_pixi, has_uv
7
+
8
+
9
+ @click.command()
10
+ def lock():
11
+ """Lock project dependencies (manages both uv and pixi if present)"""
12
+ if not has_uv():
13
+ click.echo("uv not found. Please install uv first.")
14
+ return
15
+
16
+ result = subprocess.run(["uv", "lock"], check=False)
17
+ if result.returncode != 0:
18
+ raise Exit(result.returncode)
19
+
20
+ if has_pixi():
21
+ result = subprocess.run(["pixi", "lock"], check=False)
22
+ if result.returncode != 0:
23
+ raise Exit(result.returncode)
@@ -0,0 +1,66 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+
7
+ def _command_supports_config(command: str) -> bool:
8
+ """Check if a pre-commit command supports --config flag by checking its help text."""
9
+ try:
10
+ result = subprocess.run(
11
+ ["pre-commit", command, "--help"],
12
+ capture_output=True,
13
+ text=True,
14
+ check=False,
15
+ )
16
+ return "--config" in result.stdout
17
+ except Exception:
18
+ return False
19
+
20
+
21
+ @click.command(
22
+ add_help_option=False, # disable click's --help option so that pre-commit --help can work
23
+ context_settings=dict(
24
+ ignore_unknown_options=True,
25
+ allow_extra_args=True,
26
+ ),
27
+ )
28
+ @click.pass_context
29
+ def pre_commit(ctx):
30
+ """Run pre-commit hooks (uses afterpython/.pre-commit-config.yaml if available)"""
31
+ from afterpython.utils import handle_passthrough_help
32
+
33
+ # Show both our options and pre-commit's help and exit
34
+ handle_passthrough_help(
35
+ ctx,
36
+ ["pre-commit"],
37
+ show_underlying=True,
38
+ )
39
+
40
+ args = ctx.args[:]
41
+ paths = ctx.obj["paths"]
42
+
43
+ # Find the first command in args (skip any flags that might come before the command)
44
+ command: str | None = None
45
+ command_idx: int | None = None
46
+ for i, arg in enumerate(args):
47
+ if not arg.startswith("-"):
48
+ command = arg
49
+ command_idx = i
50
+ break
51
+
52
+ # If we found a command and --config is not already specified
53
+ if (
54
+ command
55
+ and command_idx is not None
56
+ and "--config" not in args
57
+ and _command_supports_config(command)
58
+ ):
59
+ pre_commit_path = paths.afterpython_path / ".pre-commit-config.yaml"
60
+ # Insert --config right after the command
61
+ args.insert(command_idx + 1, "--config")
62
+ args.insert(command_idx + 2, str(pre_commit_path))
63
+
64
+ result = subprocess.run(["pre-commit", *args], check=False)
65
+ if result.returncode != 0:
66
+ raise Exit(result.returncode)
@@ -0,0 +1,22 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+
7
+ @click.command()
8
+ @click.pass_context
9
+ def preview(ctx):
10
+ """Preview the production build of the project website"""
11
+ from afterpython.utils import find_node_env
12
+
13
+ paths = ctx.obj["paths"]
14
+ node_env = find_node_env()
15
+ click.echo(
16
+ "Previewing the production build of the project website (including myst's builds)..."
17
+ )
18
+ result = subprocess.run(
19
+ ["pnpm", "preview"], cwd=paths.website_path, env=node_env, check=False
20
+ )
21
+ if result.returncode != 0:
22
+ raise Exit(result.returncode)
@@ -0,0 +1,67 @@
1
+ import subprocess
2
+
3
+ import click
4
+
5
+
6
+ @click.command()
7
+ @click.option(
8
+ "--force",
9
+ "-f",
10
+ is_flag=True,
11
+ help="Force release even for dev versions (not recommended)",
12
+ )
13
+ def release(force: bool):
14
+ """Manually trigger a release by pushing the current version tag.
15
+
16
+ This command is used to manually publish dev versions or pre-releases to PyPI
17
+ and create GitHub releases. It pushes the tag for the current version, which
18
+ triggers the GitHub Actions release workflow.
19
+
20
+ By default, releases are only allowed for non-dev versions (stable releases
21
+ and pre-releases like rc, alpha, beta). Use --force to release dev versions.
22
+
23
+ The release workflow will:
24
+ - Run tests in CI
25
+ - Publish to PyPI (if tests pass)
26
+ - Create a GitHub release (if tests pass)
27
+
28
+ Examples:
29
+ ap bump --pre # Bump to pre-release (e.g., 0.1.0rc1)
30
+ ap release # Push tag → triggers release workflow
31
+
32
+ ap bump # Bump dev version (e.g., 0.1.0.dev4)
33
+ ap release --force # Push tag → triggers release workflow (dev version)
34
+ """
35
+ from afterpython.tools.pyproject import read_metadata
36
+
37
+ # Get current version from pyproject.toml
38
+ metadata = read_metadata()
39
+ version = metadata.version
40
+
41
+ if version is None:
42
+ raise click.ClickException("Unable to read version from pyproject.toml")
43
+
44
+ # Check if this is a dev version
45
+ if version.is_devrelease and not force:
46
+ raise click.ClickException(
47
+ f"Cannot release dev version '{version}' without --force flag.\n"
48
+ f"Dev versions are typically not published to PyPI.\n"
49
+ f"Use 'ap bump --release' for stable releases (auto-releases),\n"
50
+ f"or 'ap bump --pre' then 'ap release' for pre-releases,\n"
51
+ f"or 'ap release --force' to release this dev version anyway."
52
+ )
53
+
54
+ tag = f"v{version}"
55
+
56
+ click.echo(f"🏷️ Pushing tag {tag} to trigger release workflow...")
57
+ result = subprocess.run(["git", "push", "origin", tag], capture_output=False)
58
+
59
+ if result.returncode != 0:
60
+ click.echo(f"\n❌ Failed to push tag (exit code {result.returncode})", err=True)
61
+ raise click.ClickException("Git push failed")
62
+
63
+ click.echo(f"✅ Tag {tag} pushed successfully")
64
+ click.echo("\n📋 Check GitHub Actions to see the release workflow progress.")
65
+
66
+ if version.is_devrelease:
67
+ click.echo(" ⚠️ Warning: Publishing a dev version to PyPI and GitHub")
@@ -0,0 +1,67 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+ from afterpython.utils import has_pixi, has_uv
7
+
8
+
9
+ @click.command()
10
+ @click.option(
11
+ "--optional",
12
+ type=str,
13
+ default=None,
14
+ required=False,
15
+ help="Remove from an optional dependency group (pixi: mapped to 'optional' feature)",
16
+ )
17
+ @click.option(
18
+ "--group",
19
+ type=str,
20
+ default=None,
21
+ required=False,
22
+ help="Remove from a dependency group (pixi: mapped to same-named feature)",
23
+ )
24
+ @click.argument("lib", type=str, required=True)
25
+ def remove(optional: str | None, group: str | None, lib: str):
26
+ """Remove a dependency from the project (manages both uv and pixi if present)"""
27
+ if not has_uv():
28
+ click.echo("uv not found. Please install uv first.")
29
+ return
30
+
31
+ if optional and group:
32
+ click.echo("Error: Cannot specify both --optional and --group")
33
+ raise Exit(1)
34
+
35
+ result = subprocess.run(
36
+ [
37
+ "uv",
38
+ "remove",
39
+ *(["--optional", optional] if optional else []),
40
+ *(["--group", group] if group else []),
41
+ lib,
42
+ ],
43
+ check=False,
44
+ )
45
+ if result.returncode != 0:
46
+ raise Exit(result.returncode)
47
+
48
+ if has_pixi():
49
+ # NOTE: pixi doesn't support --optional, so we need to use --feature instead
50
+ if optional:
51
+ feature_name = "optional"
52
+ elif group:
53
+ feature_name = group
54
+ else:
55
+ feature_name = None
56
+ result = subprocess.run(
57
+ [
58
+ "pixi",
59
+ "remove",
60
+ "--pypi",
61
+ *(["--feature", feature_name] if feature_name else []),
62
+ lib,
63
+ ],
64
+ check=False,
65
+ )
66
+ if result.returncode != 0:
67
+ raise Exit(result.returncode)