File size: 8,399 Bytes
2a87ede
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""

`dense-evolution` console script (see pyproject.toml [project.scripts]).



Three real subcommands:

  serve             starts the local Composer kernel (local_site.app.server)

                    that the published Composer page (docs/composer.md)

                    talks to.

  offline-composer  downloads the real published Composer page (the same

                    HTML Github Pages serves, not a hand-rolled copy) plus

                    the same-origin assets it references, into a local

                    folder -- so it opens via file:// with no internet at

                    all, while still talking to the local kernel above.

  mcp               starts the dense_evolution_mcp MCP server (mcp_server/),

                    a thin adapter that exposes the same Composer kernel

                    endpoints as MCP tools for an agent to call directly.

                    Requires `serve` running separately -- this process

                    talks to that kernel over HTTP, it doesn't embed it.



fastapi/uvicorn/pydantic (composer) and mcp/httpx (mcp) are optional

extras, not core dependencies -- imported here, inside their own command

branch, not at module level, so `import dense_evolution` itself never

requires them.

"""

import sys

USAGE = """usage: dense-evolution <command>



commands:

  serve                    Start the local Composer kernel (http://127.0.0.1:8800)

                           that the published Composer page (docs/composer.md)

                           connects to.

  offline-composer [DEST]  Download the real published Composer page and its

                           assets into DEST (default: ./composer-offline) so

                           it works via file:// with no internet.

  mcp                      Start the dense_evolution_mcp MCP server (stdio

                           transport) so an agent can call the Composer

                           kernel's endpoints directly. Requires `serve`

                           running separately (default http://127.0.0.1:8800,

                           override with DENSE_EVOLUTION_KERNEL_URL).



Requires the composer extra: pip install dense-evolution[composer]

The mcp command requires the mcp extra: pip install dense-evolution[mcp]

"""

COMPOSER_PAGE_URL = "https://tatopenn-cell.github.io/Dense-Evolution/composer/"


def _require_composer_extra():
    try:
        import fastapi, uvicorn, pydantic  # noqa: F401
    except ImportError as exc:
        print(
            "dense-evolution needs the composer extra:\n"
            "  pip install dense-evolution[composer]\n"
            f"(missing: {exc.name})",
            file=sys.stderr,
        )
        sys.exit(1)


def _cmd_serve():
    _require_composer_extra()
    from local_site.app.server import main as serve_main
    serve_main()


def _require_mcp_extra():
    try:
        import mcp, httpx  # noqa: F401
    except ImportError as exc:
        print(
            "dense-evolution needs the mcp extra:\n"
            "  pip install dense-evolution[mcp]\n"
            f"(missing: {exc.name})",
            file=sys.stderr,
        )
        sys.exit(1)


def _cmd_mcp():
    _require_mcp_extra()
    from mcp_server.server import main as mcp_main
    mcp_main()


def _cmd_offline_composer(dest: str):
    """Mirrors COMPOSER_PAGE_URL into `dest`: the page itself plus every

    same-origin <link href>/<script src> it references (Material's shared

    theme CSS/JS bundle, the Composer-specific app.js/style.css), each

    saved at the same relative path it already uses on the live site --

    mkdocs/Material already generate those as page-relative, precisely so

    a subtree like this stays self-consistent once copied elsewhere. Not a

    full recursive mirror (fonts referenced only from inside a CSS url()

    are not followed -- Material falls back to system fonts without them,

    a cosmetic gap, not a functional one): scoped to what the page's own

    <head>/<body> actually link to, which is everything the Composer UI

    itself needs to run."""
    import os
    import urllib.request
    from html.parser import HTMLParser
    from urllib.parse import urljoin, urlparse

    class _AssetFinder(HTMLParser):
        def __init__(self):
            super().__init__()
            self.assets = []

        def handle_starttag(self, tag, attrs):
            attrs = dict(attrs)
            # mkdocs Material's <head> has plenty of other <link> rels
            # (canonical, prev, next, preconnect, ...) that point at other
            # *pages* (directories, not files) or aren't fetchable assets
            # at all -- verified directly against the real page: following
            # rel="prev"/"next" tried to write a file at a directory-shaped
            # path and failed with a permission error. Only stylesheet/icon
            # links are real same-origin files worth mirroring.
            if tag == "link" and attrs.get("href") and attrs.get("rel") in ("stylesheet", "icon"):
                self.assets.append(attrs["href"])
            elif tag == "script" and attrs.get("src"):
                self.assets.append(attrs["src"])
            elif tag == "img" and attrs.get("src"):
                self.assets.append(attrs["src"])

    print(f"Scarico {COMPOSER_PAGE_URL} ...")
    with urllib.request.urlopen(COMPOSER_PAGE_URL, timeout=30) as resp:
        html_bytes = resp.read()
    html_text = html_bytes.decode("utf-8", errors="replace")

    parser = _AssetFinder()
    parser.feed(html_text)

    page_url = urlparse(COMPOSER_PAGE_URL)
    base_origin = f"{page_url.scheme}://{page_url.netloc}"
    # mkdocs generates every relative link (../assets/...) relative to the
    # SITE root, not to whatever folder happens to hold index.html -- so
    # the page itself has to be saved at the same depth under `dest` it
    # already has under the site root (site_root_path stripped from its
    # own path leaves "composer/"), or its own "../assets/..." references
    # end up pointing one directory above `dest` instead of inside it.
    # site_root_path is derived from COMPOSER_PAGE_URL itself (this
    # project's one fixed, known URL), not guessed from each asset URL.
    site_root_path = page_url.path.rsplit("composer/", 1)[0]

    def _relative_to_site_root(url: str) -> str:
        path = urlparse(url).path
        if path.startswith(site_root_path):
            path = path[len(site_root_path):]
        return path.lstrip("/")

    os.makedirs(dest, exist_ok=True)
    page_relative_path = _relative_to_site_root(COMPOSER_PAGE_URL) + "index.html"
    page_local_path = os.path.join(dest, page_relative_path)
    os.makedirs(os.path.dirname(page_local_path), exist_ok=True)
    with open(page_local_path, "w", encoding="utf-8") as f:
        f.write(html_text)

    seen = set()
    for href in parser.assets:
        absolute_url = urljoin(COMPOSER_PAGE_URL, href)
        if not absolute_url.startswith(base_origin) or absolute_url in seen:
            continue  # a genuinely external resource (e.g. a CDN) -- not ours to mirror
        seen.add(absolute_url)

        relative_path = _relative_to_site_root(absolute_url)
        local_path = os.path.join(dest, relative_path)
        os.makedirs(os.path.dirname(local_path), exist_ok=True)
        try:
            with urllib.request.urlopen(absolute_url, timeout=30) as resp:
                data = resp.read()
            with open(local_path, "wb") as f:
                f.write(data)
            print(f"  scaricato: {relative_path}")
        except Exception as exc:
            print(f"  saltato (non essenziale): {relative_path} ({exc})")

    print(f"\nCopia offline pronta: {page_local_path}")
    return page_local_path


def main(argv=None):
    args = argv if argv is not None else sys.argv[1:]
    if args == ["serve"]:
        _cmd_serve()
        return
    if args and args[0] == "offline-composer":
        dest = args[1] if len(args) > 1 else "composer-offline"
        _cmd_offline_composer(dest)
        return
    if args == ["mcp"]:
        _cmd_mcp()
        return

    print(USAGE, file=sys.stderr if args else sys.stdout)
    sys.exit(1 if args else 0)


if __name__ == "__main__":
    main()