gemx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gemx/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Gemx — drive the Gemini web UI from Python.
2
+
3
+ A play on "Gemini". Treats ``gemini.google.com`` as if it were an API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .client import Gemx, GemxConfig
9
+ from .errors import (
10
+ GemxError,
11
+ InputError,
12
+ ResponseParseError,
13
+ ResponseTimeoutError,
14
+ )
15
+ from .formats import OutputFormat, format_instruction, parse_output
16
+
17
+ __all__ = [
18
+ "Gemx",
19
+ "GemxConfig",
20
+ "GemxError",
21
+ "InputError",
22
+ "OutputFormat",
23
+ "ResponseParseError",
24
+ "ResponseTimeoutError",
25
+ "format_instruction",
26
+ "parse_output",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
gemx/cli.py ADDED
@@ -0,0 +1,114 @@
1
+ """Command-line interface: ``gemx``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import sys
10
+ from pathlib import Path
11
+ from xml.etree import ElementTree as ET
12
+
13
+ from . import __version__
14
+ from .client import Gemx, GemxConfig
15
+ from .errors import GemxError
16
+ from .formats import OutputFormat
17
+
18
+ DEFAULT_PROFILE_DIR = Path("~/.gemx/profile")
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ """Construct the ``gemx`` argument parser."""
23
+ parser = argparse.ArgumentParser(
24
+ prog="gemx",
25
+ description="Drive the Gemini web UI from the command line.",
26
+ )
27
+ parser.add_argument("--version", action="version", version=f"gemx {__version__}")
28
+ parser.add_argument(
29
+ "prompt",
30
+ nargs="?",
31
+ help="Prompt to send. If omitted, read from stdin.",
32
+ )
33
+ parser.add_argument(
34
+ "-f",
35
+ "--format",
36
+ type=OutputFormat.from_str,
37
+ default=OutputFormat.JSON,
38
+ metavar="{json,xml,txt}",
39
+ help="Output format requested from Gemini (default: json).",
40
+ )
41
+ parser.add_argument(
42
+ "-p",
43
+ "--profile-dir",
44
+ type=Path,
45
+ default=DEFAULT_PROFILE_DIR,
46
+ help=f"Chrome profile dir for the session (default: {DEFAULT_PROFILE_DIR}).",
47
+ )
48
+ parser.add_argument(
49
+ "--headful",
50
+ action="store_true",
51
+ help="Show the browser window instead of running headless.",
52
+ )
53
+ parser.add_argument(
54
+ "--response-timeout",
55
+ type=int,
56
+ default=180,
57
+ metavar="SECONDS",
58
+ help="Max seconds to wait for a response to start (default: 180).",
59
+ )
60
+ parser.add_argument(
61
+ "-v",
62
+ "--verbose",
63
+ action="store_true",
64
+ help="Log progress to stderr.",
65
+ )
66
+ return parser
67
+
68
+
69
+ def _render(value: object, fmt: OutputFormat) -> str:
70
+ """Serialize a parsed value back to a string for stdout."""
71
+ if fmt is OutputFormat.JSON:
72
+ return json.dumps(value, indent=2, ensure_ascii=False)
73
+ if fmt is OutputFormat.XML:
74
+ assert isinstance(value, ET.Element)
75
+ return ET.tostring(value, encoding="unicode")
76
+ return str(value)
77
+
78
+
79
+ async def _run(args: argparse.Namespace) -> int:
80
+ prompt = args.prompt if args.prompt is not None else sys.stdin.read()
81
+ prompt = prompt.strip()
82
+ if not prompt:
83
+ print("error: empty prompt", file=sys.stderr)
84
+ return 2
85
+
86
+ config = GemxConfig(
87
+ profile_dir=args.profile_dir,
88
+ headless=not args.headful,
89
+ response_timeout_s=args.response_timeout,
90
+ )
91
+ try:
92
+ async with Gemx(config) as gemx:
93
+ result = await gemx.ask(prompt, args.format)
94
+ except GemxError as exc:
95
+ print(f"error: {exc}", file=sys.stderr)
96
+ return 1
97
+
98
+ print(_render(result, args.format))
99
+ return 0
100
+
101
+
102
+ def main(argv: list[str] | None = None) -> int:
103
+ """Entry point for the ``gemx`` console script."""
104
+ args = build_parser().parse_args(argv)
105
+ logging.basicConfig(
106
+ level=logging.INFO if args.verbose else logging.WARNING,
107
+ format="%(levelname)s %(name)s: %(message)s",
108
+ stream=sys.stderr,
109
+ )
110
+ return asyncio.run(_run(args))
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
gemx/client.py ADDED
@@ -0,0 +1,235 @@
1
+ """Drive the Gemini web UI via Playwright.
2
+
3
+ This is a formalized version of a hack that drives ``gemini.google.com/app`` as
4
+ if it were an API. The non-obvious parts (and why this exists):
5
+
6
+ * **Input** must go through ``document.execCommand('insertText')``. Gemini's
7
+ editor is Quill, which keeps its own document model and ignores DOM surgery,
8
+ Playwright ``fill()``, and synthetic ``InputEvent``s — those leave the model
9
+ empty and the turn errors with "I encountered an error". ``execCommand`` fires
10
+ the trusted ``beforeinput``/``input`` pair Quill honors, exactly like a paste.
11
+ * **The response** lives in ``message-content .markdown`` (not a bare
12
+ ``.markdown``), and Gemini re-mounts / empties that node after streaming, so we
13
+ retain the *peak* text seen rather than reading the DOM once at the end.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from playwright.async_api import Page, async_playwright
25
+
26
+ from .errors import InputError, ResponseTimeoutError
27
+ from .formats import OutputFormat, format_instruction, parse_output
28
+
29
+ logger = logging.getLogger("gemx")
30
+
31
+ GEMINI_URL = "https://gemini.google.com/app"
32
+ INPUT_SELECTOR = '.ql-editor[contenteditable="true"]'
33
+ SEND_SELECTOR = 'button[aria-label="Send message"]'
34
+ RESPONSE_SELECTOR = "message-content .markdown"
35
+
36
+ _INSERT_TEXT_JS = """(text) => {
37
+ const editor = document.querySelector('.ql-editor[contenteditable="true"]');
38
+ if (!editor) return false;
39
+ editor.focus();
40
+ const sel = window.getSelection();
41
+ sel.removeAllRanges();
42
+ const range = document.createRange();
43
+ range.selectNodeContents(editor);
44
+ range.collapse(false);
45
+ sel.addRange(range);
46
+ return document.execCommand('insertText', false, text);
47
+ }"""
48
+
49
+ _LONGEST_RESPONSE_JS = """(args) => {
50
+ const [selector, initialCount] = args;
51
+ const els = document.querySelectorAll(selector);
52
+ if (els.length <= initialCount) return '';
53
+ let best = '';
54
+ els.forEach(el => {
55
+ const t = el.innerText || el.textContent || '';
56
+ if (t.length > best.length) best = t;
57
+ });
58
+ return best;
59
+ }"""
60
+
61
+ _DISMISS_WELCOME_JS = """() => {
62
+ const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
63
+ for (const btn of buttons) {
64
+ const text = btn.innerText || btn.textContent || '';
65
+ if (text.match(/continue|get started|skip|next|accept|try gemini/i)) {
66
+ btn.click();
67
+ return true;
68
+ }
69
+ }
70
+ return false;
71
+ }"""
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class GemxConfig: # pylint: disable=too-many-instance-attributes
76
+ """Tunables for a :class:`Gemx` session."""
77
+
78
+ profile_dir: Path
79
+ headless: bool = True
80
+ nav_timeout_ms: int = 60_000
81
+ input_timeout_ms: int = 30_000
82
+ response_timeout_s: int = 180
83
+ stabilization_timeout_s: int = 120
84
+ poll_interval_s: int = 2
85
+ viewport_width: int = 1280
86
+ viewport_height: int = 720
87
+ user_agent: str = (
88
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
89
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
90
+ )
91
+ launch_args: tuple[str, ...] = field(
92
+ default=(
93
+ "--disable-blink-features=AutomationControlled",
94
+ "--disable-dev-shm-usage",
95
+ "--no-sandbox",
96
+ "--disable-setuid-sandbox",
97
+ )
98
+ )
99
+
100
+
101
+ class Gemx:
102
+ """A Gemini web-UI session.
103
+
104
+ Use as an async context manager so the browser is cleaned up::
105
+
106
+ async with Gemx(GemxConfig(profile_dir=Path("~/.gemx/profile"))) as gemx:
107
+ data = await gemx.ask("List 3 fruits", OutputFormat.JSON)
108
+ """
109
+
110
+ def __init__(self, config: GemxConfig) -> None:
111
+ self._config = config
112
+
113
+ async def ask(
114
+ self, prompt: str, fmt: OutputFormat = OutputFormat.JSON
115
+ ) -> Any:
116
+ """Send ``prompt`` to Gemini and return its reply parsed as ``fmt``.
117
+
118
+ The format instruction is appended to the prompt so Gemini emits the
119
+ requested shape.
120
+
121
+ Raises:
122
+ InputError: If the prompt could not be entered.
123
+ ResponseTimeoutError: If no response arrived in time.
124
+ ResponseParseError: If the reply could not be parsed as ``fmt``.
125
+ """
126
+ full_prompt = f"{prompt}\n\n{format_instruction(fmt)}"
127
+ raw = await self.ask_raw(full_prompt)
128
+ return parse_output(raw, fmt)
129
+
130
+ async def ask_raw(self, prompt: str) -> str:
131
+ """Send ``prompt`` verbatim and return Gemini's raw reply text."""
132
+ cfg = self._config
133
+ profile = cfg.profile_dir.expanduser()
134
+ async with async_playwright() as p:
135
+ context = await p.chromium.launch_persistent_context(
136
+ str(profile),
137
+ headless=cfg.headless,
138
+ args=list(cfg.launch_args),
139
+ user_agent=cfg.user_agent,
140
+ viewport={
141
+ "width": cfg.viewport_width,
142
+ "height": cfg.viewport_height,
143
+ },
144
+ )
145
+ try:
146
+ page = (
147
+ context.pages[0] if context.pages else await context.new_page()
148
+ )
149
+ await self._navigate(page)
150
+ await self._enter_prompt(page, prompt)
151
+ await self._submit(page)
152
+ return await self._await_response(page)
153
+ finally:
154
+ await context.close()
155
+
156
+ async def _navigate(self, page: Page) -> None:
157
+ cfg = self._config
158
+ await page.goto(GEMINI_URL, wait_until="load", timeout=cfg.nav_timeout_ms)
159
+ await page.wait_for_load_state("domcontentloaded")
160
+ await page.wait_for_load_state("networkidle", timeout=30_000)
161
+ await page.wait_for_timeout(5_000)
162
+
163
+ body_text = await page.evaluate("() => document.body.innerText || ''")
164
+ if "welcome to gemini" in body_text.lower():
165
+ logger.info("welcome screen detected; dismissing")
166
+ dismissed = await page.evaluate(_DISMISS_WELCOME_JS)
167
+ await page.wait_for_timeout(3_000 if dismissed else 1_000)
168
+
169
+ await page.wait_for_selector(INPUT_SELECTOR, timeout=cfg.input_timeout_ms)
170
+
171
+ async def _enter_prompt(self, page: Page, prompt: str) -> None:
172
+ await page.click(INPUT_SELECTOR)
173
+ ok: bool = await page.evaluate(_INSERT_TEXT_JS, prompt)
174
+ entered = (await page.locator(INPUT_SELECTOR).first.inner_text()).strip()
175
+ logger.info("entered prompt: ok=%s chars=%d", ok, len(entered))
176
+ if not ok or not entered:
177
+ raise InputError("Quill did not accept the prompt text")
178
+
179
+ async def _submit(self, page: Page) -> None:
180
+ await page.wait_for_selector(
181
+ SEND_SELECTOR, timeout=self._config.input_timeout_ms
182
+ )
183
+ await page.click(SEND_SELECTOR)
184
+
185
+ async def _await_response(self, page: Page) -> str:
186
+ cfg = self._config
187
+ initial = await page.evaluate(
188
+ "(s) => document.querySelectorAll(s).length", RESPONSE_SELECTOR
189
+ )
190
+
191
+ # Wait for a new response node to appear.
192
+ elapsed = 0
193
+ while elapsed < cfg.response_timeout_s:
194
+ count = await page.evaluate(
195
+ "(s) => document.querySelectorAll(s).length", RESPONSE_SELECTOR
196
+ )
197
+ if count > initial:
198
+ break
199
+ await asyncio.sleep(cfg.poll_interval_s)
200
+ elapsed += cfg.poll_interval_s
201
+ else:
202
+ raise ResponseTimeoutError(
203
+ f"No response node after {cfg.response_timeout_s}s"
204
+ )
205
+
206
+ # Stream until the longest node's text stops growing, retaining the peak.
207
+ best = ""
208
+ last_len = 0
209
+ stable = 0
210
+ waited = 0
211
+ while waited < cfg.stabilization_timeout_s:
212
+ await asyncio.sleep(cfg.poll_interval_s)
213
+ waited += cfg.poll_interval_s
214
+ current: str = await page.evaluate(
215
+ _LONGEST_RESPONSE_JS, [RESPONSE_SELECTOR, initial]
216
+ )
217
+ if len(current) > len(best):
218
+ best = current
219
+ if len(current) > last_len:
220
+ last_len = len(current)
221
+ stable = 0
222
+ elif best:
223
+ stable += 1
224
+ if stable >= 2 and len(best) > 0:
225
+ break
226
+
227
+ if not best:
228
+ raise ResponseTimeoutError("Response node never produced text")
229
+ return best
230
+
231
+ async def __aenter__(self) -> Gemx:
232
+ return self
233
+
234
+ async def __aexit__(self, *_exc: object) -> None:
235
+ return None
gemx/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Exceptions raised by Gemx."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class GemxError(Exception):
7
+ """Base class for all Gemx errors."""
8
+
9
+
10
+ class InputError(GemxError):
11
+ """The prompt could not be entered into Gemini's editor."""
12
+
13
+
14
+ class ResponseTimeoutError(GemxError):
15
+ """Gemini did not produce a response within the configured window."""
16
+
17
+
18
+ class ResponseParseError(GemxError):
19
+ """Gemini responded, but the payload could not be parsed for the format."""
gemx/formats.py ADDED
@@ -0,0 +1,103 @@
1
+ """Output formats Gemx can request from Gemini.
2
+
3
+ The chosen format is injected into the prompt as an instruction and also drives
4
+ how Gemx extracts the structured payload from Gemini's reply.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from enum import StrEnum
12
+ from typing import Any
13
+ from xml.etree import ElementTree as ET
14
+
15
+
16
+ class OutputFormat(StrEnum):
17
+ """Structured output formats Gemx understands."""
18
+
19
+ JSON = "json"
20
+ XML = "xml"
21
+ TXT = "txt"
22
+
23
+ @classmethod
24
+ def from_str(cls, value: str) -> OutputFormat:
25
+ """Parse a format name case-insensitively.
26
+
27
+ Raises:
28
+ ValueError: If ``value`` is not a supported format.
29
+ """
30
+ try:
31
+ return cls(value.strip().lower())
32
+ except ValueError as exc:
33
+ supported = ", ".join(f.value for f in cls)
34
+ raise ValueError(
35
+ f"Unsupported output format {value!r}; expected one of: {supported}"
36
+ ) from exc
37
+
38
+
39
+ def format_instruction(fmt: OutputFormat) -> str:
40
+ """Return the prompt instruction that asks Gemini for ``fmt`` output."""
41
+ if fmt is OutputFormat.JSON:
42
+ return (
43
+ "Respond with a single valid JSON object only. Do not include prose, "
44
+ "markdown fences, or commentary outside the JSON."
45
+ )
46
+ if fmt is OutputFormat.XML:
47
+ return (
48
+ "Respond with a single well-formed XML document only. Do not include "
49
+ "prose, markdown fences, or commentary outside the XML."
50
+ )
51
+ return (
52
+ "Respond with plain text only. Do not include markdown fences, JSON, or "
53
+ "XML."
54
+ )
55
+
56
+
57
+ def _strip_fences(text: str) -> str:
58
+ """Strip a leading format label and surrounding markdown code fences."""
59
+ text = text.strip()
60
+ text = re.sub(r"^(JSON|XML)\s*\n?", "", text, flags=re.IGNORECASE)
61
+ fence = re.search(r"```[a-zA-Z]*\s*(.*?)```", text, flags=re.DOTALL)
62
+ if fence:
63
+ return fence.group(1).strip()
64
+ return text
65
+
66
+
67
+ def _clean_json_text(text: str) -> str:
68
+ """Best-effort repair of common JSON quirks in LLM output."""
69
+ text = text.strip()
70
+ text = re.sub(r",\s*([}\]])", r"\1", text)
71
+ return text
72
+
73
+
74
+ def parse_output(text: str, fmt: OutputFormat) -> Any:
75
+ """Extract a structured value from Gemini's raw reply for ``fmt``.
76
+
77
+ JSON yields a ``dict``/``list``, XML yields an
78
+ :class:`xml.etree.ElementTree.Element`, and TXT yields the cleaned ``str``.
79
+
80
+ Raises:
81
+ ValueError: If the payload cannot be parsed as ``fmt``.
82
+ """
83
+ cleaned = _strip_fences(text)
84
+
85
+ if fmt is OutputFormat.TXT:
86
+ return cleaned
87
+
88
+ if fmt is OutputFormat.JSON:
89
+ start = cleaned.find("{")
90
+ end = cleaned.rfind("}") + 1
91
+ candidate = cleaned[start:end] if start != -1 and end != 0 else cleaned
92
+ try:
93
+ return json.loads(_clean_json_text(candidate))
94
+ except json.JSONDecodeError as exc:
95
+ raise ValueError(f"Could not parse JSON from response: {exc}") from exc
96
+
97
+ start = cleaned.find("<")
98
+ end = cleaned.rfind(">") + 1
99
+ candidate = cleaned[start:end] if start != -1 and end != 0 else cleaned
100
+ try:
101
+ return ET.fromstring(candidate)
102
+ except ET.ParseError as exc:
103
+ raise ValueError(f"Could not parse XML from response: {exc}") from exc
gemx/py.typed ADDED
File without changes
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: gemx
3
+ Version: 0.1.0
4
+ Summary: Drive the Gemini web UI from Python via Playwright
5
+ Project-URL: Homepage, https://github.com/ra0x3/gemx
6
+ Project-URL: Repository, https://github.com/ra0x3/gemx
7
+ Project-URL: Issues, https://github.com/ra0x3/gemx/issues
8
+ Author: ra0x3
9
+ Maintainer: ra0x3
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: automation,browser,gemini,llm,playwright,scraping
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: playwright>=1.49
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Gemx
24
+
25
+ **Drive the Gemini web UI from Python.** A play on "Gemini" — Gemx treats
26
+ `gemini.google.com` as if it were an API, using Playwright to enter a prompt,
27
+ submit it, and capture the structured reply.
28
+
29
+ It exists because the obvious approaches don't work: Gemini's editor is
30
+ [Quill](https://quilljs.com/), which keeps its own document model and ignores
31
+ DOM surgery, Playwright `fill()`, and synthetic input events — those leave the
32
+ model empty and the turn errors with *"I encountered an error."* Gemx injects
33
+ text via `execCommand('insertText')` (the same trusted input pipeline a manual
34
+ paste uses), reads the reply from `message-content .markdown`, and retains the
35
+ *peak* streamed text because Gemini re-mounts the response node mid-stream.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ uv add gemx
41
+ # Playwright needs a browser the first time:
42
+ uv run playwright install chromium
43
+ ```
44
+
45
+ ## CLI
46
+
47
+ ```bash
48
+ # JSON (default)
49
+ gemx "List 3 NBA teams as a JSON array"
50
+
51
+ # XML
52
+ gemx --format xml "Describe the solar system as XML"
53
+
54
+ # Plain text, reading the prompt from stdin
55
+ echo "Summarize the plot of Dune in one sentence" | gemx --format txt
56
+
57
+ # Watch the browser while it works
58
+ gemx --headful --verbose "Hello there"
59
+ ```
60
+
61
+ The chosen `--format` (`json`, `xml`, or `txt`) is appended to the prompt as an
62
+ instruction *and* drives how Gemx parses the reply.
63
+
64
+ | Option | Description |
65
+ | --- | --- |
66
+ | `-f, --format {json,xml,txt}` | Output format (default: `json`). |
67
+ | `-p, --profile-dir PATH` | Chrome profile dir (default: `~/.gemx/profile`). |
68
+ | `--headful` | Show the browser window. |
69
+ | `--response-timeout SECONDS` | Wait for a response to start (default: 180). |
70
+ | `-v, --verbose` | Log progress to stderr. |
71
+
72
+ ## Library
73
+
74
+ ```python
75
+ import asyncio
76
+ from pathlib import Path
77
+ from gemx import Gemx, GemxConfig, OutputFormat
78
+
79
+
80
+ async def main() -> None:
81
+ config = GemxConfig(profile_dir=Path("~/.gemx/profile"))
82
+ async with Gemx(config) as gemx:
83
+ data = await gemx.ask("List 3 fruits as JSON", OutputFormat.JSON)
84
+ print(data)
85
+
86
+
87
+ asyncio.run(main())
88
+ ```
89
+
90
+ ## Authentication
91
+
92
+ Gemx drives a real, signed-in Gemini session. Point `--profile-dir` at a Chrome
93
+ profile that is already logged into your Google account (run once with
94
+ `--headful` to sign in); subsequent runs reuse that profile.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ uv sync
100
+ uv run ruff check .
101
+ uv run mypy
102
+ uv run pylint src/gemx
103
+ uv run pytest
104
+ ```
105
+
106
+ The browser-console debugging scripts used to discover and verify the current
107
+ Gemini selectors live in [`tests/scripts/`](tests/scripts/).
108
+
109
+ ## License
110
+
111
+ MIT © ra0x3 — [Stonehedge Labs](https://github.com/stonehedgelabs)
@@ -0,0 +1,11 @@
1
+ gemx/__init__.py,sha256=5_lo6aTFGHef-6oOs7l0V-WL3NjXy9fy2-VHYjUpYi8,595
2
+ gemx/cli.py,sha256=AEZOVzovGtd3fTcZhaNFv1pcGHWlg2rx1SJ_d6tiJbQ,3232
3
+ gemx/client.py,sha256=b3sprX_Esv5nhI0I-97pCw-Ki-0nzJhEvu8jcsMGd4I,8572
4
+ gemx/errors.py,sha256=e0h-gLm9NR_FZpYErdzwOOfSLhuo28c1ryJUSrJXQlo,470
5
+ gemx/formats.py,sha256=d9jTaKV9oaGa6iruiM95sHMxWf5nGgZ99NsaGhtcR4k,3308
6
+ gemx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ gemx-0.1.0.dist-info/METADATA,sha256=3IxArBlr1CGkfHWIFUGsf2ZtyTxCoKCW6GNm9umWMx8,3393
8
+ gemx-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ gemx-0.1.0.dist-info/entry_points.txt,sha256=NMTvUjDGNXOZOarNBSA93tpiLgyb-sARANVi0lHHDV4,39
10
+ gemx-0.1.0.dist-info/licenses/LICENSE,sha256=jd9AQQBkmyuf_q0BrYApY8xmkzWxEKlYDCSmlh6a31c,1080
11
+ gemx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gemx = gemx.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ra0x3 / Stonehedge Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.