shiftgate 0.1.2__tar.gz → 0.1.4__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.
Files changed (28) hide show
  1. {shiftgate-0.1.2 → shiftgate-0.1.4}/PKG-INFO +1 -1
  2. {shiftgate-0.1.2 → shiftgate-0.1.4}/pyproject.toml +17 -1
  3. shiftgate-0.1.4/shiftgate/data/__init__.py +1 -0
  4. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/registry/task_registry.py +33 -13
  5. shiftgate-0.1.4/tests/test_packaging.py +178 -0
  6. {shiftgate-0.1.2 → shiftgate-0.1.4}/tests/test_registry.py +23 -0
  7. shiftgate-0.1.2/.github/workflows/release.yml +0 -59
  8. {shiftgate-0.1.2 → shiftgate-0.1.4}/.gitignore +0 -0
  9. {shiftgate-0.1.2 → shiftgate-0.1.4}/README.md +0 -0
  10. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/__init__.py +0 -0
  11. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/cli.py +0 -0
  12. {shiftgate-0.1.2 → shiftgate-0.1.4/shiftgate}/data/default_tasks.json +0 -0
  13. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/feedback/__init__.py +0 -0
  14. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/feedback/loop.py +0 -0
  15. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/registry/__init__.py +0 -0
  16. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/registry/adapter_registry.py +0 -0
  17. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/registry/schemas.py +0 -0
  18. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/router/__init__.py +0 -0
  19. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/router/embedder.py +0 -0
  20. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/router/matcher.py +0 -0
  21. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/router/router.py +0 -0
  22. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/runtime/__init__.py +0 -0
  23. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/runtime/backend.py +0 -0
  24. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/utils/__init__.py +0 -0
  25. {shiftgate-0.1.2 → shiftgate-0.1.4}/shiftgate/utils/display.py +0 -0
  26. {shiftgate-0.1.2 → shiftgate-0.1.4}/tests/__init__.py +0 -0
  27. {shiftgate-0.1.2 → shiftgate-0.1.4}/tests/test_feedback.py +0 -0
  28. {shiftgate-0.1.2 → shiftgate-0.1.4}/tests/test_router.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shiftgate
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Intelligent routing layer that automatically selects the right LoRA adapter for each task in your local agent loop.
5
5
  Project-URL: Homepage, https://github.com/shiftgate-ai/shiftgate
6
6
  Project-URL: Repository, https://github.com/shiftgate-ai/shiftgate
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shiftgate"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Intelligent routing layer that automatically selects the right LoRA adapter for each task in your local agent loop."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -47,9 +47,25 @@ Homepage = "https://github.com/shiftgate-ai/shiftgate"
47
47
  Repository = "https://github.com/shiftgate-ai/shiftgate"
48
48
  Issues = "https://github.com/shiftgate-ai/shiftgate/issues"
49
49
 
50
+ # Wheel: ship the whole importable package. Non-.py data files inside the
51
+ # package directory (e.g. shiftgate/data/default_tasks.json) are included
52
+ # automatically because they live under the packaged directory.
50
53
  [tool.hatch.build.targets.wheel]
51
54
  packages = ["shiftgate"]
52
55
 
56
+ # Sdist: must contain the FULL source tree. The wheel published to PyPI is
57
+ # built from the sdist, so a restrictive sdist `include` here silently strips
58
+ # the .py modules out of the resulting wheel (the 0.1.3 regression). Include
59
+ # the entire package plus tests so `uv build` / `python -m build` reproduce a
60
+ # complete wheel.
61
+ [tool.hatch.build.targets.sdist]
62
+ include = [
63
+ "/shiftgate",
64
+ "/tests",
65
+ "/README.md",
66
+ "/pyproject.toml",
67
+ ]
68
+
53
69
  [tool.pytest.ini_options]
54
70
  asyncio_mode = "auto"
