biocognia-intelligence 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.
- biocognia_intelligence-0.1.0/.gitignore +10 -0
- biocognia_intelligence-0.1.0/PKG-INFO +59 -0
- biocognia_intelligence-0.1.0/README.md +42 -0
- biocognia_intelligence-0.1.0/pyproject.toml +49 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/__init__.py +10 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/__main__.py +7 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/api.py +98 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/app.py +242 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/cli.py +140 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/licence.py +53 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/paths.py +28 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/registry.py +151 -0
- biocognia_intelligence-0.1.0/src/biocognia_intelligence/routing.py +173 -0
- biocognia_intelligence-0.1.0/tests/conftest.py +121 -0
- biocognia_intelligence-0.1.0/tests/test_app.py +129 -0
- biocognia_intelligence-0.1.0/tests/test_cli.py +83 -0
- biocognia_intelligence-0.1.0/tests/test_import_light.py +49 -0
- biocognia_intelligence-0.1.0/tests/test_registry.py +88 -0
- biocognia_intelligence-0.1.0/tests/test_routing.py +117 -0
- biocognia_intelligence-0.1.0/uv.lock +4266 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: biocognia-intelligence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Biocognia Intelligence: one AI workspace over the BioCognia products installed, opened with `biocognia`
|
|
5
|
+
Project-URL: Homepage, https://biocognia.com
|
|
6
|
+
Author-email: Ajit Johnson Nirmal <ajitjohnson.n@gmail.com>
|
|
7
|
+
License-Expression: LicenseRef-Proprietary
|
|
8
|
+
Classifier: Intended Audience :: Science/Research
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: biocognia<0.2,>=0.1.2
|
|
13
|
+
Requires-Dist: spatialagents<0.3,>=0.2.2
|
|
14
|
+
Provides-Extra: scimappro
|
|
15
|
+
Requires-Dist: scimappro[ai,bridge,chat]; extra == 'scimappro'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Biocognia Intelligence
|
|
19
|
+
|
|
20
|
+
One AI workspace over the BioCognia products you have installed, opened with one command:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install "biocognia-intelligence[scimappro]"
|
|
24
|
+
biocognia
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`biocognia` opens the Workspace in your browser. Start with your data: a file, a
|
|
28
|
+
folder, or a data location. The product that fits it opens it (or the one you
|
|
29
|
+
choose), and the assistant helps you explore and interpret it. Your workspaces
|
|
30
|
+
from each product are listed together. The products you have installed, such
|
|
31
|
+
as Plexora, can be opened from the home screen in their own windows.
|
|
32
|
+
|
|
33
|
+
| Command | What it does |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `biocognia` | Opens the app. `--no-browser` prints its address instead, `--port` serves elsewhere (default 8480), and `--home ID` sets the product a new workspace belongs to. |
|
|
36
|
+
| `biocognia list` | The products installed. `--licence` adds whether each one's AI licence lets a conversation run, and `--json` prints it all as JSON. |
|
|
37
|
+
| `biocognia open plexora` | Opens a product's own application. |
|
|
38
|
+
| `biocognia license status` | This device's licences, for every product. `activate`, `refresh`, `install` and the other verbs work as in each product's own command line. |
|
|
39
|
+
| `biocognia serve --desktop` | A desktop shell's sidecar: one ready line, then the app until stdin closes. |
|
|
40
|
+
|
|
41
|
+
## How it is put together
|
|
42
|
+
|
|
43
|
+
- **A workspace belongs to one product**, its home. It is stored where that
|
|
44
|
+
product keeps its sessions, so the product's own app (`scimappro`) lists and
|
|
45
|
+
opens the same workspaces.
|
|
46
|
+
- **The home product pays.** Its AI licence pays for the conversation, through
|
|
47
|
+
that product's own gateway. There is no separate Intelligence licence: AI
|
|
48
|
+
comes with each product's licence.
|
|
49
|
+
- **The runtime is shared.** The conversation, plans, approvals and the page are
|
|
50
|
+
[spatialagents](https://pypi.org/project/spatialagents/). Licences and the
|
|
51
|
+
AI token come from [biocognia](https://pypi.org/project/biocognia/).
|
|
52
|
+
- **Products register themselves.** A product joins by registering one object
|
|
53
|
+
under the `biocognia.products` entry point, from a package small enough to
|
|
54
|
+
import in a moment. The contract is in `src/biocognia_intelligence/registry.py`.
|
|
55
|
+
|
|
56
|
+
```toml
|
|
57
|
+
[project.entry-points."biocognia.products"]
|
|
58
|
+
myproduct = "myproduct_intelligence:PRODUCT"
|
|
59
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Biocognia Intelligence
|
|
2
|
+
|
|
3
|
+
One AI workspace over the BioCognia products you have installed, opened with one command:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install "biocognia-intelligence[scimappro]"
|
|
7
|
+
biocognia
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`biocognia` opens the Workspace in your browser. Start with your data: a file, a
|
|
11
|
+
folder, or a data location. The product that fits it opens it (or the one you
|
|
12
|
+
choose), and the assistant helps you explore and interpret it. Your workspaces
|
|
13
|
+
from each product are listed together. The products you have installed, such
|
|
14
|
+
as Plexora, can be opened from the home screen in their own windows.
|
|
15
|
+
|
|
16
|
+
| Command | What it does |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `biocognia` | Opens the app. `--no-browser` prints its address instead, `--port` serves elsewhere (default 8480), and `--home ID` sets the product a new workspace belongs to. |
|
|
19
|
+
| `biocognia list` | The products installed. `--licence` adds whether each one's AI licence lets a conversation run, and `--json` prints it all as JSON. |
|
|
20
|
+
| `biocognia open plexora` | Opens a product's own application. |
|
|
21
|
+
| `biocognia license status` | This device's licences, for every product. `activate`, `refresh`, `install` and the other verbs work as in each product's own command line. |
|
|
22
|
+
| `biocognia serve --desktop` | A desktop shell's sidecar: one ready line, then the app until stdin closes. |
|
|
23
|
+
|
|
24
|
+
## How it is put together
|
|
25
|
+
|
|
26
|
+
- **A workspace belongs to one product**, its home. It is stored where that
|
|
27
|
+
product keeps its sessions, so the product's own app (`scimappro`) lists and
|
|
28
|
+
opens the same workspaces.
|
|
29
|
+
- **The home product pays.** Its AI licence pays for the conversation, through
|
|
30
|
+
that product's own gateway. There is no separate Intelligence licence: AI
|
|
31
|
+
comes with each product's licence.
|
|
32
|
+
- **The runtime is shared.** The conversation, plans, approvals and the page are
|
|
33
|
+
[spatialagents](https://pypi.org/project/spatialagents/). Licences and the
|
|
34
|
+
AI token come from [biocognia](https://pypi.org/project/biocognia/).
|
|
35
|
+
- **Products register themselves.** A product joins by registering one object
|
|
36
|
+
under the `biocognia.products` entry point, from a package small enough to
|
|
37
|
+
import in a moment. The contract is in `src/biocognia_intelligence/registry.py`.
|
|
38
|
+
|
|
39
|
+
```toml
|
|
40
|
+
[project.entry-points."biocognia.products"]
|
|
41
|
+
myproduct = "myproduct_intelligence:PRODUCT"
|
|
42
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "biocognia-intelligence"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Biocognia Intelligence: one AI workspace over the BioCognia products installed, opened with `biocognia`"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Ajit Johnson Nirmal", email = "ajitjohnson.n@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "LicenseRef-Proprietary"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Intended Audience :: Science/Research",
|
|
14
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
15
|
+
]
|
|
16
|
+
# The application, not a library: the licensing client and the runtime are the
|
|
17
|
+
# shared layer, and each product joins by registering under the
|
|
18
|
+
# `biocognia.products` entry point (registry.py). `biocognia --help` and
|
|
19
|
+
# `biocognia list` load neither: every subcommand imports what it needs.
|
|
20
|
+
dependencies = [
|
|
21
|
+
"biocognia>=0.1.2,<0.2",
|
|
22
|
+
"spatialagents>=0.2.2,<0.3",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
scimappro = ["scimappro[ai,chat,bridge]"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
biocognia = "biocognia_intelligence.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://biocognia.com"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/biocognia_intelligence"]
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=9.1.1",
|
|
44
|
+
"pytest-timeout>=2.3",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
timeout = 120
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Biocognia Intelligence: one AI workspace over the BioCognia products installed.
|
|
2
|
+
|
|
3
|
+
`biocognia` opens it (cli.py): spatialagents' Workspace page, where a
|
|
4
|
+
workspace belongs to one product -- its home, whose AI licence pays for the
|
|
5
|
+
conversation -- and the home screen lists the person's applications.
|
|
6
|
+
Products join by registering under the `biocognia.products` entry point
|
|
7
|
+
(registry.py). Importing this package loads nothing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The app's HTTP contract: spatialagents' Workspace (`WorkspaceApi`), and the
|
|
2
|
+
person's applications beside it.
|
|
3
|
+
|
|
4
|
+
GET /apps each installed product: {id, name, brand, version, ai, can_open, home, note?}
|
|
5
|
+
POST /apps/:id/open start its own application, in its own window
|
|
6
|
+
|
|
7
|
+
Everything else is the Workspace's.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
_APPS = re.compile(r"^/apps(?:/([^/]+)/(open))?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IntelligenceApi:
|
|
20
|
+
"""`inner` is the `WorkspaceApi`; `products` the registered products and
|
|
21
|
+
`stale` the family's installed too old to register (registry.py);
|
|
22
|
+
`start(argv, product_id) -> pid` starts an application (tests pass their own)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, inner, products, *, stale=(), start=None):
|
|
25
|
+
self.inner = inner
|
|
26
|
+
self.products = {product.id: product for product in products}
|
|
27
|
+
self.stale = {row["id"]: row for row in stale}
|
|
28
|
+
self.start = start or _start
|
|
29
|
+
|
|
30
|
+
def __getattr__(self, name):
|
|
31
|
+
# What the server asks of the Workspace's API (whether it runs plans) is the Workspace's.
|
|
32
|
+
return getattr(self.inner, name)
|
|
33
|
+
|
|
34
|
+
def handle(self, method: str, path: str, *, query=None, body=None, headers=None):
|
|
35
|
+
found = _APPS.match("/" + str(path).strip("/"))
|
|
36
|
+
if not found:
|
|
37
|
+
return self.inner.handle(method, path, query=query, body=body, headers=headers)
|
|
38
|
+
from spatialagents.chat.http import missing, ok, refused
|
|
39
|
+
|
|
40
|
+
method = method.upper()
|
|
41
|
+
if found.group(1) is None and method == "GET":
|
|
42
|
+
return ok({"apps": self.apps()})
|
|
43
|
+
if found.group(1) is not None and method == "POST":
|
|
44
|
+
product_id = found.group(1)
|
|
45
|
+
argv = self._argv(product_id)
|
|
46
|
+
if argv is None:
|
|
47
|
+
return refused({"code": "not_found" if product_id not in self.products else "capability_unavailable",
|
|
48
|
+
"message": f"{product_id!r} has no application of its own here",
|
|
49
|
+
"detail": None, "retryable": False})
|
|
50
|
+
name = self._name(product_id)
|
|
51
|
+
try:
|
|
52
|
+
pid = self.start(argv, product_id)
|
|
53
|
+
except OSError as problem:
|
|
54
|
+
return refused({"code": "resource_unavailable", "message": f"{name} would not start: {problem}",
|
|
55
|
+
"detail": None, "retryable": True})
|
|
56
|
+
return ok({"started": True, "pid": pid, "message": f"{name} is opening in its own window"})
|
|
57
|
+
return missing(f"no route {method} {path}")
|
|
58
|
+
|
|
59
|
+
def apps(self) -> list:
|
|
60
|
+
from .registry import version_of
|
|
61
|
+
from .routing import _ai
|
|
62
|
+
|
|
63
|
+
rows = []
|
|
64
|
+
for product in self.products.values():
|
|
65
|
+
rows.append({"id": product.id, "name": product.name, "brand": product.brand,
|
|
66
|
+
"version": version_of(product.distribution), "ai": _ai(product),
|
|
67
|
+
"can_open": self._argv(product.id) is not None,
|
|
68
|
+
"home": callable(getattr(product, "home", None))})
|
|
69
|
+
for row in self.stale.values():
|
|
70
|
+
rows.append({"id": row["id"], "name": row["name"], "brand": row["brand"], "version": row["version"],
|
|
71
|
+
"ai": None, "can_open": True, "home": False,
|
|
72
|
+
"note": f"opens in its own window; from {row['registers_from']} its workspaces open here too"})
|
|
73
|
+
return rows
|
|
74
|
+
|
|
75
|
+
def _name(self, product_id: str) -> str:
|
|
76
|
+
product = self.products.get(product_id)
|
|
77
|
+
return product.name if product is not None else self.stale[product_id]["name"]
|
|
78
|
+
|
|
79
|
+
def _argv(self, product_id: str):
|
|
80
|
+
product = self.products.get(product_id)
|
|
81
|
+
if product is not None:
|
|
82
|
+
argv = product.app()
|
|
83
|
+
return list(argv) if argv else None
|
|
84
|
+
if product_id in self.stale:
|
|
85
|
+
return [sys.executable, *self.stale[product_id]["app"]]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _start(argv, product_id) -> int:
|
|
90
|
+
"""Start an application on its own, its output in the app's logs."""
|
|
91
|
+
from .paths import app_dir
|
|
92
|
+
|
|
93
|
+
logs = app_dir() / "logs"
|
|
94
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with open(logs / f"{product_id}.log", "ab") as log:
|
|
96
|
+
started = subprocess.Popen(list(argv), stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
|
|
97
|
+
start_new_session=True)
|
|
98
|
+
return started.pid
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""The app: spatialagents' Workspace page over every product that can hold a
|
|
2
|
+
workspace, and the person's applications on its home screen.
|
|
3
|
+
|
|
4
|
+
`server()` builds it (not started); `launch` is a bare `biocognia`, which
|
|
5
|
+
opens it in a browser where this looks like a desktop; `serve` is the same
|
|
6
|
+
without the browser, or with `--desktop` the desktop shell's sidecar (one
|
|
7
|
+
ready line, then until stdin closes). Like each product's own app it opens
|
|
8
|
+
without an AI licence: the page says so, and model calls in a workspace whose
|
|
9
|
+
product has none are refused by that product's gateway.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from . import __version__, registry
|
|
17
|
+
from .paths import DESKTOP_PORT, LAUNCH_PORT, app_dir
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("biocognia_intelligence.app")
|
|
20
|
+
|
|
21
|
+
TITLE = "Biocognia Intelligence"
|
|
22
|
+
TAGLINE = ("Choose a file, folder, or data location. The product that fits it opens it, "
|
|
23
|
+
"and AI helps you explore and interpret your data.")
|
|
24
|
+
|
|
25
|
+
#: Set where nobody is sitting at this machine's screen: a cluster job, CI, an
|
|
26
|
+
#: SSH login (the products' list).
|
|
27
|
+
HEADLESS = ("CI", "SLURM_JOB_ID", "PBS_JOBID", "LSB_JOBID", "SSH_CONNECTION", "SSH_CLIENT")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NoHome(RuntimeError):
|
|
31
|
+
"""No installed product can hold a workspace."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def homes_of(products) -> tuple[list, list]:
|
|
35
|
+
"""`([(product, home, opener)], [problem sentences])`: each product that
|
|
36
|
+
can hold a workspace, its Home and the opener it built."""
|
|
37
|
+
homes, problems = [], []
|
|
38
|
+
for product in products:
|
|
39
|
+
try:
|
|
40
|
+
home = product.home()
|
|
41
|
+
if home is None:
|
|
42
|
+
continue
|
|
43
|
+
homes.append((product, home, home.opener()))
|
|
44
|
+
except Exception as problem: # noqa: BLE001 -- the others still open
|
|
45
|
+
log.warning("%s cannot hold workspaces here", product.name, exc_info=True)
|
|
46
|
+
problems.append(f"{product.name} cannot hold workspaces here: {problem}")
|
|
47
|
+
return homes, problems
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def server(*, port=0, host="127.0.0.1", token=None, default=None, require_token=False, frame_ancestors=None,
|
|
51
|
+
products=None, stale=None, start=None, on_open=None):
|
|
52
|
+
"""A `ChatServer` for the app (not started). `products` and `stale` are
|
|
53
|
+
the registry's unless given; its `workspaces` attribute is the
|
|
54
|
+
`WorkspaceService` (close it on the way out), `problems` what could not
|
|
55
|
+
be opened."""
|
|
56
|
+
from spatialagents.chat.server import ChatServer
|
|
57
|
+
from spatialagents.workspace import static_dir
|
|
58
|
+
from spatialagents.workspace.http import WorkspaceApi
|
|
59
|
+
from spatialagents.workspace.service import WorkspaceService
|
|
60
|
+
|
|
61
|
+
from .api import IntelligenceApi
|
|
62
|
+
from .routing import RoutingOpener
|
|
63
|
+
|
|
64
|
+
if products is None:
|
|
65
|
+
products = registry.products()
|
|
66
|
+
stale = registry.too_old() if stale is None else stale
|
|
67
|
+
homes, problems = homes_of(products)
|
|
68
|
+
if not homes:
|
|
69
|
+
raise NoHome("no BioCognia product that holds workspaces is installed" +
|
|
70
|
+
(f" ({'; '.join(problems)})" if problems else ""))
|
|
71
|
+
routing = RoutingOpener(homes, default=default, on_open=on_open)
|
|
72
|
+
service = WorkspaceService(routing)
|
|
73
|
+
api = IntelligenceApi(WorkspaceApi(service), products, stale=stale or (), start=start)
|
|
74
|
+
page = {"assistant": TITLE, "product": TITLE, "brand": "biocognia", "version": __version__,
|
|
75
|
+
"tagline": TAGLINE, "products": routing.homes(), "apps": True}
|
|
76
|
+
found = ChatServer(api, host=host, port=int(port or 0), token=token, title=TITLE, page=page,
|
|
77
|
+
page_dir=static_dir(), frame_ancestors=tuple(frame_ancestors or ("'self'",)),
|
|
78
|
+
require_token=require_token)
|
|
79
|
+
found.workspaces = service
|
|
80
|
+
found.problems = problems
|
|
81
|
+
found.homes = [product.id for product, _, _ in homes]
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def wants_browser(preference="auto", env=None, system=None) -> bool:
|
|
86
|
+
"""Whether a bare `biocognia` opens a browser: always for `yes`, never for
|
|
87
|
+
`no`, and for `auto` on a desktop -- macOS, Windows, or Linux with a
|
|
88
|
+
display -- but not on a cluster node, in CI or over SSH."""
|
|
89
|
+
import os
|
|
90
|
+
import platform
|
|
91
|
+
|
|
92
|
+
if preference in ("yes", "no"):
|
|
93
|
+
return preference == "yes"
|
|
94
|
+
env = os.environ if env is None else env
|
|
95
|
+
system = platform.system() if system is None else system
|
|
96
|
+
if any(env.get(name) for name in HEADLESS):
|
|
97
|
+
return False
|
|
98
|
+
if system in ("Darwin", "Windows"):
|
|
99
|
+
return True
|
|
100
|
+
return bool(env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _bound(build, asked, fallback_port, error, **options):
|
|
104
|
+
"""`build(port=...)` on the port asked for, else the usual one, else any."""
|
|
105
|
+
try:
|
|
106
|
+
return build(port=asked or fallback_port, **options)
|
|
107
|
+
except OSError:
|
|
108
|
+
if asked:
|
|
109
|
+
error(f"biocognia: port {asked} is in use; choose another with --port, or leave it out")
|
|
110
|
+
return None
|
|
111
|
+
return build(port=0, **options)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _say_licences(found, error) -> None:
|
|
115
|
+
for problem in found.problems:
|
|
116
|
+
error(f"biocognia: {problem}")
|
|
117
|
+
for home in found.workspaces.opener.homes():
|
|
118
|
+
if not home["ai"]["ok"]:
|
|
119
|
+
error(f"biocognia: {home['name']}: {home['ai']['reason']}")
|
|
120
|
+
error(f" {home['name']} workspaces open; the assistant stays off in them until this device has "
|
|
121
|
+
f"its licence.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def launch(args, *, write, error, open_url=None) -> int:
|
|
125
|
+
"""`biocognia` alone: the app on its home screen, in a browser where this
|
|
126
|
+
looks like a desktop, until Ctrl-C."""
|
|
127
|
+
try:
|
|
128
|
+
found = _bound(server, getattr(args, "port", 0) or 0, LAUNCH_PORT, error,
|
|
129
|
+
default=getattr(args, "home", None))
|
|
130
|
+
except (NoHome, ValueError) as problem:
|
|
131
|
+
return _no_home(problem, error)
|
|
132
|
+
if found is None:
|
|
133
|
+
return 1
|
|
134
|
+
_say_licences(found, error)
|
|
135
|
+
write(TITLE)
|
|
136
|
+
write(f"Open {found.launch_url}")
|
|
137
|
+
write("The link works once; press Ctrl-C to stop.")
|
|
138
|
+
if wants_browser(getattr(args, "browser", "auto")):
|
|
139
|
+
if open_url is None:
|
|
140
|
+
import webbrowser
|
|
141
|
+
|
|
142
|
+
open_url = webbrowser.open
|
|
143
|
+
open_url(found.launch_url)
|
|
144
|
+
return _serve_until_stopped(found)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def serve(args, *, write, error, read=None) -> int:
|
|
148
|
+
"""`biocognia serve`: the app without a browser; with `--desktop`, the
|
|
149
|
+
desktop shell's sidecar."""
|
|
150
|
+
if getattr(args, "desktop", False):
|
|
151
|
+
return desktop(args, write=write, error=error, read=read)
|
|
152
|
+
try:
|
|
153
|
+
found = _bound(server, getattr(args, "port", 0) or 0, LAUNCH_PORT, error,
|
|
154
|
+
host=getattr(args, "host", None) or "127.0.0.1", default=getattr(args, "home", None))
|
|
155
|
+
except (NoHome, ValueError) as problem:
|
|
156
|
+
return _no_home(problem, error)
|
|
157
|
+
if found is None:
|
|
158
|
+
return 1
|
|
159
|
+
_say_licences(found, error)
|
|
160
|
+
write(f"{TITLE} at {found.launch_url}")
|
|
161
|
+
return _serve_until_stopped(found)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def ready_line(found, *, token: str) -> str:
|
|
165
|
+
"""The one line a desktop shell reads (spatialagents' `ready_line`)."""
|
|
166
|
+
from spatialagents.desktop import ready_line as line
|
|
167
|
+
|
|
168
|
+
host, port = found.address
|
|
169
|
+
return line(host=host, port=port, token=token, version=__version__, origin=found.url,
|
|
170
|
+
data_root=str(app_dir()), settings_path=str(app_dir() / "desktop.json"),
|
|
171
|
+
app="biocognia-intelligence", homes=list(found.homes))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def desktop(args, *, write, error, read=None) -> int:
|
|
175
|
+
"""The desktop shell's sidecar: one ready line, then serve until stdin closes."""
|
|
176
|
+
import os
|
|
177
|
+
import secrets
|
|
178
|
+
import signal
|
|
179
|
+
import sys
|
|
180
|
+
import threading
|
|
181
|
+
|
|
182
|
+
token = os.environ.get("SPATIALAGENTS_CHAT_TOKEN") or secrets.token_urlsafe(16)
|
|
183
|
+
try:
|
|
184
|
+
found = _bound(server, getattr(args, "port", 0) or 0, DESKTOP_PORT, error, token=token,
|
|
185
|
+
default=getattr(args, "home", None))
|
|
186
|
+
except (NoHome, ValueError) as problem:
|
|
187
|
+
return _no_home(problem, error)
|
|
188
|
+
if found is None:
|
|
189
|
+
return 1
|
|
190
|
+
write(ready_line(found, token=token))
|
|
191
|
+
stopped = threading.Event()
|
|
192
|
+
|
|
193
|
+
def stop(*_):
|
|
194
|
+
if not stopped.is_set():
|
|
195
|
+
stopped.set()
|
|
196
|
+
threading.Thread(target=found.stop, daemon=True).start()
|
|
197
|
+
|
|
198
|
+
def watch():
|
|
199
|
+
# The shell keeps our stdin open; when it goes away, so do we.
|
|
200
|
+
reader = read or (lambda: sys.stdin.read(1))
|
|
201
|
+
try:
|
|
202
|
+
while reader():
|
|
203
|
+
pass
|
|
204
|
+
except Exception: # noqa: BLE001 -- a closed stdin is a goodbye
|
|
205
|
+
pass
|
|
206
|
+
stop()
|
|
207
|
+
|
|
208
|
+
threading.Thread(target=watch, name="biocognia-desktop-stdin", daemon=True).start()
|
|
209
|
+
try:
|
|
210
|
+
signal.signal(signal.SIGTERM, stop)
|
|
211
|
+
except ValueError:
|
|
212
|
+
pass # not the main thread (a test)
|
|
213
|
+
try:
|
|
214
|
+
found.serve_forever()
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
pass
|
|
217
|
+
finally:
|
|
218
|
+
stop()
|
|
219
|
+
found.workspaces.close()
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _serve_until_stopped(found) -> int:
|
|
224
|
+
try:
|
|
225
|
+
found.serve_forever()
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
pass
|
|
228
|
+
finally:
|
|
229
|
+
found.stop()
|
|
230
|
+
found.workspaces.close()
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _no_home(problem, error) -> int:
|
|
235
|
+
error(f"biocognia: {problem}")
|
|
236
|
+
stale = registry.too_old()
|
|
237
|
+
for row in stale:
|
|
238
|
+
error(f" {row['name']} {row['version']} is installed but too old to open here; it registers from "
|
|
239
|
+
f"{row['registers_from']}.")
|
|
240
|
+
if not stale:
|
|
241
|
+
error(' Install one: pip install "biocognia-intelligence[scimappro]"')
|
|
242
|
+
return 2
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The `biocognia` command.
|
|
2
|
+
|
|
3
|
+
biocognia [--port N] [--browser | --no-browser] [--home ID] open the app
|
|
4
|
+
biocognia list [--json] [--licence] the products installed
|
|
5
|
+
biocognia open PRODUCT [ARGS ...] a product's own application
|
|
6
|
+
biocognia serve [--host H] [--port N] [--home ID] [--desktop]
|
|
7
|
+
biocognia license [--product ID] VERB the shared licence verbs
|
|
8
|
+
|
|
9
|
+
Each subcommand imports what it needs: `--help` and `list` load neither the
|
|
10
|
+
runtime nor a product.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="biocognia", description="Biocognia Intelligence: one AI workspace over the BioCognia products "
|
|
23
|
+
"installed. Alone, it opens in a browser; Ctrl-C stops it.")
|
|
24
|
+
parser.add_argument("--version", action="store_true", help="print the version and stop")
|
|
25
|
+
_serving(parser)
|
|
26
|
+
browser = parser.add_mutually_exclusive_group()
|
|
27
|
+
browser.add_argument("--browser", dest="browser", action="store_const", const="yes", default="auto",
|
|
28
|
+
help="open a browser even where this looks like a cluster, CI or SSH")
|
|
29
|
+
browser.add_argument("--no-browser", dest="browser", action="store_const", const="no",
|
|
30
|
+
help="print the app's address instead of opening it")
|
|
31
|
+
commands = parser.add_subparsers(dest="command")
|
|
32
|
+
listed = commands.add_parser("list", help="the BioCognia products installed")
|
|
33
|
+
listed.add_argument("--json", action="store_true", dest="as_json")
|
|
34
|
+
listed.add_argument("--licence", "--license", action="store_true", dest="licence",
|
|
35
|
+
help="and whether each one's AI licence lets a conversation run (imports each product)")
|
|
36
|
+
opened = commands.add_parser("open", help="a product's own application, in its own window")
|
|
37
|
+
opened.add_argument("product")
|
|
38
|
+
opened.add_argument("args", nargs=argparse.REMAINDER, help="passed to the application")
|
|
39
|
+
served = commands.add_parser("serve", help="the app without a browser (--desktop: a desktop shell's sidecar)")
|
|
40
|
+
_serving(served)
|
|
41
|
+
served.add_argument("--host", help="the address to serve on (default 127.0.0.1)")
|
|
42
|
+
served.add_argument("--desktop", action="store_true", help="print one ready line and stop when stdin closes")
|
|
43
|
+
licence = commands.add_parser("license", help="this device's licences, for every product installed")
|
|
44
|
+
from . import licence as licence_verbs
|
|
45
|
+
|
|
46
|
+
licence_verbs.add_arguments(licence)
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _serving(parser) -> None:
|
|
51
|
+
parser.add_argument("--port", type=int, default=0, help="the port to serve on (default 8480, or a free one)")
|
|
52
|
+
parser.add_argument("--home", help="the product a new workspace belongs to unless the page says "
|
|
53
|
+
"(`biocognia list` names them)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv=None) -> int:
|
|
57
|
+
args = build_parser().parse_args(argv)
|
|
58
|
+
write = lambda line: print(line, flush=True) # noqa: E731
|
|
59
|
+
error = lambda line: print(line, file=sys.stderr, flush=True) # noqa: E731
|
|
60
|
+
if args.version:
|
|
61
|
+
from . import __version__
|
|
62
|
+
|
|
63
|
+
write(__version__)
|
|
64
|
+
return 0
|
|
65
|
+
if args.command == "list":
|
|
66
|
+
return list_products(args, write=write)
|
|
67
|
+
if args.command == "open":
|
|
68
|
+
return open_product(args, error=error)
|
|
69
|
+
if args.command == "license":
|
|
70
|
+
from . import licence
|
|
71
|
+
|
|
72
|
+
return licence.run(args, out=write, err=error)
|
|
73
|
+
from . import app
|
|
74
|
+
|
|
75
|
+
if args.command == "serve":
|
|
76
|
+
return app.serve(args, write=write, error=error)
|
|
77
|
+
return app.launch(args, write=write, error=error)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rows(*, licence: bool = False) -> list:
|
|
81
|
+
"""The products installed, as `biocognia list` says them."""
|
|
82
|
+
from . import registry
|
|
83
|
+
|
|
84
|
+
found = []
|
|
85
|
+
for product in registry.products():
|
|
86
|
+
row = {"id": product.id, "name": product.name, "version": registry.version_of(product.distribution),
|
|
87
|
+
"home": callable(getattr(product, "home", None)), "app": True}
|
|
88
|
+
if licence:
|
|
89
|
+
from .routing import _ai
|
|
90
|
+
|
|
91
|
+
row["ai"] = _ai(product)
|
|
92
|
+
found.append(row)
|
|
93
|
+
for stale in registry.too_old():
|
|
94
|
+
found.append({"id": stale["id"], "name": stale["name"], "version": stale["version"], "home": False,
|
|
95
|
+
"app": True, "too_old": True, "registers_from": stale["registers_from"]})
|
|
96
|
+
return found
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def list_products(args, *, write) -> int:
|
|
100
|
+
found = rows(licence=args.licence)
|
|
101
|
+
if args.as_json:
|
|
102
|
+
write(json.dumps(found, indent=1))
|
|
103
|
+
return 0
|
|
104
|
+
if not found:
|
|
105
|
+
write('No BioCognia product is installed. pip install "biocognia-intelligence[scimappro]"')
|
|
106
|
+
return 0
|
|
107
|
+
width = max(len(f"{row['name']} {row['version'] or ''}") for row in found)
|
|
108
|
+
for row in found:
|
|
109
|
+
name = f"{row['name']} {row['version'] or ''}".ljust(width)
|
|
110
|
+
if row.get("too_old"):
|
|
111
|
+
says = f"too old to open here (registers from {row['registers_from']}); `biocognia open {row['id']}`"
|
|
112
|
+
else:
|
|
113
|
+
says = "workspaces open here" if row["home"] else "its own application"
|
|
114
|
+
if "ai" in row:
|
|
115
|
+
says += " · " + ("AI included" if row["ai"]["ok"] else f"no AI: {row['ai']['reason']}")
|
|
116
|
+
write(f"{name} {says}")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def open_product(args, *, error) -> int:
|
|
121
|
+
"""A product's own application, in the foreground: this command waits for it."""
|
|
122
|
+
import subprocess
|
|
123
|
+
|
|
124
|
+
from . import registry
|
|
125
|
+
|
|
126
|
+
product = registry.find(args.product)
|
|
127
|
+
if product is not None:
|
|
128
|
+
argv = product.app()
|
|
129
|
+
else:
|
|
130
|
+
stale = next((row for row in registry.too_old() if row["id"] == args.product), None)
|
|
131
|
+
argv = [sys.executable, *stale["app"]] if stale else None
|
|
132
|
+
if not argv:
|
|
133
|
+
known = [row["id"] for row in rows()]
|
|
134
|
+
error(f"biocognia: no application {args.product!r} is installed"
|
|
135
|
+
+ (f"; installed: {', '.join(known)}" if known else ""))
|
|
136
|
+
return 2
|
|
137
|
+
try:
|
|
138
|
+
return subprocess.call([*argv, *args.args])
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
return 130
|