diff --git a/.github/workflows/check-style.yaml b/.github/workflows/check-style.yaml
new file mode 100644
index 0000000..045b752
--- /dev/null
+++ b/.github/workflows/check-style.yaml
@@ -0,0 +1,27 @@
+name: Check Python Style
+on: [pull_request]
+
+jobs:
+ python-style-check:
+ strategy:
+ matrix:
+ path:
+ - alissa
+ name: Python Style Check for ${{ matrix.path }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Repo
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ - name: Install Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Requirements
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-develop.txt
+ - name: Execute Style Check
+ run: |
+ bash check-style.sh ${{ matrix.path }}
diff --git a/.github/workflows/check-tests.yaml b/.github/workflows/check-tests.yaml
new file mode 100644
index 0000000..0f74287
--- /dev/null
+++ b/.github/workflows/check-tests.yaml
@@ -0,0 +1,30 @@
+name: Check Unit-Tests and Simple Coverage Report
+on: [pull_request]
+
+jobs:
+ python-unit-testing:
+ strategy:
+ matrix:
+ path:
+ - alissa
+ name: Unit-Test Check for ${{ matrix.path }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Repo
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ - name: Install Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Requirements
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-develop.txt
+ - name: Install Package
+ run: python -m pip install -e ./${{ matrix.path }}
+ - name: Execute Unit-Tests
+ run: |
+ bash tests-unit.sh ${{ matrix.path }}
+ bash tests-coverage.sh ${{ matrix.path }}
diff --git a/.github/workflows/check-types.yaml b/.github/workflows/check-types.yaml
new file mode 100644
index 0000000..ab890d1
--- /dev/null
+++ b/.github/workflows/check-types.yaml
@@ -0,0 +1,29 @@
+name: Check Python Types
+on: [pull_request]
+
+jobs:
+ python-unit-testing:
+ strategy:
+ matrix:
+ path:
+ - alissa
+ name: Python Types Check for ${{ matrix.path }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Repo
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ - name: Install Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Requirements
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements-develop.txt
+ - name: Install Package
+ run: python -m pip install -e ./${{ matrix.path }}
+ - name: Execute Type Checks
+ run: |
+ bash check-types.sh ${{ matrix.path }}
diff --git a/.github/workflows/check-wheel.yaml b/.github/workflows/check-wheel.yaml
new file mode 100644
index 0000000..9fd8a53
--- /dev/null
+++ b/.github/workflows/check-wheel.yaml
@@ -0,0 +1,34 @@
+name: Check Creation of Wheel Package
+on: [pull_request]
+
+jobs:
+ python-wheel-check:
+ strategy:
+ matrix:
+ path:
+ - alissa
+ name: Wheel Package Check for ${{ matrix.path }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Repo
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ - name: Install Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Requirements
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install setuptools wheel
+ - name: Create Wheel Package
+ run: (cd ${{ matrix.path }}; python setup.py bdist_wheel)
+ - name: Install Wheel Package
+ run: python -m pip install ${{ matrix.path }}/dist/*.whl
+ - name: Execute Simple Command to Verify Installation
+ run: |
+ python -c "from alissa.sdk.version import version; print(version)"
+ python -c "from alissa.sdk import installed_tools; print(installed_tools())"
+ alissa-py --help
+ alissa-py --tools
diff --git a/.github/workflows/package-publish.yaml b/.github/workflows/package-publish.yaml
new file mode 100644
index 0000000..a278989
--- /dev/null
+++ b/.github/workflows/package-publish.yaml
@@ -0,0 +1,50 @@
+name: Python Package Publish
+
+# Publishing is irreversible: a version number, once on PyPI, can never be
+# reused even if the release is deleted. The version file is therefore the
+# release trigger -- merging a PR that does not bump it publishes nothing,
+# and twine's --skip-existing keeps a re-run from failing the workflow.
+
+on:
+ pull_request:
+ paths:
+ - .github/workflows/package-publish.yaml
+ - alissa/**
+ types:
+ - closed
+ branches:
+ - main
+
+jobs:
+ python-publish:
+ environment: PYPI Package Publishing
+ strategy:
+ matrix:
+ path:
+ - alissa
+ if: github.event.pull_request.merged == true
+ name: Python Package Build and Publish for ${{ matrix.path }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone Repo
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ - name: Install Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install Build Tools
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install build twine
+ - name: Build
+ run: (cd ${{ matrix.path }}; python -m build)
+ - name: Check Metadata
+ run: twine check ${{ matrix.path }}/dist/*
+ - name: Publish
+ env:
+ TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+ GH_BRANCH: ${{ github.ref }}
+ run: twine upload --verbose --skip-existing ${{ matrix.path }}/dist/*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a8e5b80
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+build/
+dist/
+.coverage
+.pytest_cache/
+.mypy_cache/
+
+# Local environments
+venv/
+.venv/
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..871f80a
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12.3
diff --git a/README.md b/README.md
index 9cc409b..367627d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,86 @@
# alissa-python-sdk
-Alissa Python SDK
+
+The single Python package that owns and anchors the `alissa` import namespace —
+`import alissa.sdk` — and curates its **tool extras**:
+
+```sh
+pip install alissa # SDK core, zero third-party deps
+pip install 'alissa[tools.github.revloop]' # + a tool, merged into alissa.tools.*
+pip install 'alissa[all]' # + every tool
+```
+
+## How ownership works
+
+`alissa` is the anchor distribution: the thing you `pip install`, the top-level
+SDK surface (`alissa.sdk`), and the list of available tools (its extras). But it
+ships **no** `__init__.py` at the namespace levels, so it doesn't monopolize the
+namespace — tool distributions pulled in by extras merge their own
+`alissa.tools.*` subtrees in via [PEP 420](https://peps.python.org/pep-0420/):
+
+```
+alissa/ ← namespace (no __init__.py)
+├── sdk/ __init__.py ← the SDK distribution: surface + version file
+├── utils/ __init__.py ← the SDK distribution: shared helpers (utils.version)
+└── tools/ ← namespace
+ └── github/
+ └── reviewloop/ __init__.py ← ships from alissa-tools-github-reviewloop
+```
+
+Each `alissa[tools..]` extra maps to a separately published
+distribution. Tools stay independently versioned and released; the SDK just
+curates which ones exist and pulls them in on demand. The mapping lives in a
+registry, so an extra name need not match its distribution name — today
+`tools.github.revloop` pulls `alissa-tools-github-reviewloop`, pending that
+package's rename. See [`alissa/README.md`](./alissa/README.md) for the full
+model and for how to add a new tool extra.
+
+## Repository layout
+
+This repo is a monorepo of distributions. Today it holds one, `alissa/`; the
+shared dev tooling and CI live at the root and matrix over each distribution's
+directory name.
+
+```
+alissa-python-sdk/
+├── alissa/ ← the `alissa` distribution
+│ ├── setup.py ← packaging + extras_require (the tool curation)
+│ ├── requirements.txt ← core deps (empty — the SDK core has none)
+│ ├── MANIFEST.in
+│ └── src/
+│ ├── main/alissa/sdk/ ← owned leaf: SDK surface (+ plain-text `version` file)
+│ ├── main/alissa/utils/ ← owned leaf: shared helpers (alissa.utils.version)
+│ └── test/test_alissa/ ← mirrors main as test_*
+├── .github/workflows/ ← style / types / tests / wheel / publish (matrix: alissa)
+├── check-style.sh check-types.sh tests-unit.sh tests-coverage.sh
+├── requirements-develop.txt
+└── .python-version ← 3.12.3
+```
+
+## Develop
+
+```sh
+python -m venv venv && source venv/bin/activate
+pip install -r requirements-develop.txt
+pip install -e ./alissa
+
+alissa-py # SDK version + how to add tools
+alissa-py --tools # list installed alissa.tools.* packages
+```
+
+## Checks
+
+Each script takes a distribution directory (matching the CI matrix):
+
+```sh
+bash tests-unit.sh alissa
+bash tests-coverage.sh alissa
+bash check-style.sh alissa
+bash check-types.sh alissa
+```
+
+## Publishing
+
+Publishing is driven by the per-distribution `version` file
+(`alissa/src/main/alissa/sdk/version`). Merging a PR to `main` that bumps it
+publishes to PyPI; a PR that doesn't bump it publishes nothing
+(`twine upload --skip-existing`). Versions are irreversible once on PyPI.
diff --git a/alissa/MANIFEST.in b/alissa/MANIFEST.in
new file mode 100644
index 0000000..14f5d05
--- /dev/null
+++ b/alissa/MANIFEST.in
@@ -0,0 +1,2 @@
+include requirements.txt
+include src/main/alissa/sdk/version
diff --git a/alissa/README.md b/alissa/README.md
new file mode 100644
index 0000000..b0136d3
--- /dev/null
+++ b/alissa/README.md
@@ -0,0 +1,131 @@
+# alissa
+
+The `alissa` distribution: the single package that owns and anchors the
+`alissa` import namespace, and the curation point for its tool extras.
+
+## The model
+
+This distribution ships **only** the leaf packages `alissa.sdk` and
+`alissa.utils`. Everything above them is a
+[PEP 420](https://peps.python.org/pep-0420/) namespace package with no
+`__init__.py`, so other distributions — pulled in through this package's
+extras — contribute their own subtrees under the same namespace:
+
+```
+alissa/ ← namespace (no __init__.py)
+├── sdk/ __init__.py ← THIS distribution: SDK surface + version file
+├── utils/ __init__.py ← THIS distribution: shared helpers (e.g. utils.version)
+└── tools/ ← namespace (no __init__.py)
+ └── github/ ← namespace
+ └── reviewloop/ __init__.py ← ships from alissa-tools-github-reviewloop
+```
+
+The rule (same as every distribution in this ecosystem): own your leaves,
+declare only those subtrees (`find_namespace_packages(include=[...])`), and never
+add an `__init__.py` at a namespace level — doing so would claim it for one
+distribution and shadow the others.
+
+"Owning the namespace" here means being the **anchor**: the distribution you
+`pip install`, the one that provides the top-level SDK surface (`alissa.sdk`),
+and the one whose extras enumerate the available tools.
+
+## Install
+
+```sh
+pip install alissa # SDK core only, zero third-party deps
+pip install 'alissa[tools.github.revloop]' # + the GitHub review-loop tool
+pip install 'alissa[all]' # + every tool extra
+```
+
+Each extra pulls in a separately published tool distribution (e.g.
+`alissa-tools-github-reviewloop`), which merges its `alissa.tools.*` packages
+into the namespace. pip normalizes the dotted extra name, so
+`alissa[tools.github.revloop]` resolves.
+
+Extra names are decoupled from distribution names by the curated registry, so
+they need not match: `tools.github.revloop` currently pulls
+`alissa-tools-github-reviewloop`. That package will be renamed to `revloop`
+later (to pair with a planned `devloop`); only the registry changes when it does.
+
+### Editable / development install
+
+```sh
+pip install -e ./alissa
+```
+
+## Console scripts
+
+| Command | Entry point |
+| --- | --- |
+| `alissa-py` | `alissa.sdk.__main__:main` |
+
+The command is `alissa-py` — deliberately **not** `alissa`, which is the Alissa
+by Fahera CLI this SDK does not shadow. The `-py` suffix marks it, explicitly,
+as the Python SDK's counterpart. (A literal `alissa.py` can't be used: the
+console-script file `bin/alissa.py` would shadow the importable `alissa`
+package.)
+
+```sh
+alissa-py # SDK version + how to add tools
+alissa-py --tools # list installed alissa.tools.* packages
+alissa-py --version
+```
+
+## Using it
+
+```python
+from alissa.sdk import __version__, installed_tools
+
+print(__version__)
+print(installed_tools()) # ['alissa.tools.github.reviewloop', ...] — whatever extras are installed
+```
+
+`installed_tools()` reports which of the SDK's curated tools are installed,
+probing each through the import machinery — so it is correct for both wheel and
+editable installs. The curated set lives in `src/main/alissa/sdk/_tools.py`, the
+same registry `setup.py` builds the extras from.
+
+## Shared utilities (`alissa.utils`)
+
+Helpers the SDK factors out so every `alissa.*` distribution reuses one
+implementation instead of copying it. Downstream packages may treat these as
+stable, public API.
+
+### `alissa.utils.version` — load a distribution's version file
+
+Ship a plain-text `version` file next to your package and load it the same way
+the SDK loads its own (`alissa/src/main/alissa/sdk/version.py` is the reference):
+
+```python
+import os
+from alissa.utils.version import Version
+
+version = Version.load(os.path.dirname(__file__), name="alissa-tools-github-reviewloop")
+```
+
+`Version.load` warns and falls back to `0.0.0` when the file is missing;
+`Version.from_path` raises instead, when a missing file should be fatal.
+
+To use it, declare the SDK as a dependency: `install_requires=["alissa"]` (pin
+`alissa>=`). **There is no
+dependency cycle** — base `alissa` requires nothing, tools require base `alissa`,
+and the `alissa[tools.*]` extras are a separate, opt-in edge. The only
+constraint is release ordering: publish `alissa` before a tool that depends on
+that version. And no circular *import*: the SDK core never imports tool code.
+
+## Layout
+
+`src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
+distribution version lives in the plain-text `version` file next to the package
+it versions (`src/main/alissa/sdk/version`), read by both `setup.py` and
+`version.py`.
+
+## Adding a tool extra
+
+1. Ship the tool as its own distribution owning a leaf under `alissa.tools.*`
+ (see [`alissa-tools-github-reviewloop`](https://pypi.org/project/alissa-tools-github-reviewloop/)
+ for the template). Have it load its version via `alissa.utils.version` and
+ declare `install_requires=["alissa"]`.
+2. Add one line to `extras_require` in [`setup.py`](./setup.py):
+ `"tools..": ["alissa-tools--"]`. The `all` extra
+ updates itself.
diff --git a/alissa/requirements.txt b/alissa/requirements.txt
new file mode 100644
index 0000000..26a2626
--- /dev/null
+++ b/alissa/requirements.txt
@@ -0,0 +1,2 @@
+# The SDK core has no third-party dependencies. Tool dependencies are pulled in
+# per-extra (see extras_require in setup.py), never from here.
diff --git a/alissa/setup.py b/alissa/setup.py
new file mode 100644
index 0000000..f63957c
--- /dev/null
+++ b/alissa/setup.py
@@ -0,0 +1,87 @@
+import os
+from setuptools import setup, find_namespace_packages
+
+
+CODEBASE_PATH = os.environ.get(
+ "CODEBASE_PATH",
+ default=os.path.join("src", "main"),
+)
+
+# This distribution owns two leaf packages under the alissa namespace:
+# alissa.sdk — the SDK surface + the distribution's version file
+# alissa.utils — shared helpers downstream alissa.* packages reuse
+# Everything above them (`alissa`, `alissa.tools`, ...) is left as a PEP 420
+# namespace with no __init__.py, so tool distributions installed through the
+# extras below merge their own alissa.tools.* subtrees into the same namespace.
+# Adding an __init__.py at any namespace level would claim it for this
+# distribution and shadow the tools.
+OWNED_PACKAGES = ["alissa.sdk", "alissa.utils"]
+
+# The distribution's version file lives beside the SDK leaf.
+VERSION_PACKAGE = "alissa.sdk"
+
+with open("requirements.txt", "r") as file:
+ requirements = [line for line in file.read().splitlines() if line and not line.startswith("#")]
+
+version_filepath = os.path.join(CODEBASE_PATH, *VERSION_PACKAGE.split("."), "version")
+with open(version_filepath, "r") as file:
+ version = file.read().strip()
+
+
+with open("README.md") as file:
+ readme = file.read()
+
+
+# Extras are the SDK's curation surface. Each extra pulls in a separately
+# published tool distribution that contributes its own alissa.tools.* subtree;
+# the SDK core itself carries no third-party dependencies. Install one with
+# pip install 'alissa[tools.github.revloop]'
+# pip normalizes the dotted extra name, so the dotted spelling above resolves.
+#
+# The curated tools live in the package's `_tools.py` so the extras and the
+# runtime's installed_tools() share one source of truth. Read it here without
+# importing the package (its deps may not be installed at build time).
+_tools_namespace: dict = {}
+_tools_filepath = os.path.join(CODEBASE_PATH, *VERSION_PACKAGE.split("."), "_tools.py")
+with open(_tools_filepath, "r") as file:
+ exec(compile(file.read(), _tools_filepath, "exec"), _tools_namespace)
+
+extras_require = {
+ extra: [distribution]
+ for extra, (distribution, _module) in _tools_namespace["CURATED_TOOLS"].items()
+}
+# `all` is the union of every tool extra, kept in sync automatically.
+extras_require["all"] = sorted({dep for deps in extras_require.values() for dep in deps})
+
+
+setup(
+ name="alissa",
+ version=version,
+ description="Alissa Python SDK — anchors the 'alissa' namespace and its tool extras.",
+ long_description=readme,
+ long_description_content_type='text/markdown',
+ url="https://alissa.app",
+ author="Fahera",
+ author_email="support@alissa.app",
+ packages=find_namespace_packages(
+ where=CODEBASE_PATH,
+ include=[pattern for pkg in OWNED_PACKAGES for pattern in (pkg, f"{pkg}.*")],
+ ),
+ package_dir={
+ "": CODEBASE_PATH
+ },
+ package_data={
+ "": [
+ version_filepath,
+ ]
+ },
+ entry_points={
+ "console_scripts": [
+ "alissa-py=alissa.sdk.__main__:main",
+ ]
+ },
+ install_requires=requirements,
+ extras_require=extras_require,
+ include_package_data=True,
+ python_requires=">=3.11",
+)
diff --git a/alissa/src/main/alissa/sdk/__init__.py b/alissa/src/main/alissa/sdk/__init__.py
new file mode 100644
index 0000000..e63c771
--- /dev/null
+++ b/alissa/src/main/alissa/sdk/__init__.py
@@ -0,0 +1,48 @@
+"""alissa — the Alissa Python SDK.
+
+This distribution is the canonical anchor of the ``alissa`` import namespace.
+It ships **no** ``__init__.py`` at the namespace levels (``alissa``,
+``alissa.tools``, ...) — only this owned leaf, ``alissa.sdk``, is a regular
+package. That keeps every namespace level a
+`PEP 420 `_ implicit namespace package, so
+tool distributions pulled in through extras — for example
+``pip install 'alissa[tools.github.revloop]'`` — merge their own
+``alissa.tools.*`` subtrees into the same namespace at import time.
+
+The SDK's own surface lives here, under ``alissa.sdk``. Tools live under
+``alissa.tools.*`` and are discovered dynamically via :func:`installed_tools`.
+"""
+
+from .version import version
+
+__all__ = ["version", "__version__", "installed_tools"]
+__version__ = version.value
+
+
+def installed_tools() -> list:
+ """Return the curated ``alissa.tools.*`` packages installed in this env.
+
+ The SDK curates a set of tools, each pulled in by an extra
+ (``pip install 'alissa[]'``); see :data:`alissa.sdk._tools.CURATED_TOOLS`,
+ the same registry ``setup.py`` builds ``extras_require`` from. This probes
+ each curated tool with :func:`importlib.util.find_spec`, which resolves
+ through the import machinery — so it works for both wheel and editable
+ installs (a plain filesystem walk of ``__path__`` misses editable ones,
+ whose path entries are synthetic finder hooks). Returns an empty list when
+ no tool extras are installed.
+ """
+ import importlib.util
+
+ from ._tools import CURATED_TOOLS
+
+ found: list = []
+ for _extra, (_distribution, module) in CURATED_TOOLS.items():
+ try:
+ spec = importlib.util.find_spec(module)
+ except (ImportError, ValueError):
+ # A broken parent package must not sink discovery of the others.
+ spec = None
+ if spec is not None:
+ found.append(module)
+
+ return sorted(found)
diff --git a/alissa/src/main/alissa/sdk/__main__.py b/alissa/src/main/alissa/sdk/__main__.py
new file mode 100644
index 0000000..71f4a7e
--- /dev/null
+++ b/alissa/src/main/alissa/sdk/__main__.py
@@ -0,0 +1,48 @@
+"""Console entry point for the Alissa Python SDK (``alissa-py``).
+
+The command is ``alissa-py`` — not ``alissa`` — on purpose: ``alissa`` is the
+Alissa by Fahera CLI (tasks, sessions, tmux queues), and the ``-py`` suffix
+marks this one, explicitly, as the Python SDK's counterpart. (A literal
+``alissa.py`` cannot be used: the console-script file ``bin/alissa.py`` would
+shadow the importable ``alissa`` package.)
+"""
+import argparse
+
+from . import __version__, installed_tools
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ prog="alissa-py",
+ description="Alissa Python SDK — anchors the 'alissa' namespace and its tool extras.",
+ )
+ parser.add_argument(
+ "--version",
+ action="version",
+ version=f"alissa {__version__}",
+ )
+ parser.add_argument(
+ "--tools",
+ action="store_true",
+ help="list the alissa.tools.* packages installed in this environment",
+ )
+ args = parser.parse_args()
+
+ if args.tools:
+ tools = installed_tools()
+ if tools:
+ for name in tools:
+ print(name)
+ else:
+ print("no alissa.tools.* packages installed")
+ print("try: pip install 'alissa[tools.github.revloop]'")
+ return
+
+ print(f"alissa {__version__}")
+ print("the Alissa Python SDK — anchors the 'alissa' namespace")
+ print("install tools via extras, e.g.: pip install 'alissa[tools.github.revloop]'")
+ print("list installed tools: alissa-py --tools")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/alissa/src/main/alissa/sdk/_tools.py b/alissa/src/main/alissa/sdk/_tools.py
new file mode 100644
index 0000000..c5b3172
--- /dev/null
+++ b/alissa/src/main/alissa/sdk/_tools.py
@@ -0,0 +1,29 @@
+"""Single source of truth for the tools this SDK curates.
+
+Each entry maps a pip *extra* name to the distribution it pulls in and the
+module that distribution makes importable:
+
+ "": ("", "")
+
+`setup.py` reads this to build ``extras_require`` (so the extras and the
+runtime never drift), and :func:`alissa.sdk.installed_tools` reads it to report
+which curated tools are actually installed.
+
+To curate a new tool, add one line here — nothing else needs editing.
+
+Note the indirection is deliberate: the public extra name is decoupled from the
+distribution and module names, so a tool can be renamed on the SDK side before
+(or independently of) its source package.
+"""
+
+CURATED_TOOLS = {
+ # The extra is `revloop`; the distribution and module are still `reviewloop`
+ # because that is what is published on PyPI. The source package will be
+ # renamed to `revloop` later — to pair with a planned `devloop` — and the
+ # two values below move with it then. Renaming them before the rename ships
+ # would resolve to a distribution that does not exist.
+ "tools.github.revloop": (
+ "alissa-tools-github-reviewloop",
+ "alissa.tools.github.reviewloop",
+ ),
+}
diff --git a/alissa/src/main/alissa/sdk/version b/alissa/src/main/alissa/sdk/version
new file mode 100644
index 0000000..6c6aa7c
--- /dev/null
+++ b/alissa/src/main/alissa/sdk/version
@@ -0,0 +1 @@
+0.1.0
\ No newline at end of file
diff --git a/alissa/src/main/alissa/sdk/version.py b/alissa/src/main/alissa/sdk/version.py
new file mode 100644
index 0000000..bfaa42a
--- /dev/null
+++ b/alissa/src/main/alissa/sdk/version.py
@@ -0,0 +1,10 @@
+"""The alissa distribution's version, loaded via the shared utility.
+
+This is the canonical example of the pattern every alissa.* distribution
+follows — see :mod:`alissa.utils.version`.
+"""
+import os
+
+from alissa.utils.version import Version
+
+version = Version.load(os.path.dirname(__file__), name="alissa")
diff --git a/alissa/src/main/alissa/utils/__init__.py b/alissa/src/main/alissa/utils/__init__.py
new file mode 100644
index 0000000..d2f7c2a
--- /dev/null
+++ b/alissa/src/main/alissa/utils/__init__.py
@@ -0,0 +1,11 @@
+"""alissa.utils — shared helpers for the ``alissa.*`` ecosystem.
+
+The SDK factors these out so every ``alissa.*`` distribution (tools included)
+reuses one implementation instead of copying it. Downstream packages may assume
+``alissa`` is importable at runtime and should declare it as a dependency
+(``install_requires=["alissa"]``); there is no dependency cycle, because the SDK
+core never imports tool code.
+"""
+from .version import Version
+
+__all__ = ["Version"]
diff --git a/alissa/src/main/alissa/utils/version.py b/alissa/src/main/alissa/utils/version.py
new file mode 100644
index 0000000..e9286b8
--- /dev/null
+++ b/alissa/src/main/alissa/utils/version.py
@@ -0,0 +1,75 @@
+"""Load a distribution's version from its plain-text ``version`` file.
+
+Factored out so every ``alissa.*`` distribution reads its version identically
+instead of re-implementing the loader. Ship a plain-text ``version`` file next
+to your package and, in a ``version.py`` beside it:
+
+ import os
+ from alissa.utils.version import Version
+
+ version = Version.load(os.path.dirname(__file__), name="my-distribution")
+
+:meth:`Version.load` tolerates a missing file (warns, falls back to a default);
+:meth:`Version.from_path` raises instead, when a missing file should be fatal.
+
+Downstream packages may treat this as stable, public API — declare ``alissa`` as
+a dependency (``install_requires=["alissa"]``) to guarantee it is importable.
+"""
+import os
+import warnings
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class Version:
+ name: str
+ value: str
+
+ def components(self, as_int: bool = False) -> list:
+ return [int(val) if as_int else val for val in self.value.split(".")]
+
+ @property
+ def major(self) -> int:
+ component, *_ = self.components(as_int=True)
+ return component
+
+ @property
+ def minor(self) -> int:
+ _, component, *_ = self.components(as_int=True)
+ return component
+
+ @property
+ def patch(self) -> int:
+ *_, component = self.components(as_int=True)
+ return component
+
+ def __str__(self) -> str:
+ return self.value
+
+ @classmethod
+ def from_path(cls, dirpath: str, name: str) -> "Version":
+ """Read the version from the ``*version`` file in ``dirpath``.
+
+ Raises ``ValueError`` when no such file exists.
+ """
+ for file in os.listdir(dirpath):
+ if file.lower().endswith("version"):
+ filepath = os.path.join(dirpath, file)
+ break
+ else:
+ raise ValueError("Version file not found for package name: " + name)
+
+ with open(filepath, "r") as version_file:
+ return cls(name=name, value=version_file.readline().strip())
+
+ @classmethod
+ def load(cls, dirpath: str, name: str, default: str = "0.0.0") -> "Version":
+ """Like :meth:`from_path`, but warn and fall back to ``default`` if absent."""
+ try:
+ return cls.from_path(dirpath, name)
+ except Exception:
+ warnings.warn(
+ f"Version file not found for package name: {name}, using {default}",
+ stacklevel=2,
+ )
+ return cls(name=name, value=default)
diff --git a/alissa/src/test/test_alissa/test_sdk/test_sdk.py b/alissa/src/test/test_alissa/test_sdk/test_sdk.py
new file mode 100644
index 0000000..267535d
--- /dev/null
+++ b/alissa/src/test/test_alissa/test_sdk/test_sdk.py
@@ -0,0 +1,62 @@
+"""The SDK's package contract: version wiring and curated-tool discovery.
+
+These lock the two things the aggregator model depends on — that the leaf
+package reports a coherent version, and that tool discovery is driven by the
+curated registry through the import machinery (so it is correct for both wheel
+and editable installs), without depending on which tools happen to be installed.
+"""
+from __future__ import annotations
+
+from alissa.sdk import __version__, installed_tools
+from alissa.sdk.version import version
+
+
+def test_version_matches_the_version_file():
+ assert __version__ == version.value
+ assert version.name == "alissa"
+
+
+def test_version_is_semver():
+ major, minor, patch = version.components(as_int=True)
+ assert isinstance(major, int) and isinstance(minor, int) and isinstance(patch, int)
+
+
+def test_curated_registry_is_wellformed():
+ from alissa.sdk._tools import CURATED_TOOLS
+
+ for extra, value in CURATED_TOOLS.items():
+ distribution, module = value
+ assert extra.startswith("tools."), extra
+ assert distribution.startswith("alissa-"), distribution
+ assert module.startswith("alissa.tools."), module
+
+
+def test_installed_tools_returns_a_sorted_subset_of_the_registry():
+ from alissa.sdk._tools import CURATED_TOOLS
+
+ curated_modules = {module for _distribution, module in CURATED_TOOLS.values()}
+ tools = installed_tools()
+
+ assert isinstance(tools, list)
+ assert tools == sorted(tools)
+ # Whatever is reported is a curated module — never something invented.
+ assert set(tools) <= curated_modules
+
+
+def test_installed_tools_probes_via_the_import_machinery(monkeypatch):
+ # Drive discovery off a fake registry: one importable module, one not.
+ # This is deterministic regardless of which real tools are installed.
+ import alissa.sdk._tools as tools_module
+
+ monkeypatch.setattr(
+ tools_module,
+ "CURATED_TOOLS",
+ {
+ "tools.present": ("alissa-tools-present", "json"),
+ "tools.absent": ("alissa-tools-absent", "alissa.tools.__does_not_exist__"),
+ },
+ )
+
+ result = installed_tools()
+ assert "json" in result
+ assert "alissa.tools.__does_not_exist__" not in result
diff --git a/alissa/src/test/test_alissa/test_utils/test_version.py b/alissa/src/test/test_alissa/test_utils/test_version.py
new file mode 100644
index 0000000..cae9f76
--- /dev/null
+++ b/alissa/src/test/test_alissa/test_utils/test_version.py
@@ -0,0 +1,55 @@
+"""The shared version loader — the contract downstream alissa.* packages rely on.
+
+These lock the public behaviour of ``alissa.utils.version.Version``: parsing,
+the strict vs. tolerant loaders, and the ``alissa.utils`` re-export.
+"""
+from __future__ import annotations
+
+import pytest
+
+from alissa.utils import Version as ReexportedVersion
+from alissa.utils.version import Version
+
+
+def test_reexported_from_the_utils_package():
+ assert ReexportedVersion is Version
+
+
+def test_from_path_reads_and_parses(tmp_path):
+ (tmp_path / "version").write_text("1.2.3\n")
+
+ v = Version.from_path(str(tmp_path), name="demo")
+
+ assert v.name == "demo"
+ assert v.value == "1.2.3"
+ assert str(v) == "1.2.3"
+ assert v.components(as_int=True) == [1, 2, 3]
+ assert (v.major, v.minor, v.patch) == (1, 2, 3)
+
+
+def test_from_path_raises_when_absent(tmp_path):
+ with pytest.raises(ValueError):
+ Version.from_path(str(tmp_path), name="demo")
+
+
+def test_load_reads_when_present(tmp_path):
+ (tmp_path / "version").write_text("4.5.6")
+
+ v = Version.load(str(tmp_path), name="demo")
+
+ assert (v.name, v.value) == ("demo", "4.5.6")
+
+
+def test_load_falls_back_and_warns_when_absent(tmp_path):
+ with pytest.warns(UserWarning):
+ v = Version.load(str(tmp_path), name="demo")
+
+ assert v.value == "0.0.0"
+ assert v.name == "demo"
+
+
+def test_load_honors_a_custom_default(tmp_path):
+ with pytest.warns(UserWarning):
+ v = Version.load(str(tmp_path), name="demo", default="9.9.9")
+
+ assert v.value == "9.9.9"
diff --git a/check-style.sh b/check-style.sh
new file mode 100644
index 0000000..c1fc342
--- /dev/null
+++ b/check-style.sh
@@ -0,0 +1 @@
+pycodestyle ${1} --max-line-length 120
diff --git a/check-types.sh b/check-types.sh
new file mode 100644
index 0000000..4fb04b0
--- /dev/null
+++ b/check-types.sh
@@ -0,0 +1 @@
+mypy ${1} --ignore-missing-imports --install-types --non-interactive
diff --git a/requirements-develop.txt b/requirements-develop.txt
new file mode 100644
index 0000000..2f7e060
--- /dev/null
+++ b/requirements-develop.txt
@@ -0,0 +1,6 @@
+pycodestyle==2.14.0
+pytest==8.4.1
+coverage==7.10.6
+mypy==1.17.1
+mypy-extensions==1.1.0
+typing_extensions==4.15.0
diff --git a/tests-coverage.sh b/tests-coverage.sh
new file mode 100644
index 0000000..a719d5f
--- /dev/null
+++ b/tests-coverage.sh
@@ -0,0 +1 @@
+(cd ${1}; coverage report --omit="test_*","*_remote_module_non_scriptable.py")
diff --git a/tests-unit.sh b/tests-unit.sh
new file mode 100644
index 0000000..81ee660
--- /dev/null
+++ b/tests-unit.sh
@@ -0,0 +1 @@
+(cd ${1}; coverage run -m pytest src/test)