55
71
  testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Bundled package data for shiftgate."""
@@ -3,7 +3,7 @@ Task registry: load, persist, and manage TaskCluster definitions.
3
3
 
4
4
  The registry reads from (in priority order):
5
5
  1. ``~/.shiftgate/tasks.json`` — user-edited / previously saved
6
- 2. ``<package>/../../data/default_tasks.json`` — bundled defaults
6
+ 2. ``shiftgate.data/default_tasks.json`` — bundled defaults (via importlib.resources)
7
7
 
8
8
  On first run (``shiftgate init``) the ``compute_embeddings`` method is called
9
9
  to populate ``embedding_centroid`` for every cluster and cache them to
@@ -12,6 +12,7 @@ to populate ``embedding_centroid`` for every cluster and cache them to
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import importlib.resources
15
16
  import json
16
17
  import logging
17
18
  from pathlib import Path
@@ -31,8 +32,24 @@ _SHIFTGATE_DIR = Path.home() / ".shiftgate"
31
32
  _USER_TASKS_PATH = _SHIFTGATE_DIR / "tasks.json"
32
33
  _CACHE_PATH = _SHIFTGATE_DIR / "embeddings_cache.npy"
33
34
 
34
- # Path to the bundled default tasks, resolved relative to this file's location.
35
- _DEFAULT_TASKS_PATH = Path(__file__).parent.parent.parent / "data" / "default_tasks.json"
35
+ # Legacy dev-checkout path kept for backwards compatibility with source trees
36
+ # that still ship repo-root ``data/default_tasks.json``.
37
+ _LEGACY_DEFAULT_TASKS_PATH = Path(__file__).parent.parent.parent / "data" / "default_tasks.json"
38
+ _BUNDLED_DEFAULT_TASKS_LABEL = "shiftgate.data/default_tasks.json"
39
+
40
+
41
+ def _read_bundled_default_tasks() -> str:
42
+ """Return the bundled default task registry JSON from the installed package."""
43
+ try:
44
+ resource = importlib.resources.files("shiftgate.data") / "default_tasks.json"
45
+ return resource.read_text(encoding="utf-8")
46
+ except (FileNotFoundError, ModuleNotFoundError, TypeError):
47
+ pass
48
+
49
+ if _LEGACY_DEFAULT_TASKS_PATH.exists():
50
+ return _LEGACY_DEFAULT_TASKS_PATH.read_text(encoding="utf-8")
51
+
52
+ raise FileNotFoundError(_BUNDLED_DEFAULT_TASKS_LABEL)
36
53
 
37
54
 
38
55
  class TaskRegistry:
@@ -58,22 +75,25 @@ class TaskRegistry:
58
75
  """Load the task registry from disk.
59
76
 
60
77
  Prefers the user's ``~/.shiftgate/tasks.json`` and falls back to the
