hello-127 0.1.1__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.
hello_127/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """hello-127: carry TCP over a container's standard streams to Ollama on loopback."""
hello_127/args.py ADDED
@@ -0,0 +1,205 @@
1
+ """The command line's vocabulary: the parser, its subparsers, and the version line."""
2
+ import argparse
3
+ import ipaddress
4
+ import sys
5
+ from collections import namedtuple
6
+ from importlib import metadata
7
+
8
+ from hello_127 import cli_query, cli_tunnel, launch
9
+
10
+ PROG = "hello-127"
11
+
12
+ PATHS = """\
13
+ paths:
14
+ RUNTIME `docker` or `container`, the runtime that runs CONTAINER.
15
+ CONTAINER the name or id of a running container.
16
+ RUNTIME and CONTAINER come right after COMMAND, then options in any order."""
17
+
18
+ MONOCHROME = {"color": False} if sys.version_info >= (3, 14) else {}
19
+
20
+ LOOPBACK = "127.0.0.1"
21
+ OLLAMA_PORT = 11434
22
+
23
+ Forward = namedtuple("Forward", "bind port host hostport")
24
+ """Where the helper listens inside, and what the Mac dials for each connection."""
25
+
26
+ Endpoint = namedtuple("Endpoint", "address port")
27
+ """An address and a port, for `ollama-query` to dial inside the container."""
28
+
29
+ DEFAULT_FORWARD = Forward(LOOPBACK, OLLAMA_PORT, LOOPBACK, OLLAMA_PORT)
30
+ DEFAULT_ENDPOINT = Endpoint(LOOPBACK, OLLAMA_PORT)
31
+
32
+
33
+ def leading_paths(tokens):
34
+ """Return the tokens ahead of the first flag."""
35
+ paths = []
36
+ for token in tokens:
37
+ if token.startswith("-"):
38
+ break
39
+ paths.append(token)
40
+ return paths
41
+
42
+
43
+ def installed_version():
44
+ """Return the version of the installed hello-127 distribution."""
45
+ try:
46
+ return metadata.version("hello-127")
47
+ except metadata.PackageNotFoundError:
48
+ return "unknown (not installed)"
49
+
50
+
51
+ def version_line():
52
+ """Return the program name and the installed version on one line."""
53
+ return f"{PROG} {installed_version()}"
54
+
55
+
56
+ def loopback(text):
57
+ """Return `text` as a loopback IPv4 address, or refuse it as a bad value."""
58
+ try:
59
+ address = ipaddress.IPv4Address(text)
60
+ except ValueError:
61
+ raise argparse.ArgumentTypeError(f"not an IPv4 address: {text!r}") from None
62
+ if not address.is_loopback:
63
+ raise argparse.ArgumentTypeError(f"not a loopback address: {text!r}")
64
+ return str(address)
65
+
66
+
67
+ def port_number(text):
68
+ """Return `text` as a TCP port from 1 to 65535, or refuse it as a bad value."""
69
+ if not (text.isascii() and text.isdigit() and 0 < int(text) < 65536):
70
+ raise argparse.ArgumentTypeError(f"not a port: {text!r}")
71
+ return int(text)
72
+
73
+
74
+ def forward_spec(text):
75
+ """Read `[BIND:]PORT:HOST:HOSTPORT` into a Forward. BIND defaults to loopback."""
76
+ parts = text.split(":")
77
+ if len(parts) == 3:
78
+ parts.insert(0, LOOPBACK)
79
+ if len(parts) != 4:
80
+ raise argparse.ArgumentTypeError(f"expected [BIND:]PORT:HOST:HOSTPORT: {text!r}")
81
+ bind, port, host, hostport = parts
82
+ return Forward(loopback(bind), port_number(port), loopback(host), port_number(hostport))
83
+
84
+
85
+ def endpoint_spec(text):
86
+ """Read `ADDRESS:PORT` into an Endpoint."""
87
+ parts = text.split(":")
88
+ if len(parts) != 2:
89
+ raise argparse.ArgumentTypeError(f"expected ADDRESS:PORT: {text!r}")
90
+ address, port = parts
91
+ return Endpoint(loopback(address), port_number(port))
92
+
93
+
94
+ class StoreOnce(argparse.Action):
95
+ """Store a flag's value, and refuse the flag given a second time."""
96
+
97
+ def __call__(self, parser, namespace, values, option_string=None):
98
+ """Store `values`, unless an earlier occurrence already replaced the default."""
99
+ if getattr(namespace, self.dest) is not self.default:
100
+ raise argparse.ArgumentError(self, "given more than once")
101
+ setattr(namespace, self.dest, values)
102
+
103
+
104
+ class AppendForward(argparse.Action):
105
+ """Collect each --forward in order, and refuse a listener given twice."""
106
+
107
+ def __call__(self, parser, namespace, values, option_string=None):
108
+ """Append `values`, starting a fresh list in place of the default."""
109
+ forwards = getattr(namespace, self.dest)
110
+ if forwards is self.default:
111
+ forwards = []
112
+ if any(forward[:2] == values[:2] for forward in forwards):
113
+ raise argparse.ArgumentError(
114
+ self, f"listener {values.bind}:{values.port} given twice"
115
+ )
116
+ setattr(namespace, self.dest, [*forwards, values])
117
+
118
+
119
+ def add_runtime_slots(command):
120
+ """Add the RUNTIME and CONTAINER positionals that both commands read."""
121
+ paths = command.add_argument_group("paths")
122
+ paths.add_argument(
123
+ "runtime",
124
+ nargs="?",
125
+ choices=sorted(launch.EXEC),
126
+ metavar="RUNTIME",
127
+ help="`docker` or `container`",
128
+ )
129
+ paths.add_argument(
130
+ "container", nargs="?", metavar="CONTAINER", help="a running container"
131
+ )
132
+ return command
133
+
134
+
135
+ def add_tunnel(subparsers):
136
+ """Add the `tunnel` subparser and return it."""
137
+ tunnel = add_runtime_slots(
138
+ subparsers.add_parser(
139
+ "tunnel", help=cli_tunnel.HELP, usage=cli_tunnel.USAGE, allow_abbrev=False
140
+ )
141
+ )
142
+ tunnel.add_argument(
143
+ "--forward",
144
+ type=forward_spec,
145
+ action=AppendForward,
146
+ default=[DEFAULT_FORWARD],
147
+ dest="forwards",
148
+ metavar="SPEC",
149
+ help="listen on BIND:PORT inside and dial HOST:HOSTPORT on the Mac, "
150
+ "as [BIND:]PORT:HOST:HOSTPORT, all loopback; repeat for more "
151
+ "(default: 127.0.0.1:11434:127.0.0.1:11434)",
152
+ )
153
+ return tunnel
154
+
155
+
156
+ def add_ollama_query(subparsers):
157
+ """Add the `ollama-query` subparser and return it."""
158
+ query = add_runtime_slots(
159
+ subparsers.add_parser(
160
+ "ollama-query",
161
+ help=cli_query.HELP,
162
+ usage=cli_query.USAGE,
163
+ allow_abbrev=False,
164
+ )
165
+ )
166
+ query.add_argument(
167
+ "--connect",
168
+ type=endpoint_spec,
169
+ action=StoreOnce,
170
+ default=DEFAULT_ENDPOINT,
171
+ metavar="ADDRESS:PORT",
172
+ help="where the tunnel listens inside, loopback (default: 127.0.0.1:11434)",
173
+ )
174
+ return query
175
+
176
+
177
+ def build_parser():
178
+ """Build the top-level parser with every command."""
179
+ parser = argparse.ArgumentParser(
180
+ prog=PROG,
181
+ usage="\n ".join([cli_tunnel.USAGE, cli_query.USAGE, f"{PROG} version"]),
182
+ epilog=PATHS,
183
+ formatter_class=argparse.RawDescriptionHelpFormatter,
184
+ allow_abbrev=False,
185
+ **MONOCHROME,
186
+ )
187
+ parser.add_argument(
188
+ "--version",
189
+ action="version",
190
+ version=version_line(),
191
+ help="print the installed version and exit",
192
+ )
193
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
194
+ add_tunnel(subparsers)
195
+ add_ollama_query(subparsers)
196
+ subparsers.add_parser(
197
+ "version", help="print the installed version and exit", allow_abbrev=False
198
+ )
199
+ return parser
200
+
201
+
202
+ def command_help(add_command):
203
+ """Return one command's own `--help` text, as argparse prints it."""
204
+ subparsers = argparse.ArgumentParser(prog=PROG, **MONOCHROME).add_subparsers()
205
+ return add_command(subparsers).format_help()
hello_127/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """
2
+ # ==============================================
3
+ # East Van AI -- AI for the rest of us!
4
+ # https://github.com/east-van-ai/hello-127
5
+ # contact: east-van-ai@proton.me
6
+ # license: MIT
7
+ # ==============================================
8
+
9
+ hello-127
10
+
11
+ Carries TCP over a container's standard streams, so a container with no
12
+ network reaches Ollama on the Mac's 127.0.0.1. A helper inside listens on
13
+ 127.0.0.1:11434; this side dials Ollama for every connection it reports.
14
+ Reach for `tunnel`, then `ollama-query` to try it.
15
+
16
+ Run a command with nothing after it for its own page.
17
+
18
+ piped input:
19
+ hello-127 reads none.
20
+
21
+ exit codes:
22
+ 0: success, and documentation
23
+ 1: hello-127's own error: a missing CONTAINER, a stray word after it,
24
+ the runtime not on PATH, or the helper or the query exiting
25
+ with an error
26
+ 2: an unknown command, an unknown option, or a bad value
27
+ """
28
+ import sys
29
+ from collections import namedtuple
30
+
31
+ from hello_127 import args, cli_query, cli_tunnel, errors
32
+
33
+ # argparse exits 2 itself, so EXIT_ARGPARSE never returns through main.
34
+ EXIT_OK = 0
35
+ EXIT_ERROR = 1
36
+ EXIT_ARGPARSE = 2
37
+
38
+
39
+ def command_page(module, add_command):
40
+ """Return a command's answer to its bare word: its docstring, then its --help."""
41
+ doc = module.__doc__.strip("\n") # not strip(): the title's indent is layout
42
+ return f"{doc}\n\n{args.command_help(add_command).rstrip()}"
43
+
44
+
45
+ Command = namedtuple("Command", "bare usage slots action")
46
+ """A command word's answer to being typed alone, its usage line, the path slots it
47
+ reads, and the action a full invocation runs.
48
+
49
+ `bare` returns the text for the bare word; `action` runs the command. For `version`,
50
+ both read from `version_line`, since running the command answers the bare word.
51
+ """
52
+
53
+ COMMANDS = {
54
+ "tunnel": Command(
55
+ lambda: command_page(cli_tunnel, args.add_tunnel),
56
+ cli_tunnel.USAGE,
57
+ cli_tunnel.SLOTS,
58
+ lambda paths, parsed: cli_tunnel.run(*paths, parsed),
59
+ ),
60
+ "ollama-query": Command(
61
+ lambda: command_page(cli_query, args.add_ollama_query),
62
+ cli_query.USAGE,
63
+ cli_query.SLOTS,
64
+ lambda paths, parsed: cli_query.run(*paths, parsed),
65
+ ),
66
+ "version": Command(
67
+ args.version_line,
68
+ "hello-127 version",
69
+ (),
70
+ lambda paths, parsed: print(args.version_line()),
71
+ ),
72
+ }
73
+
74
+
75
+ def usage_error(usage, message):
76
+ """Report a command line the tool could not read, with the matching usage."""
77
+ sys.stdout.flush()
78
+ print(f"hello-127: {message}", file=sys.stderr)
79
+ print(f"Usage: {usage}", file=sys.stderr)
80
+ return EXIT_ERROR
81
+
82
+
83
+ def readiness_error(message):
84
+ """Report what the run needed and did not find, with no usage line."""
85
+ sys.stdout.flush()
86
+ print(f"hello-127: {message}", file=sys.stderr)
87
+ return EXIT_ERROR
88
+
89
+
90
+ def runtime_error(message):
91
+ """Report a run that stopped partway, naming what was written."""
92
+ sys.stdout.flush()
93
+ print(f"hello-127: {message}", file=sys.stderr)
94
+ return EXIT_ERROR
95
+
96
+
97
+ def main(argv=None):
98
+ """Parse arguments, run the matching command, return an exit code."""
99
+ tokens = list(sys.argv[1:] if argv is None else argv)
100
+
101
+ if not tokens:
102
+ print(f"{__doc__.strip()}\n\n{args.build_parser().format_help().rstrip()}")
103
+ return EXIT_OK
104
+
105
+ # A command word and nothing else is a question, and its own page answers.
106
+ if len(tokens) == 1 and tokens[0] in COMMANDS:
107
+ print(COMMANDS[tokens[0]].bare())
108
+ return EXIT_OK
109
+
110
+ parser = args.build_parser()
111
+ parsed, extras = parser.parse_known_args(tokens)
112
+
113
+ if any(extra.startswith("-") for extra in extras):
114
+ parser.parse_args(tokens) # argparse names the flag better, exit 2
115
+
116
+ paths = args.leading_paths(tokens[1:])
117
+
118
+ command = COMMANDS[parsed.command]
119
+
120
+ if len(paths) < len(command.slots):
121
+ needed = " and ".join(command.slots)
122
+ if len(command.slots) > 1:
123
+ needed = f"both {needed}"
124
+ return usage_error(command.usage, f"{parsed.command} needs {needed}")
125
+
126
+ # A word past the slots is a stray, and so is one argparse left over after a flag.
127
+ strays = paths[len(command.slots) :] or extras
128
+ if strays:
129
+ last = command.slots[-1] if command.slots else "it"
130
+ return usage_error(
131
+ command.usage,
132
+ f"{parsed.command} takes nothing after {last}: {strays[0]!r}",
133
+ )
134
+
135
+ try:
136
+ command.action(paths, parsed)
137
+ except errors.ReadinessError as failure:
138
+ return readiness_error(str(failure))
139
+ except errors.RuntimeFailure as failure:
140
+ return runtime_error(str(failure))
141
+
142
+ return EXIT_OK
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
hello_127/cli_query.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ hello-127 -- Ollama Query
3
+
4
+ https://github.com/east-van-ai/hello-127
5
+
6
+ Asks Ollama one question from inside a running container, and prints the
7
+ answer as it streams. The program runs in the container and dials
8
+ 127.0.0.1:11434, or the loopback ADDRESS:PORT given to --connect, so it
9
+ reaches Ollama only through a running `tunnel` listening there.
10
+
11
+ The model is Cohere's Tiny Aya, hf.co/CohereLabs/tiny-aya-global-GGUF:Q4_K_M,
12
+ and the prompt is fixed. It is a probe, not a client.
13
+
14
+ RUNTIME `docker` runs the program with `docker exec`, and `container` with
15
+ Apple Container's `container exec`. The container must have python3, 3.10 or
16
+ newer.
17
+
18
+ With no tunnel running, the program says nothing answers on that
19
+ address and port, and exits 1.
20
+ """
21
+ import subprocess
22
+
23
+ from hello_127 import errors, launch, query
24
+
25
+ HELP = "ask Ollama one question from inside a container, through the tunnel"
26
+ USAGE = "hello-127 ollama-query RUNTIME CONTAINER [--connect ADDRESS:PORT]"
27
+ SLOTS = ("RUNTIME", "CONTAINER")
28
+
29
+
30
+ def run(runtime, name, parsed):
31
+ """Run the query inside container `name`, streaming its answer to stdout."""
32
+ program = launch.source(query, "ask", *parsed.connect)
33
+ command = launch.exec_command(runtime, name, program)
34
+ try:
35
+ code = subprocess.run(command).returncode
36
+ except FileNotFoundError:
37
+ raise errors.ReadinessError(f"{command[0]} is not on PATH") from None
38
+ except KeyboardInterrupt:
39
+ raise errors.RuntimeFailure("the query was interrupted") from None
40
+ if code:
41
+ raise errors.RuntimeFailure(f"the query exited with code {code}")
@@ -0,0 +1,49 @@
1
+ """
2
+ hello-127 -- Tunnel
3
+
4
+ https://github.com/east-van-ai/hello-127
5
+
6
+ Starts a helper inside a running container and relays its connections to
7
+ a service on the Mac's loopback. By default the helper listens on
8
+ 127.0.0.1:11434 inside, and the Mac dials Ollama on 127.0.0.1:11434, so a
9
+ program in the container reaches Ollama as if it were local, even with
10
+ --network none.
11
+
12
+ --forward changes both ends, in `ssh -L`'s shape: BIND:PORT is where the
13
+ helper listens inside, and HOST:HOSTPORT is what the Mac dials. BIND may be
14
+ left out, and is then 127.0.0.1. Every address must be loopback, 127.0.0.1
15
+ to 127.255.255.255. The Mac answers only on the 127.x addresses its lo0
16
+ carries, by default 127.0.0.1 alone. Another HOST leaves each connection
17
+ hanging until macOS gives up, after about 75 seconds.
18
+
19
+ --forward may repeat, once per listener, and one helper carries them all.
20
+ Two forwards may share a HOST:HOSTPORT, but not a BIND:PORT. A HOST that
21
+ hangs stalls every forward, not just its own, until macOS gives up.
22
+
23
+ RUNTIME `docker` runs the helper with `docker exec -i`, and `container` with
24
+ Apple Container's `container exec -i`. The container must already be running
25
+ and have python3, 3.10 or newer.
26
+
27
+ The tunnel runs until Ctrl-C, which closes the helper's input so it exits.
28
+ If the helper lingers for 5 seconds, the exec is killed.
29
+ """
30
+ import asyncio
31
+
32
+ from hello_127 import errors, host, launch, wire
33
+
34
+ HELP = "relay a container's loopback port to the Mac's, 11434 by default"
35
+ USAGE = "hello-127 tunnel RUNTIME CONTAINER [--forward SPEC]..."
36
+ SLOTS = ("RUNTIME", "CONTAINER")
37
+
38
+
39
+ def run(runtime, name, parsed):
40
+ """Run the tunnel into container `name` until Ctrl-C."""
41
+ listeners = [(forward.bind, forward.port) for forward in parsed.forwards]
42
+ program = launch.source(wire, "listen", listeners)
43
+ command = launch.exec_command(runtime, name, program, interactive=True)
44
+ try:
45
+ code, stopped = asyncio.run(host.run_until_signal(command, parsed.forwards))
46
+ except FileNotFoundError:
47
+ raise errors.ReadinessError(f"{command[0]} is not on PATH") from None
48
+ if code and not stopped:
49
+ raise errors.RuntimeFailure(f"the helper exited with code {code}")
hello_127/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ """The two failures a command can raise. Grammar errors need no class."""
2
+
3
+
4
+ class ReadinessError(Exception):
5
+ """Something the run needed was missing, found before the first write."""
6
+
7
+
8
+ class RuntimeFailure(Exception):
9
+ """A run that stopped partway, after the first write."""
hello_127/host.py ADDED
@@ -0,0 +1,67 @@
1
+ """The Mac side: the relay."""
2
+
3
+ import asyncio
4
+ import signal
5
+
6
+ from hello_127 import wire
7
+
8
+ STOP_GRACE = 5.0
9
+
10
+
11
+ async def run(command, forwards, stop=None):
12
+ """Run the helper command and relay each connection to its forward's target.
13
+
14
+ An OPEN frame names its forward by index in `forwards`, and the connection goes to
15
+ that forward's `host:hostport`.
16
+
17
+ The helper runs in its own session, so Ctrl-C reaches only this process. Setting
18
+ `stop` closes the helper's stdin, which tells it to exit. Return the helper's exit
19
+ code and whether `stop` ended the run.
20
+ """
21
+ stop = stop or asyncio.Event()
22
+ process = await asyncio.create_subprocess_exec(
23
+ *command,
24
+ stdin=asyncio.subprocess.PIPE,
25
+ stdout=asyncio.subprocess.PIPE,
26
+ start_new_session=True,
27
+ )
28
+ tunnel = wire.Tunnel(process.stdout, process.stdin, {}, {})
29
+
30
+ async def dial(payload):
31
+ """Open a connection to the target of the forward an OPEN frame names."""
32
+ (index,) = wire.INDEX.unpack(payload)
33
+ if index >= len(forwards):
34
+ raise OSError(f"no forward number {index}")
35
+ return await asyncio.open_connection(forwards[index].host, forwards[index].hostport)
36
+
37
+ relay = asyncio.create_task(wire.dispatch(tunnel, dial=dial))
38
+ stopped = asyncio.create_task(stop.wait())
39
+ await asyncio.wait({relay, stopped}, return_when=asyncio.FIRST_COMPLETED)
40
+
41
+ if stop.is_set():
42
+ wire.log("outside: stopping, closing the helper's input")
43
+ process.stdin.close()
44
+ try:
45
+ await asyncio.wait_for(process.wait(), STOP_GRACE)
46
+ except TimeoutError:
47
+ wire.log(f"outside: helper still running after {STOP_GRACE:g} s, killing exec")
48
+ process.kill()
49
+ await process.wait()
50
+ await relay
51
+ stopped.cancel()
52
+ return process.returncode, stop.is_set()
53
+
54
+
55
+ async def run_until_signal(command, forwards):
56
+ """Run the tunnel until SIGINT or SIGTERM, and return what `run` returns."""
57
+ stop = asyncio.Event()
58
+ loop = asyncio.get_running_loop()
59
+ for sig in (signal.SIGINT, signal.SIGTERM):
60
+ loop.add_signal_handler(sig, stop.set)
61
+ for forward in forwards:
62
+ wire.log(
63
+ f"outside: relaying {forward.bind}:{forward.port}"
64
+ f" to {forward.host}:{forward.hostport}"
65
+ )
66
+ wire.log("outside: Ctrl-C to stop")
67
+ return await run(command, forwards, stop=stop)
hello_127/launch.py ADDED
@@ -0,0 +1,20 @@
1
+ """The exec commands that run a program from this package inside a container."""
2
+
3
+ from pathlib import Path
4
+
5
+ EXEC = {"docker": ["docker", "exec"], "container": ["container", "exec"]}
6
+
7
+
8
+ def source(module, entry, *arguments):
9
+ """Return `module`'s own file, then one call to `entry(*arguments)`, for `python3 -c`."""
10
+ call = ", ".join(map(repr, arguments))
11
+ return f"{Path(module.__file__).read_text()}\n{entry}({call})\n"
12
+
13
+
14
+ def exec_command(runtime, name, program, interactive=False):
15
+ """Return the exec command that runs `program` with python3 inside container `name`.
16
+
17
+ `interactive` adds `-i`, which keeps the program's stdin connected. The helper
18
+ needs it for its frames and its stop. Without it, the helper never exits.
19
+ """
20
+ return [*EXEC[runtime], *(["-i"] if interactive else []), name, "python3", "-c", program]
hello_127/query.py ADDED
@@ -0,0 +1,57 @@
1
+ """Ask Ollama one question from inside a container, and print the answer as it streams.
2
+
3
+ Sent into the container as `python3 -c`, like the helper, so it stays standard library
4
+ only and runs on Python 3.10. It dials only the address it is given.
5
+ """
6
+
7
+ import http.client
8
+ import json
9
+ import sys
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ MODEL = "hf.co/CohereLabs/tiny-aya-global-GGUF:Q4_K_M"
14
+ PROMPT = "Say hello from a container in five sentences."
15
+
16
+
17
+ def fail(message):
18
+ """Print one line to stderr and exit 1."""
19
+ print(f"hello-127: {message}", file=sys.stderr, flush=True)
20
+ raise SystemExit(1)
21
+
22
+
23
+ def ask(address, port):
24
+ """Post the prompt to address:port and print the streamed answer.
25
+
26
+ The answer counts as finished only at Ollama's `done` line. A stream that ends
27
+ before it fails, since `http.client` ends quietly when a cut falls between chunks.
28
+ """
29
+ url = f"http://{address}:{port}/api/chat"
30
+ body = json.dumps(
31
+ {"model": MODEL, "messages": [{"role": "user", "content": PROMPT}]}
32
+ ).encode()
33
+ try:
34
+ reply = urllib.request.urlopen(url, body)
35
+ except urllib.error.HTTPError as error:
36
+ fail(f"Ollama answered {error.code}: {json.load(error).get('error', '')}")
37
+ except urllib.error.URLError as error:
38
+ if isinstance(error.reason, ConnectionRefusedError):
39
+ fail(f"nothing answers on {address}:{port} here. Is the tunnel running?")
40
+ fail(f"cannot reach {address}:{port}: {error.reason}")
41
+ except http.client.RemoteDisconnected:
42
+ fail("the tunnel closed the connection. Is Ollama running on the Mac?")
43
+ with reply:
44
+ try:
45
+ for line in reply:
46
+ part = json.loads(line)
47
+ if "error" in part:
48
+ print()
49
+ fail(f"Ollama stopped with an error: {part['error']}")
50
+ print(part["message"]["content"], end="", flush=True)
51
+ if part.get("done"):
52
+ print()
53
+ return
54
+ except http.client.IncompleteRead:
55
+ pass
56
+ print()
57
+ fail("the answer was cut off before Ollama finished it")
hello_127/wire.py ADDED
@@ -0,0 +1,196 @@
1
+ """Carry TCP connections as frames over a pair of pipes.
2
+
3
+ Both ends of the tunnel run this file. The Mac side imports it. The container side
4
+ receives its source through `python3 -c`, so it stays standard library only, runs on
5
+ Python 3.10, and never writes to stdout, which is the wire.
6
+ """
7
+
8
+ import asyncio
9
+ import errno
10
+ import itertools
11
+ import struct
12
+ import sys
13
+ from collections import namedtuple
14
+
15
+ OPEN, DATA, CLOSE = 1, 2, 3
16
+ HEADER = struct.Struct("!BII")
17
+ INDEX = struct.Struct("!H")
18
+ CHUNK = 64 * 1024
19
+
20
+ Tunnel = namedtuple("Tunnel", "pipe_in pipe_out writers remaining")
21
+ """One end of the tunnel: the pipe both ways, and its open connections.
22
+
23
+ `writers` maps a connection number to its socket's writer. `remaining` counts the
24
+ directions of that connection still open, from 2 down to 0.
25
+ """
26
+
27
+
28
+ def log(message):
29
+ """Print one line to stderr, since stdout belongs to the frames."""
30
+ print(f"hello-127: {message}", file=sys.stderr, flush=True)
31
+
32
+
33
+ def frame(kind, conn, payload=b""):
34
+ """Return one frame: kind, connection number, payload length, then the payload."""
35
+ return HEADER.pack(kind, conn, len(payload)) + payload
36
+
37
+
38
+ async def read_frame(pipe):
39
+ """Return the next (kind, conn, payload) from the pipe, or None at its end."""
40
+ try:
41
+ kind, conn, length = HEADER.unpack(await pipe.readexactly(HEADER.size))
42
+ payload = await pipe.readexactly(length)
43
+ except asyncio.IncompleteReadError:
44
+ return None
45
+ return kind, conn, payload
46
+
47
+
48
+ def open_link(tunnel, conn, writer):
49
+ """Record a new connection with both of its directions open."""
50
+ tunnel.writers[conn] = writer
51
+ tunnel.remaining[conn] = 2
52
+
53
+
54
+ def finish_half(tunnel, conn):
55
+ """Count one direction of a connection as done, and drop it once both are."""
56
+ if conn not in tunnel.remaining:
57
+ return
58
+ tunnel.remaining[conn] -= 1
59
+ if tunnel.remaining[conn] == 0:
60
+ del tunnel.remaining[conn]
61
+ tunnel.writers.pop(conn).close()
62
+
63
+
64
+ async def pump(tunnel, conn, reader):
65
+ """Copy one socket's bytes into DATA frames, then send CLOSE at its end."""
66
+ try:
67
+ while True:
68
+ chunk = await reader.read(CHUNK)
69
+ if not chunk:
70
+ break
71
+ tunnel.pipe_out.write(frame(DATA, conn, chunk))
72
+ await tunnel.pipe_out.drain()
73
+ except ConnectionError:
74
+ pass
75
+ if not tunnel.pipe_out.is_closing():
76
+ tunnel.pipe_out.write(frame(CLOSE, conn))
77
+ finish_half(tunnel, conn)
78
+
79
+
80
+ async def deliver(tunnel, conn, payload):
81
+ """Write a DATA frame's payload to its socket, if the socket is still there."""
82
+ writer = tunnel.writers.get(conn)
83
+ if writer is None or writer.is_closing():
84
+ return
85
+ writer.write(payload)
86
+ try:
87
+ await writer.drain()
88
+ except ConnectionError:
89
+ pass
90
+
91
+
92
+ def half_close(tunnel, conn):
93
+ """Act on a CLOSE frame: end the socket's write side and count the direction."""
94
+ writer = tunnel.writers.get(conn)
95
+ if writer is None:
96
+ return
97
+ try:
98
+ if writer.can_write_eof():
99
+ writer.write_eof()
100
+ except OSError:
101
+ pass
102
+ finish_half(tunnel, conn)
103
+
104
+
105
+ async def dispatch(tunnel, dial=None):
106
+ """Act on frames from the pipe until it ends, then close every connection.
107
+
108
+ `dial` takes an OPEN frame's payload and opens its far end. Only the Mac side
109
+ passes one. It runs inline, so no DATA frame overtakes the dial for its own
110
+ connection.
111
+ """
112
+ pumps = set()
113
+ while True:
114
+ got = await read_frame(tunnel.pipe_in)
115
+ if got is None:
116
+ break
117
+ kind, conn, payload = got
118
+ if kind == OPEN and dial is not None:
119
+ try:
120
+ reader, writer = await dial(payload)
121
+ except OSError as error:
122
+ log(f"connection {conn}: dial failed: {error}")
123
+ tunnel.pipe_out.write(frame(CLOSE, conn))
124
+ continue
125
+ open_link(tunnel, conn, writer)
126
+ task = asyncio.ensure_future(pump(tunnel, conn, reader))
127
+ pumps.add(task)
128
+ task.add_done_callback(pumps.discard)
129
+ elif kind == DATA:
130
+ await deliver(tunnel, conn, payload)
131
+ elif kind == CLOSE:
132
+ half_close(tunnel, conn)
133
+ for writer in tunnel.writers.values():
134
+ writer.close()
135
+ tunnel.writers.clear()
136
+ tunnel.remaining.clear()
137
+ for task in pumps:
138
+ task.cancel()
139
+
140
+
141
+ async def stdio_pipes():
142
+ """Return this process's stdin and stdout as an asyncio reader and writer."""
143
+ loop = asyncio.get_running_loop()
144
+ pipe_in = asyncio.StreamReader()
145
+ await loop.connect_read_pipe(
146
+ lambda: asyncio.StreamReaderProtocol(pipe_in), sys.stdin.buffer
147
+ )
148
+ transport, protocol = await loop.connect_write_pipe(
149
+ asyncio.streams.FlowControlMixin, sys.stdout.buffer
150
+ )
151
+ pipe_out = asyncio.StreamWriter(transport, protocol, None, loop)
152
+ return pipe_in, pipe_out
153
+
154
+
155
+ async def serve_inside(listeners):
156
+ """Listen on each (address, port) and send every connection through stdout.
157
+
158
+ An OPEN frame carries its listener's index in `listeners`, so the Mac side knows
159
+ where to dial. A listener that cannot bind ends the helper, with no partial tunnel.
160
+ """
161
+ pipe_in, pipe_out = await stdio_pipes()
162
+ tunnel = Tunnel(pipe_in, pipe_out, {}, {})
163
+ numbers = itertools.count(1)
164
+
165
+ def acceptor(index):
166
+ """Return the accept callback for the listener at `index`."""
167
+
168
+ async def accept(reader, writer):
169
+ """Number a new connection, announce it with its listener, and pump it."""
170
+ conn = next(numbers)
171
+ open_link(tunnel, conn, writer)
172
+ pipe_out.write(frame(OPEN, conn, INDEX.pack(index)))
173
+ await pump(tunnel, conn, reader)
174
+
175
+ return accept
176
+
177
+ servers = []
178
+ for index, (address, port) in enumerate(listeners):
179
+ try:
180
+ servers.append(await asyncio.start_server(acceptor(index), address, port))
181
+ except OSError as error:
182
+ reason = error.strerror
183
+ if error.errno == errno.EADDRINUSE:
184
+ reason = "already in use in this container"
185
+ log(f"inside: cannot listen on {address}:{port}: {reason}")
186
+ raise SystemExit(1) from None
187
+ log(f"inside: listening on {address}:{port}")
188
+ await dispatch(tunnel)
189
+ for server in servers:
190
+ server.close()
191
+ log("inside: input ended, helper exiting")
192
+
193
+
194
+ def listen(listeners):
195
+ """Run the inside half until stdin ends. The container's entry point."""
196
+ asyncio.run(serve_inside(listeners))
@@ -0,0 +1,305 @@
1
+ Metadata-Version: 2.4
2
+ Name: hello-127
3
+ Version: 0.1.1
4
+ Summary: Carry TCP over a container's standard streams to a service on the Mac's loopback
5
+ Author: Go Nakamaru
6
+ Author-email: Go Nakamaru <gonakamaru@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: MacOS
10
+ Requires-Python: >=3.14
11
+ Project-URL: Repository, https://github.com/east-van-ai/hello-127
12
+ Project-URL: Changelog, https://github.com/east-van-ai/hello-127/blob/main/CHANGELOG.md
13
+ Description-Content-Type: text/markdown
14
+
15
+ # hello-127
16
+
17
+ ![CI](https://github.com/east-van-ai/hello-127/actions/workflows/ci.yaml/badge.svg)
18
+
19
+ Can a Docker or Apple Container with no network access still reach a service on
20
+ the Mac's 127.0.0.1, such as Ollama? Yes, through a pipe: the standard streams
21
+ of `docker exec -i` or `container exec -i`. hello-127 is a small command that
22
+ carries TCP through that pipe.
23
+
24
+ hello-127 is a reusable CLI tool for this purpose. This page covers its design,
25
+ limitations, install, behaviour, and known failure modes.
26
+
27
+ ## How a container reaches loopback today
28
+
29
+ A program on a Mac binds to 127.0.0.1 to stay private. Ollama on a Mac does
30
+ this by default. Only programs on the same machine should reach it.
31
+
32
+ Docker Desktop bends that. When a Docker container connects to the Mac, Docker
33
+ Desktop's own process, `com.docker.backend`, opens a fresh connection to
34
+ 127.0.0.1 on the Mac. Ollama sees a local client. The macOS firewall sees an
35
+ outgoing connection, and never asks.
36
+
37
+ Measured on macOS with Docker Desktop 4.92.0 and Ollama 0.33.3:
38
+
39
+ - During a run, `lsof` shows `com.docker.backend` as Ollama's only client.
40
+ - A test web server bound to 127.0.0.1 refused the Mac's own LAN address.
41
+ A container on Docker's default network read from it anyway.
42
+
43
+ That is a gap. "Bound to loopback" stops meaning "local programs only" the
44
+ moment Docker Desktop is running.
45
+
46
+ Apple Container has no such hop. Its traffic arrives over a network
47
+ interface, so reaching Ollama there needs a firewall exception or sudo.
48
+
49
+ ## The same job, on purpose
50
+
51
+ Every container runtime can already run a program inside a container and
52
+ hand you its standard input and output: `docker exec -i`, or
53
+ `container exec -i`. That is a pipe. hello-127 carries TCP through it.
54
+
55
+ ```text
56
+ container (--network none) Mac
57
+ ┌──────────────────────────────┐ ┌──────────────────────────┐
58
+ │ your program │ │ ┌──────────┐ │
59
+ │ │ connects to │ │ ┌─ │ Ollama │◄─┐ │
60
+ │ ▼ 127.0.0.1:11434 │ stdin │ ▼ └──────────┘ │ │
61
+ │ helper ◄─────────────────┼─────────┼── hello-127 │ │
62
+ │ └─────────────────────────►┼─────────┼──► dials 127.0.0.1 ─┘ │
63
+ │ │ stdout │ │
64
+ └──────────────────────────────┘ └──────────────────────────┘
65
+ ```
66
+
67
+ - **Inside the container**, a small helper listens on 127.0.0.1:11434. It
68
+ shares the container's network, so your program sees Ollama as local.
69
+ - **Over the pipe**, each connection becomes frames: open, data, close, each
70
+ tagged with a connection number. Many connections share one pipe.
71
+ - **On the Mac**, hello-127 reads the frames and dials Ollama on loopback.
72
+ Every connection goes out, none comes in. No firewall prompt, no sudo.
73
+
74
+ The helper is not installed in the image. hello-127 sends its source as an
75
+ argument to `python3 -c`, so the image needs only `python3`, 3.10 or newer.
76
+
77
+ None of this is new. SSH's `ProxyCommand` carries one TCP stream over a
78
+ program's standard streams. `ssh -L` and `kubectl port-forward` carry many
79
+ connections through one channel.
80
+
81
+ ## What it is not
82
+
83
+ It carries bytes, and never reads them. Whatever the service lets a local
84
+ client do, a program in the container can do too. The tunnel opens the whole
85
+ service, not one part of it.
86
+
87
+ It also changes how a connection ends. TCP has two endings: a clean close,
88
+ and a reset when something breaks. The pipe has no frame for a reset, so
89
+ every ending arrives as a clean close. A protocol that marks its own end,
90
+ as HTTP does, still notices a cut. A protocol that simply stops at the end
91
+ of the connection cannot tell a crash from a finish.
92
+
93
+ A gatekeeper is the other design. It understands each request and decides
94
+ what to allow. hello-127 makes the pipe on purpose and leaves the deciding
95
+ out.
96
+
97
+ ## Install
98
+
99
+ - A Mac.
100
+ - [uv](https://docs.astral.sh/uv/), which installs hello-127 and fetches
101
+ its Python.
102
+ - [Docker Desktop](https://www.docker.com/products/docker-desktop/), or
103
+ [Apple Container](https://github.com/apple/container).
104
+ - hello-127, from PyPI:
105
+
106
+ ```bash
107
+ uv tool install hello-127
108
+ ```
109
+
110
+ hello-127 itself needs nothing beyond Python's standard library.
111
+
112
+ ## Try it yourself
113
+
114
+ A container started with `--network none` streams a chat from Ollama
115
+ through hello-127. Without the tunnel, the same chat is refused.
116
+
117
+ It needs [Ollama](https://ollama.com), running, with Cohere's Tiny Aya
118
+ pulled, a 2.1 GB download:
119
+
120
+ ```bash
121
+ ollama pull hf.co/CohereLabs/tiny-aya-global-GGUF:Q4_K_M
122
+ ```
123
+
124
+ It takes three terminals. In the first, start a container with no network,
125
+ then the tunnel into it:
126
+
127
+ ```bash
128
+ docker run -d --rm --name h127 --network none python:3.12-slim sleep infinity
129
+ hello-127 tunnel docker h127
130
+ ```
131
+
132
+ - `-d` runs the container in the background.
133
+ - `--rm` deletes the container once it stops.
134
+ - `--name h127` gives it the name the other commands use.
135
+ - `--network none` leaves it with loopback only.
136
+ - `sleep infinity` keeps it running with nothing else to do.
137
+
138
+ ```text
139
+ hello-127: outside: relaying 127.0.0.1:11434 to 127.0.0.1:11434
140
+ hello-127: outside: Ctrl-C to stop
141
+ hello-127: inside: listening on 127.0.0.1:11434
142
+ ```
143
+
144
+ In the second, chat from inside the container:
145
+
146
+ ```bash
147
+ hello-127 ollama-query docker h127
148
+ ```
149
+
150
+ ```text
151
+ Containerized applications are a revolutionary way to deploy and manage software, ...
152
+ ```
153
+
154
+ `ollama-query` runs a small Python program in the container with
155
+ `docker exec`. It asks Cohere's Tiny Aya one fixed question at
156
+ 127.0.0.1:11434 and prints the answer as it streams. It knows nothing else
157
+ about the Mac.
158
+
159
+ Run it before the tunnel is up, and the same program is refused:
160
+
161
+ ```text
162
+ hello-127: nothing answers on 127.0.0.1:11434 here. Is the tunnel running?
163
+ hello-127: the query exited with code 1
164
+ ```
165
+
166
+ In the third, while the answer streams, ask who holds Ollama's port:
167
+
168
+ ```bash
169
+ lsof -nP -iTCP:11434 -sTCP:ESTABLISHED
170
+ ```
171
+
172
+ - `-n` and `-P` print addresses and ports as numbers, not names.
173
+ - `-iTCP:11434` picks TCP sockets on Ollama's port, 11434.
174
+ - `-sTCP:ESTABLISHED` keeps only live connections, in the ESTABLISHED state.
175
+
176
+ Once the answer ends, only Ollama's listening socket is left, so the list
177
+ comes back empty.
178
+
179
+ ```text
180
+ COMMAND PID USER FD TYPE ... NAME
181
+ ollama 803 you 7u IPv4 ... TCP 127.0.0.1:11434->127.0.0.1:52235 (ESTABLISHED)
182
+ python3.1 55300 you 6u IPv4 ... TCP 127.0.0.1:52235->127.0.0.1:11434 (ESTABLISHED)
183
+ ```
184
+
185
+ Ollama's only client is hello-127's Python, which `lsof` cuts to
186
+ `python3.1`. It is not `com.docker.backend`.
187
+
188
+ Back in the first terminal, Ctrl-C closes the helper's input, and the helper
189
+ exits:
190
+
191
+ ```text
192
+ hello-127: outside: stopping, closing the helper's input
193
+ hello-127: inside: input ended, helper exiting
194
+ ```
195
+
196
+ Then `docker stop h127` removes the container.
197
+
198
+ Apple Container works the same way, with the same flags:
199
+
200
+ ```bash
201
+ container run -d --rm --name h127 --network none python:3.14-slim sleep infinity
202
+ hello-127 tunnel container h127
203
+ ```
204
+
205
+ The chat is `hello-127 ollama-query container h127`, and `lsof` again
206
+ names hello-127's Python. Then `container stop h127` removes the container.
207
+
208
+ Measured on macOS with Docker 29.8.0, Apple Container 1.6.0, and Ollama
209
+ 0.33.3.
210
+
211
+ ## Other ports
212
+
213
+ Ollama is only the specimen. The tunnel carries any TCP service on the
214
+ Mac's loopback. `--forward` picks both ends, in the shape `ssh -L` uses:
215
+
216
+ ```bash
217
+ hello-127 tunnel docker h127 --forward 127.0.0.2:80:127.0.0.1:11434
218
+ hello-127 ollama-query docker h127 --connect 127.0.0.2:80
219
+ ```
220
+
221
+ - `127.0.0.2:80` is where the helper listens inside. Leave the address out,
222
+ and it is 127.0.0.1.
223
+ - `127.0.0.1:11434` is what the Mac dials.
224
+ - `--connect` points `ollama-query` at the helper.
225
+
226
+ `--forward` repeats, and one helper carries every listener:
227
+
228
+ ```bash
229
+ hello-127 tunnel docker h127 --forward 11434:127.0.0.1:11434 --forward 8080:127.0.0.1:8000
230
+ ```
231
+
232
+ Two forwards may dial the same target. Two listeners on the same address
233
+ and port would never both bind, so hello-127 refuses them.
234
+
235
+ Every address must be loopback, on both ends. A wider address on the Mac
236
+ would make it a relay to the LAN for a container with no network. A wider
237
+ one inside, such as 0.0.0.0, would publish the Mac's service to any
238
+ network the container does have. hello-127 refuses both before anything
239
+ runs:
240
+
241
+ ```text
242
+ usage: hello-127 tunnel RUNTIME CONTAINER [--forward SPEC]...
243
+ hello-127 tunnel: error: argument --forward: not a loopback address: '10.0.0.5'
244
+ ```
245
+
246
+ ## How it fails
247
+
248
+ Each of these was run on both runtimes. The tunnel is left as it is.
249
+
250
+ - **One slow reader stalls everyone.** All connections share one pipe. A
251
+ client that leaves about a megabyte unread freezes every other connection.
252
+ When it reads again, they all finish, and nothing is lost.
253
+ - **A cut-off stream can look complete.** If the tunnel dies mid-stream,
254
+ the client sees a clean close, not an error. Whether it notices depends on
255
+ the protocol. Against Ollama, the stock `ollama` library raises an error,
256
+ and Python's built-in `http.client` does not. So `ollama-query` also
257
+ waits for Ollama's own `done` line.
258
+ - **A crash on the Mac reads as a finish.** A test server sent 64 KiB, then
259
+ reset the connection. A client that reads until the end got an error on
260
+ the Mac itself. Through the tunnel, it got 64 KiB and a clean end: a
261
+ short file that looks complete. Docker's default network does the same,
262
+ so `com.docker.backend` hides the reset too.
263
+ - **A service that is down looks like one that hung up.** The helper
264
+ accepts a connection before the Mac dials. With nothing listening on the
265
+ Mac, Python's `smtplib` is refused on the Mac and on Docker's default
266
+ network. Through the tunnel, it connects, then reads "Connection
267
+ unexpectedly closed". With the server up, its greeting arrives as usual.
268
+ - **No `python3` in the image.** The tunnel stops at once. Apple Container
269
+ says why. Docker prints its reason on the exec's output, which is the
270
+ pipe, so hello-127 swallows it and shows only "code 127".
271
+ - **The container is killed mid-stream.** The tunnel notices, reports it,
272
+ and exits. Nothing is left connected on the Mac.
273
+ - **A Mac address nobody answers on.** Linux answers on all of 127.x, so
274
+ the helper can listen on 127.0.0.2 inside. macOS answers only on the
275
+ addresses its loopback carries, by default 127.0.0.1 alone. A forward to
276
+ 127.0.0.2 on the Mac holds each connection for 76 seconds, until macOS
277
+ gives up. `ollama-query` then blames Ollama: "the tunnel closed the
278
+ connection".
279
+ - **A dead forward stalls the live ones.** The Mac finishes each dial
280
+ before it reads the next frame, so no data can overtake its own
281
+ connection. A dial that hangs holds up every forward. Behind a forward to
282
+ 127.0.0.2, a chat on a healthy forward waited about 75 seconds for its
283
+ first word, then streamed in full.
284
+
285
+ And one thing that works: a client that has never heard of hello-127. It
286
+ connects to 127.0.0.1 as usual, and the tunnel does the rest. The stock
287
+ `ollama` Python library, with its default settings, works unchanged from a
288
+ container with no network.
289
+
290
+ ## More documentation
291
+
292
+ - [Changelog](https://github.com/east-van-ai/hello-127/blob/main/CHANGELOG.md): project history and releases
293
+
294
+ ## Use of AI
295
+
296
+ Code and documentation are written in collaboration with remote and local AI; design
297
+ decisions, code review, semantic and auditory review, and final judgement stay human.
298
+
299
+ ---
300
+
301
+ **East Van AI** · AI for the rest of us! · Vancouver, BC, Canada
302
+
303
+ [github.com/east-van-ai](https://github.com/east-van-ai) · <east-van-ai@proton.me>
304
+
305
+ Copyright (c) 2026 Go Nakamaru
@@ -0,0 +1,15 @@
1
+ hello_127/__init__.py,sha256=xa3Wg63lu49a6PPA3LyCyLS0inkXzIw0uJE96y995bs,86
2
+ hello_127/args.py,sha256=jvbXpna5u4SCd5HH5dfO02zl1Sn8n4lY1rU7O7plZQc,6823
3
+ hello_127/cli.py,sha256=Fb74YvSv5sxMO7q73jqDMOHpsGgKmux51hFxB582DRA,4685
4
+ hello_127/cli_query.py,sha256=eN5I956WYSJ8dsPJGOCU_XLapAVESZFXZ42b21mxrJc,1591
5
+ hello_127/cli_tunnel.py,sha256=jjrPlY8pj9f6daNcvsSqpzANXPxR61b5OmUKF8tSAxM,2188
6
+ hello_127/errors.py,sha256=PL0QuRDua2rHxGN5dR5nKScD7GRP3kZyiwLWWLslB7o,283
7
+ hello_127/host.py,sha256=6fkx8WadMmWfNIpyqKNmgRmMBW4HLLFsBaM20k5RiX4,2391
8
+ hello_127/launch.py,sha256=tSqDOG_MFWEGo3-0C1eV0KR7UG-kmR5y9cBxvz2Ek9w,845
9
+ hello_127/query.py,sha256=ow0idsh74KYm-lQx4JlPh0BQ2uHX3gFdeG8JH4jsOKs,2134
10
+ hello_127/wire.py,sha256=c1EAYEPTCr2kfkcLfUCq5uyndA_2JYi_1T7iWS37QEQ,6549
11
+ hello_127-0.1.1.dist-info/licenses/LICENSE,sha256=0Ncj7dClY9i2bXd_Bdl2h6o1HTAPKn07GS363J81zbc,1068
12
+ hello_127-0.1.1.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
13
+ hello_127-0.1.1.dist-info/entry_points.txt,sha256=yLVIzRdUKzPccYIZubT40FdSTIwz1vCwFnhQ5WRhj8M,50
14
+ hello_127-0.1.1.dist-info/METADATA,sha256=U5ePdqnzkW_gfdXjdp19g9haaKahFDGBNCuP4tArReA,12585
15
+ hello_127-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ hello-127 = hello_127.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Go Nakamaru
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.