r2flow-cloud-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .coverage
9
+ *.egg-info/
10
+ dist/
11
+ node_modules/
12
+ designer-web/dist/
13
+ frontend/dist/
14
+ .env
15
+ flow.json
16
+ flows/
17
+ *.log
18
+ .vscode/
19
+ .idea/
20
+ .DS_Store
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: r2flow-cloud-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for deploying and managing R2Flow Cloud processes
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click
7
+ Requires-Dist: httpx
8
+ Requires-Dist: rich
@@ -0,0 +1,55 @@
1
+ # r2flow-cloud-cli
2
+
3
+ CLI for deploying and managing processes on the R2Flow Cloud orchestrator.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ cd cli
9
+ pip install -e .
10
+ ```
11
+
12
+ ## Configuration
13
+
14
+ Set the orchestrator URL via the `--orchestrator` flag or the `R2FLOW_CLOUD_URL` environment variable (default: `http://localhost:8000`).
15
+
16
+ ## Usage
17
+
18
+ ### Deploy a project
19
+
20
+ Pack a local directory and upload it as a new process:
21
+
22
+ ```bash
23
+ r2flow-cloud deploy ./my-process --name "Data Pipeline" --description "Nightly ETL" --entry main.py
24
+ ```
25
+
26
+ ### List processes
27
+
28
+ ```bash
29
+ r2flow-cloud processes
30
+ ```
31
+
32
+ ### List agents
33
+
34
+ ```bash
35
+ r2flow-cloud agents
36
+ ```
37
+
38
+ ### Run a process on an agent
39
+
40
+ ```bash
41
+ r2flow-cloud run <process_id> <agent_id>
42
+ ```
43
+
44
+ ### Custom orchestrator URL
45
+
46
+ ```bash
47
+ r2flow-cloud --orchestrator http://remote-host:8000 deploy ./my-process --name "Pipeline"
48
+ ```
49
+
50
+ Or via environment variable:
51
+
52
+ ```bash
53
+ export R2FLOW_CLOUD_URL=http://remote-host:8000
54
+ r2flow-cloud deploy ./my-process --name "Pipeline"
55
+ ```
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "r2flow-cloud-cli"
7
+ version = "0.1.0"
8
+ description = "CLI for deploying and managing R2Flow Cloud processes"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "httpx",
12
+ "rich",
13
+ "click",
14
+ ]
15
+
16
+ [project.scripts]
17
+ r2flow-cloud = "r2flow_cloud_cli.main:cli"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/r2flow_cloud_cli"]
@@ -0,0 +1,5 @@
1
+ """R2Flow Cloud CLI — deploy and manage processes on the orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,223 @@
1
+ """Async HTTP client for the R2Flow orchestrator API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import httpx
11
+
12
+
13
+ def _credentials_path() -> Path:
14
+ """Location of the CLI token store (``~/.r2flow/credentials.json``)."""
15
+ return Path.home() / ".r2flow" / "credentials.json"
16
+
17
+
18
+ def _load_tokens(base_url: str) -> dict[str, str]:
19
+ try:
20
+ data = json.loads(_credentials_path().read_text(encoding="utf-8"))
21
+ except (OSError, ValueError):
22
+ return {}
23
+ entry = data.get(base_url) if isinstance(data, dict) else None
24
+ return entry if isinstance(entry, dict) else {}
25
+
26
+
27
+ def _save_tokens(base_url: str, tokens: dict[str, str] | None) -> None:
28
+ import os
29
+ from contextlib import suppress
30
+
31
+ path = _credentials_path()
32
+ try:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ except (OSError, ValueError):
35
+ data = {}
36
+ if not isinstance(data, dict):
37
+ data = {}
38
+ if tokens is None:
39
+ data.pop(base_url, None)
40
+ else:
41
+ data[base_url] = tokens
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
44
+ with suppress(OSError):
45
+ os.chmod(path, 0o600)
46
+
47
+
48
+ class OrchestratorClient:
49
+ """Thin wrapper around :mod:`httpx` for the orchestrator REST API.
50
+
51
+ Bearer tokens saved by ``r2flow-cloud login`` are loaded automatically
52
+ and refreshed transparently when the server answers 401.
53
+ """
54
+
55
+ def __init__(self, base_url: str = "http://localhost:8000") -> None:
56
+ self._base = base_url.rstrip("/")
57
+ self._http = httpx.AsyncClient(base_url=self._base, timeout=30)
58
+ tokens = _load_tokens(self._base)
59
+ self._access_token: str | None = tokens.get("access_token")
60
+ self._refresh_token: str | None = tokens.get("refresh_token")
61
+
62
+ # -- context-manager support ------------------------------------------------
63
+
64
+ async def __aenter__(self) -> OrchestratorClient:
65
+ return self
66
+
67
+ async def __aexit__(
68
+ self,
69
+ exc_type: type[BaseException] | None,
70
+ exc_val: BaseException | None,
71
+ exc_tb: object,
72
+ ) -> None:
73
+ await self._http.aclose()
74
+
75
+ @property
76
+ def logged_in(self) -> bool:
77
+ """Whether stored user credentials are available."""
78
+ return self._access_token is not None
79
+
80
+ # -- auth -----------------------------------------------------------------
81
+
82
+ async def login(self, email: str, password: str) -> dict[str, Any]:
83
+ """Authenticate via ``POST /api/auth/login`` and persist the token pair."""
84
+ resp = await self._http.post(
85
+ "/api/auth/login", data={"username": email, "password": password}
86
+ )
87
+ resp.raise_for_status()
88
+ body = cast(dict[str, Any], resp.json())
89
+ self._store_tokens(str(body["access_token"]), str(body["refresh_token"]))
90
+ return await self.me()
91
+
92
+ async def logout(self) -> None:
93
+ """Revoke the refresh token server-side and drop local credentials."""
94
+ if self._refresh_token:
95
+ with suppress(httpx.HTTPError):
96
+ await self._http.post(
97
+ "/api/auth/logout", json={"refresh_token": self._refresh_token}
98
+ )
99
+ self._access_token = None
100
+ self._refresh_token = None
101
+ _save_tokens(self._base, None)
102
+
103
+ async def me(self) -> dict[str, Any]:
104
+ """Return the current user via ``GET /api/auth/me``."""
105
+ resp = await self._send("GET", "/api/auth/me")
106
+ resp.raise_for_status()
107
+ return cast(dict[str, Any], resp.json())
108
+
109
+ # -- transport --------------------------------------------------------------
110
+
111
+ def _store_tokens(self, access_token: str, refresh_token: str) -> None:
112
+ self._access_token = access_token
113
+ self._refresh_token = refresh_token
114
+ _save_tokens(
115
+ self._base,
116
+ {"access_token": access_token, "refresh_token": refresh_token},
117
+ )
118
+
119
+ async def _refresh_access(self) -> bool:
120
+ """Rotate the token pair. Returns True when new tokens were stored."""
121
+ if not self._refresh_token:
122
+ return False
123
+ try:
124
+ resp = await self._http.post(
125
+ "/api/auth/refresh", json={"refresh_token": self._refresh_token}
126
+ )
127
+ except httpx.HTTPError:
128
+ return False
129
+ if resp.status_code != 200:
130
+ self._access_token = None
131
+ self._refresh_token = None
132
+ _save_tokens(self._base, None)
133
+ return False
134
+ body = cast(dict[str, Any], resp.json())
135
+ self._store_tokens(str(body["access_token"]), str(body["refresh_token"]))
136
+ return True
137
+
138
+ async def _send(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
139
+ """Send a request with Bearer auth, refreshing once on 401."""
140
+ if self._access_token:
141
+ headers = dict(kwargs.pop("headers", {}) or {})
142
+ headers.setdefault("Authorization", f"Bearer {self._access_token}")
143
+ kwargs["headers"] = headers
144
+ resp = await self._http.request(method, url, **kwargs)
145
+ if (
146
+ resp.status_code == 401
147
+ and self._refresh_token
148
+ and await self._refresh_access()
149
+ and self._access_token
150
+ ):
151
+ headers = dict(kwargs.pop("headers", {}) or {})
152
+ headers["Authorization"] = f"Bearer {self._access_token}"
153
+ kwargs["headers"] = headers
154
+ resp = await self._http.request(method, url, **kwargs)
155
+ return resp
156
+
157
+ # -- processes --------------------------------------------------------------
158
+
159
+ async def create_process(
160
+ self,
161
+ *,
162
+ name: str,
163
+ description: str = "",
164
+ entry_point: str = "main.py",
165
+ files: dict[str, str],
166
+ requirements: list[str] | None = None,
167
+ ) -> dict[str, Any]:
168
+ """Create a new process via ``POST /api/processes``."""
169
+ resp = await self._send(
170
+ "POST",
171
+ "/api/processes",
172
+ json={
173
+ "name": name,
174
+ "description": description,
175
+ "entry_point": entry_point,
176
+ "files": files,
177
+ "requirements": requirements or [],
178
+ },
179
+ )
180
+ resp.raise_for_status()
181
+ return cast(dict[str, Any], resp.json())
182
+
183
+ async def update_process(self, process_id: str, **kwargs: object) -> dict[str, Any]:
184
+ """Update an existing process via ``PUT /api/processes/{id}``."""
185
+ resp = await self._send("PUT", f"/api/processes/{process_id}", json=kwargs)
186
+ resp.raise_for_status()
187
+ return cast(dict[str, Any], resp.json())
188
+
189
+ async def list_processes(self) -> list[dict[str, Any]]:
190
+ """Return all processes via ``GET /api/processes``."""
191
+ resp = await self._send("GET", "/api/processes")
192
+ resp.raise_for_status()
193
+ return cast(list[dict[str, Any]], resp.json())
194
+
195
+ # -- agents ----------------------------------------------------------------
196
+
197
+ async def list_agents(self) -> list[dict[str, Any]]:
198
+ """Return all registered agents via ``GET /api/agents``."""
199
+ resp = await self._send("GET", "/api/agents")
200
+ resp.raise_for_status()
201
+ return cast(list[dict[str, Any]], resp.json())
202
+
203
+ # -- deployment / execution ------------------------------------------------
204
+
205
+ async def deploy(self, process_id: str, agent_id: str) -> dict[str, Any]:
206
+ """Deploy a process to an agent via ``POST /api/processes/{id}/deploy``."""
207
+ resp = await self._send(
208
+ "POST",
209
+ f"/api/processes/{process_id}/deploy",
210
+ json={"agent_id": agent_id},
211
+ )
212
+ resp.raise_for_status()
213
+ return cast(dict[str, Any], resp.json())
214
+
215
+ async def run(self, process_id: str, agent_id: str) -> dict[str, Any]:
216
+ """Run a process on an agent via ``POST /api/processes/{id}/run``."""
217
+ resp = await self._send(
218
+ "POST",
219
+ f"/api/processes/{process_id}/run",
220
+ json={"agent_id": agent_id},
221
+ )
222
+ resp.raise_for_status()
223
+ return cast(dict[str, Any], resp.json())
@@ -0,0 +1,315 @@
1
+ """CLI entry-point — ``r2flow-cloud`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from r2flow_cloud_cli.client import OrchestratorClient
13
+ from r2flow_cloud_cli.packager import read_directory, read_requirements
14
+
15
+ console = Console()
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helper: resolve orchestrator URL from option / env-var / default
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def _client_ctx(ctx: click.Context) -> OrchestratorClient:
24
+ return OrchestratorClient(base_url=ctx.obj["orchestrator"])
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Root group
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @click.group()
33
+ @click.option(
34
+ "--orchestrator",
35
+ envvar="R2FLOW_CLOUD_URL",
36
+ default="http://localhost:8000",
37
+ show_default=True,
38
+ help="URL of the orchestrator service.",
39
+ )
40
+ @click.pass_context
41
+ def cli(ctx: click.Context, orchestrator: str) -> None:
42
+ """R2Flow Cloud CLI — deploy and run processes on remote agents."""
43
+ ctx.ensure_object(dict)
44
+ ctx.obj["orchestrator"] = orchestrator
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # deploy
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ @cli.command()
53
+ @click.argument("path", type=click.Path(exists=True, file_okay=False))
54
+ @click.option("--name", required=True, help="Process name.")
55
+ @click.option("--description", default="", help="Human-readable description.")
56
+ @click.option(
57
+ "--entry",
58
+ "entry_point",
59
+ default="main.py",
60
+ show_default=True,
61
+ help="Entry-point filename.",
62
+ )
63
+ @click.pass_context
64
+ def deploy(ctx: click.Context, path: str, name: str, description: str, entry_point: str) -> None:
65
+ """Pack a directory and upload it as a new process."""
66
+ console.print(f"[bold]Packing[/bold] {path} …")
67
+ files = read_directory(path)
68
+ if not files:
69
+ console.print("[red]No files found in the given directory.[/red]")
70
+ raise SystemExit(1)
71
+
72
+ requirements = read_requirements(path)
73
+ console.print(f" {len(files)} file(s), {len(requirements)} requirement(s)")
74
+
75
+ async def _create() -> dict[str, Any]:
76
+ async with _client_ctx(ctx) as client:
77
+ return await client.create_process(
78
+ name=name,
79
+ description=description,
80
+ entry_point=entry_point,
81
+ files=files,
82
+ requirements=requirements,
83
+ )
84
+
85
+ result = asyncio.run(_create())
86
+ pid = result.get("id") or result.get("process_id") or "?"
87
+ console.print(f"[green]✓ Process created[/green] id={pid}")
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # update
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ @cli.command()
96
+ @click.argument("process_id")
97
+ @click.argument("path", type=click.Path(exists=True, file_okay=False))
98
+ @click.option(
99
+ "--entry",
100
+ "entry_point",
101
+ default="main.py",
102
+ show_default=True,
103
+ help="Entry-point filename.",
104
+ )
105
+ @click.option(
106
+ "--agent",
107
+ "agent_ref",
108
+ default=None,
109
+ help="Agent name or ID to deploy to (implies --deploy).",
110
+ )
111
+ @click.option(
112
+ "--deploy/--no-deploy",
113
+ default=False,
114
+ show_default=True,
115
+ help="Deploy the updated bundle to the agent given via --agent.",
116
+ )
117
+ @click.pass_context
118
+ def update(
119
+ ctx: click.Context,
120
+ process_id: str,
121
+ path: str,
122
+ entry_point: str,
123
+ agent_ref: str | None,
124
+ deploy: bool,
125
+ ) -> None:
126
+ """Re-pack a directory and update an existing process in place."""
127
+ if deploy and not agent_ref:
128
+ console.print("[red]--deploy requires --agent <name|id>.[/red]")
129
+ raise SystemExit(2)
130
+
131
+ console.print(f"[bold]Packing[/bold] {path} …")
132
+ files = read_directory(path)
133
+ if not files:
134
+ console.print("[red]No files found in the given directory.[/red]")
135
+ raise SystemExit(1)
136
+
137
+ requirements = read_requirements(path)
138
+ console.print(f" {len(files)} file(s), {len(requirements)} requirement(s)")
139
+
140
+ async def _update() -> dict[str, Any]:
141
+ async with _client_ctx(ctx) as client:
142
+ return await client.update_process(
143
+ process_id,
144
+ files=files,
145
+ requirements=requirements,
146
+ entry_point=entry_point,
147
+ )
148
+
149
+ try:
150
+ asyncio.run(_update())
151
+ except Exception as err:
152
+ console.print(f"[red]Update failed:[/red] {err}")
153
+ raise SystemExit(1) from err
154
+ console.print(f"[green]✓ Process updated[/green] id={process_id}")
155
+
156
+ if not agent_ref:
157
+ return
158
+
159
+ async def _deploy() -> tuple[str, dict[str, Any]]:
160
+ async with _client_ctx(ctx) as client:
161
+ agents = await client.list_agents()
162
+ match = next(
163
+ (
164
+ a
165
+ for a in agents
166
+ if a.get("id") == agent_ref or a.get("name") == agent_ref
167
+ ),
168
+ None,
169
+ )
170
+ if match is None:
171
+ raise LookupError(f"no agent matching {agent_ref!r}")
172
+ agent_id = str(match["id"])
173
+ return agent_id, await client.deploy(process_id, agent_id)
174
+
175
+ try:
176
+ agent_id, _ = asyncio.run(_deploy())
177
+ except LookupError as err:
178
+ console.print(f"[red]Deploy failed:[/red] {err}")
179
+ raise SystemExit(1) from err
180
+ except Exception as err:
181
+ console.print(f"[red]Deploy failed:[/red] {err}")
182
+ raise SystemExit(1) from err
183
+ console.print(f"[green]✓ Deploy triggered[/green] agent={agent_id}")
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # processes
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ @cli.command()
192
+ @click.pass_context
193
+ def processes(ctx: click.Context) -> None:
194
+ """List all processes."""
195
+
196
+ async def _list() -> list[dict[str, Any]]:
197
+ async with _client_ctx(ctx) as client:
198
+ return await client.list_processes()
199
+
200
+ items = asyncio.run(_list())
201
+
202
+ table = Table(title="Processes")
203
+ table.add_column("ID", style="cyan", no_wrap=True)
204
+ table.add_column("Name", style="green")
205
+ table.add_column("Entry Point")
206
+ table.add_column("Description")
207
+
208
+ for p in items:
209
+ table.add_row(
210
+ str(p.get("id", "")),
211
+ p.get("name", ""),
212
+ p.get("entry_point", ""),
213
+ p.get("description", ""),
214
+ )
215
+
216
+ console.print(table)
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # agents
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ @cli.command()
225
+ @click.pass_context
226
+ def agents(ctx: click.Context) -> None:
227
+ """List all registered agents."""
228
+
229
+ async def _list() -> list[dict[str, Any]]:
230
+ async with _client_ctx(ctx) as client:
231
+ return await client.list_agents()
232
+
233
+ items = asyncio.run(_list())
234
+
235
+ table = Table(title="Agents")
236
+ table.add_column("ID", style="cyan", no_wrap=True)
237
+ table.add_column("Name", style="green")
238
+ table.add_column("Status")
239
+
240
+ for a in items:
241
+ table.add_row(
242
+ str(a.get("id", "")),
243
+ a.get("name", ""),
244
+ a.get("status", ""),
245
+ )
246
+
247
+ console.print(table)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # login / logout
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ @cli.command()
256
+ @click.option("--email", prompt=True, help="Account email.")
257
+ @click.option(
258
+ "--password",
259
+ prompt=True,
260
+ hide_input=True,
261
+ help="Account password.",
262
+ )
263
+ @click.pass_context
264
+ def login(ctx: click.Context, email: str, password: str) -> None:
265
+ """Authenticate against the orchestrator and store tokens locally."""
266
+
267
+ async def _login() -> dict[str, Any]:
268
+ async with _client_ctx(ctx) as client:
269
+ return await client.login(email, password)
270
+
271
+ try:
272
+ user = asyncio.run(_login())
273
+ except Exception as err:
274
+ console.print(f"[red]Login failed:[/red] {err}")
275
+ raise SystemExit(1) from err
276
+ console.print(
277
+ f"[green]✓ Logged in[/green] {user.get('email')} (role={user.get('role')})"
278
+ )
279
+
280
+
281
+ @cli.command()
282
+ @click.pass_context
283
+ def logout(ctx: click.Context) -> None:
284
+ """Revoke the stored session and drop local credentials."""
285
+
286
+ async def _logout() -> None:
287
+ async with _client_ctx(ctx) as client:
288
+ await client.logout()
289
+
290
+ asyncio.run(_logout())
291
+ console.print("[green]✓ Logged out[/green]")
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # run
296
+ # ---------------------------------------------------------------------------
297
+
298
+
299
+ @cli.command()
300
+ @click.argument("process_id")
301
+ @click.argument("agent_id")
302
+ @click.pass_context
303
+ def run(ctx: click.Context, process_id: str, agent_id: str) -> None:
304
+ """Trigger a process run on an agent."""
305
+
306
+ async def _run() -> dict[str, Any]:
307
+ async with _client_ctx(ctx) as client:
308
+ return await client.run(process_id, agent_id)
309
+
310
+ result = asyncio.run(_run())
311
+ console.print(f"[green]✓ Run triggered[/green] {result}")
312
+
313
+
314
+ if __name__ == "__main__":
315
+ cli()
@@ -0,0 +1,71 @@
1
+ """Read a project directory into a JSON-serialisable files dict and requirements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from pathlib import Path
7
+
8
+ _EXCLUDE_DIRS: set[str] = {
9
+ ".git",
10
+ ".venv",
11
+ "__pycache__",
12
+ "node_modules",
13
+ ".ruff_cache",
14
+ ".mypy_cache",
15
+ ".pytest_cache",
16
+ ".gigacode",
17
+ }
18
+ _EXCLUDE_GLOBS: set[str] = {"*.pyc", "*.pyo", ".env"}
19
+
20
+
21
+ def read_directory(path: str | Path) -> dict[str, str]:
22
+ """Walk *path* recursively and return ``{relative_path: content}`` dicts.
23
+
24
+ Skips directories and files in ``_EXCLUDE_DIRS`` / ``_EXCLUDE_GLOBS``.
25
+ Binary files (those that raise :class:`UnicodeDecodeError`) are silently
26
+ skipped.
27
+ """
28
+ root = Path(path).resolve()
29
+ files: dict[str, str] = {}
30
+
31
+ for entry in sorted(root.rglob("*")):
32
+ if not entry.is_file():
33
+ continue
34
+
35
+ # Directory name check (walks through each parent too).
36
+ if any(part in _EXCLUDE_DIRS for part in entry.relative_to(root).parts):
37
+ continue
38
+
39
+ name = entry.name
40
+ if any(fnmatch.fnmatch(name, pat) for pat in _EXCLUDE_GLOBS):
41
+ continue
42
+
43
+ rel = entry.relative_to(root).as_posix()
44
+ try:
45
+ files[rel] = entry.read_text(encoding="utf-8")
46
+ except UnicodeDecodeError:
47
+ continue # binary file — skip
48
+
49
+ return files
50
+
51
+
52
+ def read_requirements(path: str | Path | None = None) -> list[str]:
53
+ """Return requirements lines from *requirements.txt* if it exists.
54
+
55
+ Parameters
56
+ ----------
57
+ path:
58
+ Directory that may contain ``requirements.txt``. Defaults to cwd.
59
+ """
60
+ req_file = Path(path) if path else Path.cwd()
61
+ req_file = req_file / "requirements.txt"
62
+
63
+ if not req_file.is_file():
64
+ return []
65
+
66
+ lines = [
67
+ line.strip()
68
+ for line in req_file.read_text(encoding="utf-8").splitlines()
69
+ if line.strip() and not line.strip().startswith("#")
70
+ ]
71
+ return lines