61
- bundled ``data/default_tasks.json`` if the user file does not exist.
78
+ bundled ``shiftgate.data/default_tasks.json`` if the user file does not exist.
62
79
  """
63
80
  if _USER_TASKS_PATH.exists():
64
81
  source = _USER_TASKS_PATH
65
- elif _DEFAULT_TASKS_PATH.exists():
66
- source = _DEFAULT_TASKS_PATH
82
+ raw = json.loads(source.read_text(encoding="utf-8"))
67
83
  else:
68
- raise FileNotFoundError(
69
- f"No task registry found. Expected one of:\n"
70
- f" {_USER_TASKS_PATH}\n"
71
- f" {_DEFAULT_TASKS_PATH}\n"
72
- "Run `shiftgate init` to set up the default registry."
73
- )
84
+ try:
85
+ raw_text = _read_bundled_default_tasks()
86
+ except FileNotFoundError:
87
+ raise FileNotFoundError(
88
+ f"No task registry found. Expected one of:\n"
89
+ f" {_USER_TASKS_PATH}\n"
90
+ f" {_BUNDLED_DEFAULT_TASKS_LABEL}\n"
91
+ "Run `shiftgate init` to set up the default registry."
92
+ ) from None
93
+ source = Path(_BUNDLED_DEFAULT_TASKS_LABEL)
94
+ raw = json.loads(raw_text)
74
95
 
75
96
  logger.debug("Loading task registry from %s", source)
76
- raw = json.loads(source.read_text(encoding="utf-8"))
77
97
  tasks = [TaskCluster.model_validate(t) for t in raw]
78
98
  instance = cls(tasks, source_path=source)
79
99
 
@@ -0,0 +1,178 @@
1
+ """
2
+ Packaging tests — guard against the 0.1.3 regression where the published wheel
3
+ shipped without its Python modules (``ModuleNotFoundError: shiftgate.cli``).
4
+
5
+ Why these tests build the *full* pipeline
6
+ -----------------------------------------
7
+ PyPI (and ``uv build`` / ``python -m build`` with no flags) builds the sdist
8
+ first, then builds the wheel **from the extracted sdist**. A wheel built
9
+ directly from the source tree can look complete while the real published wheel
10
+ (built from a too-restrictive sdist) is missing modules. So the regression
11
+ guard below uses the default ``uv build`` (sdist + wheel-from-sdist), exactly
12
+ like a release, and inspects the resulting wheel.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import tarfile
23
+ import zipfile
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
29
+
30
+ # Skip the whole module gracefully if `uv` isn't on PATH (e.g. minimal CI image).
31
+ _UV = shutil.which("uv")
32
+ pytestmark = pytest.mark.skipif(_UV is None, reason="uv is required to build the package")
33
+
34
+
35
+ def _run_build(*args: str, out_dir: Path) -> None:
36
+ """Build into ``out_dir``. With no extra args, builds sdist then wheel-from-sdist."""
37
+ subprocess.run(
38
+ [_UV, "build", *args, "-o", str(out_dir)],
39
+ cwd=PROJECT_ROOT,
40
+ check=True,
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+
45
+
46
+ @pytest.fixture(scope="module")
47
+ def full_dist(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path]:
48
+ """Build the sdist and a wheel-from-sdist once, like a real release.
49
+
50
+ Returns a dict with ``wheel`` and ``sdist`` paths.
51
+ """
52
+ dist_dir = tmp_path_factory.mktemp("dist")
53
+ _run_build(out_dir=dist_dir) # no flags → sdist + wheel built from that sdist
54
+
55
+ wheels = list(dist_dir.glob("*.whl"))
56
+ sdists = list(dist_dir.glob("*.tar.gz"))
57
+ assert len(wheels) == 1, f"expected one wheel, got {wheels}"
58
+ assert len(sdists) == 1, f"expected one sdist, got {sdists}"
59
+ return {"wheel": wheels[0], "sdist": sdists[0]}
60
+
61
+
62
+ def _wheel_names(wheel: Path) -> list[str]:
63
+ with zipfile.ZipFile(wheel) as archive:
64
+ return archive.namelist()
65
+
66
+
67
+ def _sdist_names(sdist: Path) -> list[str]:
68
+ with tarfile.open(sdist, "r:gz") as archive:
69
+ return archive.getnames()
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Wheel content tests
74
+ # ---------------------------------------------------------------------------
75
+
76
+ class TestWheelContents:
77
+ def test_wheel_contains_cli_module(self, full_dist: dict[str, Path]) -> None:
78
+ """The 0.1.3 regression guard: shiftgate/cli.py must be in the wheel."""
79
+ names = _wheel_names(full_dist["wheel"])
80
+ assert "shiftgate/cli.py" in names, (
81
+ "shiftgate/cli.py missing from wheel — this is the 0.1.3 packaging bug. "
82
+ f"Wheel contained: {names}"
83
+ )
84
+
85
+ def test_wheel_contains_all_subpackages(self, full_dist: dict[str, Path]) -> None:
86
+ """Every shiftgate subpackage module should ship in the wheel."""
87
+ names = set(_wheel_names(full_dist["wheel"]))
88
+ expected = {
89
+ "shiftgate/__init__.py",
90
+ "shiftgate/cli.py",
91
+ "shiftgate/registry/schemas.py",
92
+ "shiftgate/registry/adapter_registry.py",
93
+ "shiftgate/registry/task_registry.py",
94
+ "shiftgate/router/router.py",
95
+ "shiftgate/router/matcher.py",
96
+ "shiftgate/router/embedder.py",
97
+ "shiftgate/runtime/backend.py",
98
+ "shiftgate/feedback/loop.py",
99
+ "shiftgate/utils/display.py",
100
+ }
101
+ missing = expected - names
102
+ assert not missing, f"wheel is missing modules: {sorted(missing)}"
103
+
104
+ def test_wheel_contains_default_tasks_json(self, full_dist: dict[str, Path]) -> None:
105
+ names = _wheel_names(full_dist["wheel"])
106
+ assert "shiftgate/data/default_tasks.json" in names
107
+
108
+ def test_wheel_default_tasks_json_is_valid(self, full_dist: dict[str, Path]) -> None:
109
+ with zipfile.ZipFile(full_dist["wheel"]) as archive:
110
+ raw = archive.read("shiftgate/data/default_tasks.json")
111
+ tasks = json.loads(raw)
112
+ assert isinstance(tasks, list)
113
+ assert len(tasks) == 10
114
+ assert tasks[0]["id"]
115
+
116
+ def test_wheel_is_not_truncated(self, full_dist: dict[str, Path]) -> None:
117
+ """A healthy wheel is well above the ~8 KB broken build."""
118
+ size_kb = full_dist["wheel"].stat().st_size / 1024
119
+ assert size_kb > 15, f"wheel is suspiciously small ({size_kb:.1f} KB)"
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Sdist content tests
124
+ # ---------------------------------------------------------------------------
125
+
126
+ class TestSdistContents:
127
+ def test_sdist_contains_cli_module(self, full_dist: dict[str, Path]) -> None:
128
+ names = _sdist_names(full_dist["sdist"])
129
+ assert any(n.endswith("shiftgate/cli.py") for n in names), (
130
+ "shiftgate/cli.py missing from sdist — wheels built from it will be broken."
131
+ )
132
+
133
+ def test_sdist_contains_default_tasks_json(self, full_dist: dict[str, Path]) -> None:
134
+ names = _sdist_names(full_dist["sdist"])
135
+ assert any(n.endswith("shiftgate/data/default_tasks.json") for n in names)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Integration test: install the wheel into a fresh venv and run the CLI
140
+ # ---------------------------------------------------------------------------
141
+
142
+ class TestWheelInstall:
143
+ def test_installed_wheel_cli_help_runs(
144
+ self, full_dist: dict[str, Path], tmp_path: Path
145
+ ) -> None:
146
+ """Install the wheel into a clean venv and run `shiftgate --help`.
147
+
148
+ Verifies the console-script entry point resolves and ``shiftgate.cli``
149
+ imports cleanly from the installed package (exit code 0).
150
+ """
151
+ venv_dir = tmp_path / "venv"
152
+ subprocess.run([_UV, "venv", str(venv_dir)], check=True, capture_output=True, text=True)
153
+
154
+ # Install the freshly built wheel (deps resolved from uv's cache).
155
+ subprocess.run(
156
+ [_UV, "pip", "install", "--python", str(venv_dir), str(full_dist["wheel"])],
157
+ check=True,
158
+ capture_output=True,
159
+ text=True,
160
+ )
161
+
162
+ # Locate the installed console script (cross-platform).
163
+ if os.name == "nt":
164
+ script = venv_dir / "Scripts" / "shiftgate.exe"
165
+ else:
166
+ script = venv_dir / "bin" / "shiftgate"
167
+ assert script.exists(), f"console script not installed at {script}"
168
+
169
+ result = subprocess.run(
170
+ [str(script), "--help"],
171
+ capture_output=True,
172
+ text=True,
173
+ )
174
+ assert result.returncode == 0, (
175
+ f"`shiftgate --help` failed (rc={result.returncode}).\n"
176
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
177
+ )
178
+ assert "shiftgate" in result.stdout.lower()
@@ -319,6 +319,29 @@ class TestTaskRegistry:
319
319
  assert len(reg) == 1
320
320
  assert reg.get_task("test_task").name == "Updated Task"
321
321
 
322
+ def test_load_bundled_defaults_when_no_user_registry(self, tmp_shiftgate):
323
+ """Packaged defaults load via importlib when ~/.shiftgate/tasks.json is absent."""
324
+ reg = TaskRegistry.load()
325
+ assert len(reg) == 10
326
+ assert reg.get_task("code_python") is not None
327
+ assert reg.get_task("code_sql") is not None
328
+
329
+ def test_user_registry_takes_priority_over_bundled_defaults(self, tmp_shiftgate, sample_task):
330
+ """Existing user registries continue to win over bundled defaults."""
331
+ import shiftgate.registry.task_registry as tr_mod
332
+
333
+ user_path = tmp_shiftgate / "tasks.json"
334
+ user_path.write_text(
335
+ json.dumps([sample_task.model_dump()]),
336
+ encoding="utf-8",
337
+ )
338
+ assert tr_mod._USER_TASKS_PATH == user_path
339
+
340
+ reg = TaskRegistry.load()
341
+ assert len(reg) == 1
342
+ assert reg.get_task("test_task") is not None
343
+ assert reg.get_task("code_python") is None
344
+
322
345
 
323
346
  # ---------------------------------------------------------------------------
324
347
  # _auto_link_adapter helper tests
@@ -1,59 +0,0 @@
1
- name: Release and Publish
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*' # Triggers the pipeline whenever a version tag is pushed, e.g., v0.1.0
7
-
8
- jobs:
9
- validate:
10
- name: Validate and Test Code
11
- runs-on: ubuntu-latest
12
- steps:
13
- - name: Check out repository
14
- uses: actions/checkout@v4
15
-
16
- - name: Install uv
17
- uses: astral-sh/setup-uv@v5
18
- with:
19
- enable-cache: true
20
-
21
- - name: Set up Python
22
- uses: actions/setup-python@v5
23
- with:
24
- python-version-file: "pyproject.toml"
25
-
26
- - name: Install dependencies & run tests
27
- run: |
28
- uv sync --all-extras --dev
29
- uv run pytest
30
-
31
- publish:
32
- name: Build and Publish to PyPI
33
- needs: validate
34
- runs-on: ubuntu-latest
35
-
36
- # Define the target environment configured on PyPI
37
- environment:
38
- name: pypi
39
- url: https://pypi.org/p/shiftgate
40
-
41
- # CRITICAL: This gives the job permission to request an OIDC id-token from GitHub
42
- permissions:
43
- id-token: write
44
- contents: read
45
-
46
- steps:
47
- - name: Check out repository
48
- uses: actions/checkout@v4
49
-
50
- - name: Install uv
51
- uses: astral-sh/setup-uv@v5
52
- with:
53
- enable-cache: true
54
-
55
- - name: Build distribution packages
56
- run: uv build
57
-
58
- - name: Publish to PyPI via Trusted Publishing
59
- run: uv publish --trusted-publishing always
File without changes
File without changes
File without changes
File without changes