git-goose 0.0.1__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.
goose/backend/node.py ADDED
@@ -0,0 +1,190 @@
1
+ import asyncio
2
+ import asyncio.subprocess
3
+ import contextlib
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from collections.abc import Iterator
8
+ from collections.abc import Mapping
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Final
12
+
13
+ from pydantic import Field
14
+
15
+ from goose._utils.pydantic import BaseModel
16
+ from goose.config import EnvironmentConfig
17
+ from goose.executable_unit import ExecutableUnit
18
+ from goose.manifest import build_manifest
19
+ from goose.manifest import write_manifest
20
+ from goose.process import stream_both
21
+ from goose.process import system_python
22
+
23
+ from .base import Backend
24
+ from .base import RunResult
25
+
26
+
27
+ class PackageJson(BaseModel):
28
+ lockfile_version: int = Field(default=3, serialization_alias="lockfileVersion")
29
+ dependencies: Mapping[str, str]
30
+
31
+
32
+ def _bootstrap_env() -> dict[str, str]:
33
+ return os.environ | {
34
+ "PYTHONUNBUFFERED": "1",
35
+ }
36
+
37
+
38
+ async def _create_node_env(env_path: Path, version: str) -> None:
39
+ process = await asyncio.create_subprocess_exec(
40
+ system_python(),
41
+ *(
42
+ "-m",
43
+ "nodeenv",
44
+ f"--node={version}",
45
+ str(env_path),
46
+ ),
47
+ env=_bootstrap_env(),
48
+ stdout=asyncio.subprocess.PIPE,
49
+ stderr=asyncio.subprocess.PIPE,
50
+ )
51
+ await stream_both(process)
52
+ await process.wait()
53
+ if process.returncode != 0:
54
+ raise RuntimeError("Failed creating virtualenv {process.returncode=}")
55
+
56
+
57
+ async def bootstrap(
58
+ env_path: Path,
59
+ config: EnvironmentConfig,
60
+ ) -> None:
61
+ print(f"Creating node env at {env_path.name}", file=sys.stderr)
62
+ await _create_node_env(env_path, config.ecosystem.version)
63
+
64
+
65
+ def _write_package_json(
66
+ config: EnvironmentConfig,
67
+ lock_files_path: Path,
68
+ ) -> Path:
69
+ package_json_path = lock_files_path / "package.json"
70
+ package_json = PackageJson(
71
+ dependencies={
72
+ # todo: support version specs
73
+ dependency: "*"
74
+ for dependency in config.dependencies
75
+ }
76
+ )
77
+ package_json_path.write_text(package_json.model_dump_json(by_alias=True))
78
+ return package_json_path
79
+
80
+
81
+ @contextlib.contextmanager
82
+ def cd_to(path: Path) -> Iterator[None]:
83
+ cd = os.getcwd()
84
+ try:
85
+ os.chdir(path)
86
+ yield
87
+ finally:
88
+ os.chdir(cd)
89
+
90
+
91
+ async def freeze(
92
+ env_path: Path,
93
+ config: EnvironmentConfig,
94
+ lock_files_path: Path,
95
+ ) -> None:
96
+ lock_files_path.mkdir(exist_ok=True)
97
+
98
+ package_json_path = _write_package_json(config, lock_files_path)
99
+
100
+ with cd_to(lock_files_path):
101
+ process = await asyncio.create_subprocess_exec(
102
+ env_path / "bin" / "npm",
103
+ *(
104
+ "install",
105
+ "--package-lock-only",
106
+ ),
107
+ env=os.environ | {"PATH": f"{os.environ['PATH']}:{env_path / 'bin'}"},
108
+ stdout=asyncio.subprocess.PIPE,
109
+ stderr=asyncio.subprocess.PIPE,
110
+ )
111
+ await stream_both(process)
112
+ await process.wait()
113
+
114
+ if process.returncode != 0:
115
+ raise RuntimeError("Failed freezing dependencies {process.returncode=}")
116
+
117
+ package_lock_json_path = lock_files_path / "package-lock.json"
118
+ manifest = build_manifest(
119
+ source_dependencies=config.dependencies,
120
+ lock_files=(
121
+ package_json_path,
122
+ package_lock_json_path,
123
+ ),
124
+ lock_files_path=lock_files_path,
125
+ )
126
+ write_manifest(lock_files_path, manifest)
127
+
128
+
129
+ async def sync(
130
+ env_path: Path,
131
+ config: EnvironmentConfig,
132
+ lock_files_path: Path,
133
+ ) -> None:
134
+ package_lock_path = lock_files_path / "package-lock.json"
135
+ package_json_path = lock_files_path / "package.json"
136
+
137
+ shutil.copy(package_lock_path, env_path / package_lock_path.name)
138
+ shutil.copy(package_json_path, env_path / package_json_path.name)
139
+
140
+ with cd_to(env_path):
141
+ process = await asyncio.create_subprocess_exec(
142
+ env_path / "bin" / "npm",
143
+ *(
144
+ "install",
145
+ "--no-save",
146
+ ),
147
+ env=os.environ | {"PATH": f"{os.environ['PATH']}:{env_path / 'bin'}"},
148
+ stdout=asyncio.subprocess.PIPE,
149
+ stderr=asyncio.subprocess.PIPE,
150
+ )
151
+ await stream_both(process)
152
+ await process.wait()
153
+
154
+ if process.returncode != 0:
155
+ raise RuntimeError("Failed syncing dependencies {process.returncode=}")
156
+
157
+
158
+ async def run(
159
+ env_path: Path,
160
+ config: EnvironmentConfig,
161
+ unit: ExecutableUnit,
162
+ ) -> RunResult:
163
+ args: Sequence[str | Path] = (
164
+ "exec",
165
+ f"--prefix={env_path}",
166
+ unit.hook.command,
167
+ "--",
168
+ *unit.hook.args,
169
+ *unit.targets,
170
+ )
171
+ process = await asyncio.create_subprocess_exec(
172
+ env_path / "bin" / "npm",
173
+ *args,
174
+ env=os.environ | {"PATH": f"{os.environ['PATH']}:{env_path / 'bin'}"},
175
+ stdout=asyncio.subprocess.PIPE,
176
+ stderr=asyncio.subprocess.PIPE,
177
+ )
178
+ await stream_both(process)
179
+ await process.wait()
180
+
181
+ return RunResult.ok if process.returncode == 0 else RunResult.error
182
+
183
+
184
+ backend: Final = Backend(
185
+ ecosystem="node",
186
+ bootstrap=bootstrap,
187
+ freeze=freeze,
188
+ sync=sync,
189
+ run=run,
190
+ )
@@ -0,0 +1,185 @@
1
+ import asyncio
2
+ import asyncio.subprocess
3
+ import os
4
+ import sys
5
+ from collections.abc import Iterable
6
+ from contextlib import ExitStack
7
+ from pathlib import Path
8
+ from typing import Final
9
+
10
+ from goose.config import EnvironmentConfig
11
+ from goose.executable_unit import ExecutableUnit
12
+ from goose.manifest import build_manifest
13
+ from goose.manifest import write_manifest
14
+ from goose.process import stream_both
15
+ from goose.process import system_python
16
+
17
+ from .base import Backend
18
+ from .base import RunResult
19
+
20
+
21
+ def _venv_python(env_path: Path) -> Path:
22
+ return env_path / "bin" / "python"
23
+
24
+
25
+ def _bootstrap_env() -> dict[str, str]:
26
+ return os.environ | {
27
+ "PYTHONUNBUFFERED": "1",
28
+ "PIP_REQUIRE_VIRTUALENV": "true",
29
+ "PIP_DISABLE_PIP_VERSION_CHECK": "true",
30
+ }
31
+
32
+
33
+ def _run_env(env_path: Path) -> dict[str, str]:
34
+ bin_path = env_path / "bin"
35
+ return os.environ | {
36
+ "PATH": f"{bin_path}:{os.environ['PATH']}",
37
+ }
38
+
39
+
40
+ async def _create_virtualenv(env_path: Path) -> None:
41
+ process = await asyncio.create_subprocess_exec(
42
+ system_python(),
43
+ *("-m", "venv", str(env_path)),
44
+ env=_bootstrap_env(),
45
+ stdout=asyncio.subprocess.PIPE,
46
+ stderr=asyncio.subprocess.PIPE,
47
+ )
48
+ await stream_both(process)
49
+ await process.wait()
50
+ if process.returncode != 0:
51
+ raise RuntimeError("Failed creating virtualenv {process.returncode=}")
52
+
53
+
54
+ async def _pip_install(
55
+ env_path: Path,
56
+ dependencies: Iterable[str],
57
+ ) -> None:
58
+ process = await asyncio.create_subprocess_exec(
59
+ _venv_python(env_path),
60
+ *("-m", "pip", "install", *dependencies),
61
+ env=_bootstrap_env(),
62
+ stdout=asyncio.subprocess.PIPE,
63
+ stderr=asyncio.subprocess.PIPE,
64
+ )
65
+ await stream_both(process)
66
+ await process.wait()
67
+ if process.returncode != 0:
68
+ raise RuntimeError("Failed installing packages {process.returncode=}")
69
+
70
+
71
+ async def _pip_sync(
72
+ env_path: Path,
73
+ requirements_txt: Path,
74
+ ) -> None:
75
+ process = await asyncio.create_subprocess_exec(
76
+ env_path / "bin" / "pip-sync",
77
+ str(requirements_txt),
78
+ env=_bootstrap_env(),
79
+ stdout=asyncio.subprocess.PIPE,
80
+ stderr=asyncio.subprocess.PIPE,
81
+ )
82
+ await stream_both(process)
83
+ await process.wait()
84
+ if process.returncode != 0:
85
+ raise RuntimeError("Failed syncing dependencies {process.returncode=}")
86
+
87
+
88
+ async def bootstrap(
89
+ env_path: Path,
90
+ config: EnvironmentConfig,
91
+ ) -> None:
92
+ print(f"Creating virtualenv {env_path.name}", file=sys.stderr)
93
+ await _create_virtualenv(env_path)
94
+
95
+ print("Installing pip-tools ...", file=sys.stderr)
96
+ await _pip_install(env_path, ("pip-tools",))
97
+
98
+
99
+ async def freeze(
100
+ env_path: Path,
101
+ config: EnvironmentConfig,
102
+ lock_files_path: Path,
103
+ ) -> None:
104
+ lock_files_path.mkdir(exist_ok=True)
105
+
106
+ tmp_requirements_in = lock_files_path / "requirements.in"
107
+ requirements_txt = lock_files_path / "requirements.txt"
108
+
109
+ with ExitStack() as stack:
110
+ stack.callback(tmp_requirements_in.unlink, missing_ok=True)
111
+
112
+ # Write equivalent of a requirements.in.
113
+ with tmp_requirements_in.open("w") as fd:
114
+ for dependency in config.dependencies:
115
+ print(dependency, file=fd)
116
+
117
+ process = await asyncio.create_subprocess_exec(
118
+ env_path / "bin" / "pip-compile",
119
+ *(
120
+ "--upgrade",
121
+ "--strip-extras",
122
+ "--generate-hashes",
123
+ "--resolver=backtracking",
124
+ "--no-annotate",
125
+ "--no-header",
126
+ f"--output-file={requirements_txt}",
127
+ f"{tmp_requirements_in}",
128
+ ),
129
+ env=_bootstrap_env(),
130
+ stdout=asyncio.subprocess.PIPE,
131
+ stderr=asyncio.subprocess.PIPE,
132
+ )
133
+ await stream_both(process)
134
+ await process.wait()
135
+
136
+ if process.returncode != 0:
137
+ raise RuntimeError("Failed freezing dependencies {process.returncode=}")
138
+
139
+ manifest = build_manifest(
140
+ source_dependencies=config.dependencies,
141
+ lock_files=(requirements_txt,),
142
+ lock_files_path=lock_files_path,
143
+ )
144
+ write_manifest(lock_files_path, manifest)
145
+
146
+
147
+ async def sync(
148
+ env_path: Path,
149
+ config: EnvironmentConfig,
150
+ lock_files_path: Path,
151
+ ) -> None:
152
+ requirements_txt = lock_files_path / "requirements.txt"
153
+
154
+ await _pip_sync(
155
+ env_path=env_path,
156
+ requirements_txt=requirements_txt,
157
+ )
158
+
159
+
160
+ async def run(
161
+ env_path: Path,
162
+ config: EnvironmentConfig,
163
+ unit: ExecutableUnit,
164
+ ) -> RunResult:
165
+ process = await asyncio.create_subprocess_exec(
166
+ unit.hook.command,
167
+ *unit.hook.args,
168
+ *unit.targets,
169
+ env=_run_env(env_path),
170
+ stdout=asyncio.subprocess.PIPE,
171
+ stderr=asyncio.subprocess.PIPE,
172
+ )
173
+ await stream_both(process)
174
+ await process.wait()
175
+
176
+ return RunResult.ok if process.returncode == 0 else RunResult.error
177
+
178
+
179
+ backend: Final = Backend(
180
+ ecosystem="python",
181
+ bootstrap=bootstrap,
182
+ freeze=freeze,
183
+ sync=sync,
184
+ run=run,
185
+ )
goose/config.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from re import Pattern
5
+ from typing import Literal
6
+ from typing import NewType
7
+ from typing import Self
8
+ from typing import final
9
+
10
+ import yaml
11
+ from pydantic import model_validator
12
+
13
+ from ._utils.pydantic import BaseModel
14
+
15
+
16
+ @final
17
+ class EcosystemConfig(BaseModel):
18
+ language: Literal["python", "node"]
19
+ version: str
20
+
21
+
22
+ EnvironmentId = NewType("EnvironmentId", str)
23
+
24
+
25
+ @final
26
+ class EnvironmentConfig(BaseModel):
27
+ id: EnvironmentId
28
+ ecosystem: EcosystemConfig
29
+ dependencies: tuple[str, ...]
30
+
31
+
32
+ @final
33
+ class HookConfig(BaseModel):
34
+ id: str
35
+ environment: EnvironmentId
36
+ command: str
37
+ args: tuple[str, ...] = ()
38
+ parameterize: bool = True
39
+ types: frozenset[str] = frozenset()
40
+ exclude: tuple[Pattern, ...] = ()
41
+ read_only: bool = False
42
+
43
+
44
+ @final
45
+ class Config(BaseModel):
46
+ environments: tuple[EnvironmentConfig, ...]
47
+ hooks: tuple[HookConfig, ...]
48
+ exclude: tuple[Pattern, ...] = ()
49
+
50
+ @model_validator(mode="after") # type: ignore[misc]
51
+ def validate_hook_environments_configured(self) -> Self:
52
+ environments = {env.id for env in self.environments}
53
+ for hook in self.hooks:
54
+ if hook.environment not in environments:
55
+ raise ValueError(f"Unknown hook environment: {hook.environment!r}")
56
+ return self
57
+
58
+ @model_validator(mode="after") # type: ignore[misc]
59
+ def validate_hook_ids_unique(self) -> Self:
60
+ if len({hook.id for hook in self.hooks}) != len(self.hooks):
61
+ raise ValueError("Hook ids must be unique")
62
+ return self
63
+
64
+ @model_validator(mode="after") # type: ignore[misc]
65
+ def validate_environment_ids_unique(self) -> Self:
66
+ if len({env.id for env in self.environments}) != len(self.environments):
67
+ raise ValueError("Environment ids must be unique")
68
+ return self
69
+
70
+
71
+ def load_config(path: Path) -> Config:
72
+ with path.open("rb") as fd:
73
+ loaded = yaml.safe_load(fd)
74
+ return Config.model_validate(loaded)
goose/context.py ADDED
@@ -0,0 +1,34 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from .config import Config
6
+ from .config import EnvironmentId
7
+ from .config import load_config
8
+ from .environment import Environment
9
+ from .environment import build_environments
10
+ from .paths import get_env_path
11
+
12
+
13
+ @dataclass(frozen=True, slots=True, kw_only=True)
14
+ class Context:
15
+ config: Config
16
+ lock_files_path: Path
17
+ environments_path: Path
18
+ environments: Mapping[EnvironmentId, Environment]
19
+
20
+
21
+ def gather_context(
22
+ config_path: Path,
23
+ ) -> Context:
24
+ lock_files_path = Path("./.goose").resolve()
25
+ lock_files_path.mkdir(exist_ok=True)
26
+ config = load_config(config_path)
27
+ environments_path = get_env_path()
28
+ return Context(
29
+ config=config,
30
+ # fixme: should be configurable.
31
+ lock_files_path=lock_files_path,
32
+ environments_path=environments_path,
33
+ environments=build_environments(config, environments_path, lock_files_path),
34
+ )