Commit graph

13 commits

Author SHA1 Message Date
ea2bc9a12c
cap setuptools<77 in build-system, bump to v0.1.2
Pipeline #40344 (v0.1.1) failed at twine check with 'Metadata is
missing required fields: Name, Version' — twine 5.x doesn't recognize
Metadata-Version 2.4 wheels (it caps at 2.3). setuptools 77.0 (March
2025) bumped wheels to 2.4 for PEP 639 license-expression support.

Capping the build env's setuptools at <77 keeps wheels on Metadata-
Version 2.2 (verified locally — twine 5 PASSED).

Long-term fix is Trusted Publishing OIDC, which lets us drop the
twine<6 pin AND the setuptools cap. See the migration plan in our
next commit / pipeline discussion.
2026-06-16 14:19:22 -04:00
6f05f7eef4
ci: pin twine<6, bump to v0.1.1 — classic auth on build runner
twine 6 (Sep 2025) auto-detects GitLab CI envvars and refuses to
fall back to ~/.pypirc on the runner, requiring PYPI_ID_TOKEN
(Trusted Publishing OIDC) instead. Pipeline #40338 (v0.1.0 tag) hit
this and failed with TrustedPublishingFailure.

Pinning twine<6 restores classic ~/.pypirc auth that ago /
make_post_sell / remarkbox already use on the same runner. When we
migrate to Trusted Publishing as a coordinated change across all four
python/* repos, we'll drop this pin and add an id_tokens block + PyPI
trusted-publisher config.
2026-06-16 14:11:47 -04:00
e8f905f2d8
bump to v0.1.0 for first PyPI release
7 phases shipped: ETF codec, EPMD client, v6 handshake (MD5 cookie),
data channel (4-byte framing), gen_call protocol, TLS dist
(inet_tls_dist), unfeed integration. Maps + cross-stack validation
through wallet.unsandbox.com's test pyramid.

122 tests across unit / handshake / channel / node / tls / etf-maps.

Tag this commit with vX.Y.Z and push the tag — CI's pypi-twine stage
takes over from there.
2026-06-16 13:57:50 -04:00
001a471549
packaging: ship to PyPI via tag + GitLab CI (mirrors ago's pattern)
pyproject.toml gains the metadata PyPI expects:
  - Real author name + email
  - Keywords (erlang, elixir, distribution, gen_server, ...)
  - Classifiers (status, license, Python versions, topic)
  - Project URLs (Homepage / Repository / Bug Tracker)
  - readme content-type = text/markdown so PyPI renders our README
  - build + twine added to the [dev] extra

.gitlab-ci.yml mirrors ago's two-stage pattern:
  - test stage on every push (venv + pip install -e .[dev] + pytest)
  - pypi-twine stage only on tags: python -m build, twine check,
    twine upload. Credentials come from GitLab CI variables
    TWINE_USERNAME (typically __token__) and TWINE_PASSWORD.

Makefile gains build / dist-check / publish-test / publish targets so
the release flow is also runnable locally if a dry run is needed.

Release flow:
  1. Bump version in pyproject.toml
  2. Commit + push
  3. git tag -a vX.Y.Z -m 'release vX.Y.Z' && git push --tags
  4. CI's pypi-twine stage picks up the tag and uploads

README.md adds an Install section + Quick start (plain dist + TLS dist)
so PyPI's project page shows usable copy on first visit. dist/ artifacts
build cleanly and both pass twine check.
2026-06-16 13:45:08 -04:00
c870bdb2ac
roadmap: mark phase 7 done (unfeed ErlangDistTransport)
ErlangDistTransport itself lives in unfeed/unfeed/wallet.py to keep
erldistpy app-agnostic. Tests + docs there. Pending the Elixir-side
wallet_rpc gen_server, then staging soak, then prod switch.
2026-06-16 12:25:55 -04:00
531c26fcfb
etf: add MAP_EXT (tag 116) for Erlang/Elixir maps
Python dict round-trips as an Erlang/Elixir map. Golden vectors from
real Erlang term_to_binary verify wire compatibility for:
  - empty map (#{})
  - keyed map with binary keys + mixed values
  - {ok, #{status => ok, balance => N}} (Elixir gen_server reply shape)

122 tests green. Required for the unfeed ErlangDistTransport which
decodes Elixir wallet RPC replies.
2026-06-16 12:16:35 -04:00
271d712237
phase 6: TLS dist via inet_tls_dist
make_dist_tls_context() builds an ssl.SSLContext tuned for OTP defaults
(verify_peer, mTLS, TLSv1.2 minimum). Node accepts tls_context= and
wraps the TCP socket in TLS before the v6 handshake runs.

Critical quirk found by experimentation: inet_tls_dist uses {packet, 4}
on the SSL socket during the handshake. Plain inet_tcp_dist uses
{packet, 2} for handshake then switches to {packet, 4} post-nodeup.
handshake() now takes a frame_size= kwarg (2 or 4); Node auto-selects 4
whenever tls_context is supplied.

Cert requirements (found by experimentation against Erlang E2E):
  - CA cert with basicConstraints CA:TRUE
  - Leaf certs with SAN including the dist hostname (and localhost)
  - extendedKeyUsage covering both serverAuth and clientAuth

Tests:
  - make_dist_tls_context unit tests
  - Live: spawn erl -proto_dist inet_tls with SAN-bearing certs,
    Node.call(gen_target, {ping, 99}) round-trips through the tunnel
  - Live negative: plaintext connection to TLS-only peer must fail
  - Live negative: client cert from a different CA must fail

115 tests green across 5 consecutive runs, lint clean.
2026-06-16 12:06:07 -04:00
ac4636e00c
phase 5: Node.call() gen_server protocol
Node wraps EPMD lookup + handshake + Channel into a single client
object. Constructor eagerly opens the dist connection; call() runs the
synchronous $gen_call protocol against a registered name on the peer:

  caller -> {'$gen_call', {FromPid, Ref}, Request}    (REG_SEND)
  server -> {Ref, Reply}                              (SEND)

Synthesized FromPid and a Node-lifetime Ref counter route replies back
to us; mismatched Ref or unexpected control op raises CallProtocolError.
Reply timeout raises CallTimeout (also covers Erlang's silent-drop case
when the registered name doesn't exist).

Tests against an erl peer running a $gen_call-aware loop:
  - {ping, X} -> {pong, X}
  - {add, A, B} -> {ok, A + B}
  - five sequential calls with monotonically increasing Refs
  - server error response surfaces as Python tuple
  - slow responder triggers CallTimeout
  - unknown registered name surfaces as CallTimeout
  - ref uniqueness across 100 synthesized refs

111 tests green across 10 consecutive runs, lint clean.
2026-06-16 11:33:28 -04:00
776efaead3
phase 4: distribution data channel
Channel wraps the post-handshake socket and carries 4-byte length-
prefixed distribution messages: pass-through byte ('p') + ETF control
tuple + optional payload term.

API surface:
  send_raw / recv_raw     -- raw 4-byte framed bytes, empty == net_tick
  send_tick               -- send keepalive frame
  send_control / recv_message  -- structured control + payload
  send_reg_send           -- helper for the REG_SEND case (FromPid,
                             registered name, payload)

recv_message() transparently skips inbound ticks; callers wanting tick
awareness use recv_raw().

etf.decode_term(data, offset) exposed as a streaming decoder so the
channel can read control + payload back-to-back from one frame body.

Tests:
  - pure encode/decode round-trips
  - socketpair tests for framing, ticks, helper signatures
  - live end-to-end against an erl node with a registered echo process:
    EPMD -> handshake -> REG_SEND -> recv reply, payload matches
  - boot script writes a /tmp ready-flag after registering `echo`;
    fixture waits for both EPMD registration AND the flag to dodge
    the race where EPMD registers the node before -eval runs

101 tests green, lint clean.
2026-06-16 11:23:16 -04:00
8c9311f18a
phase 3: v6 distribution handshake
handshake(sock, our_name=..., cookie=...) drives the OTP 23+ dance over
a 2-byte length-prefixed frame stream:

  send_name (N)       client -> server
  recv_status (s)     server -> client
  recv_challenge (N)  server -> client
  challenge_reply (r) client -> server
  challenge_ack (a)   server -> client

Cookie digest formula md5(cookie ++ integer_to_list(challenge)) was
cross-checked against erlang:md5/1 output as a test reference.

Distribution flags in erldistpy/flags.py advertise the minimum useful
set: extended refs/pids, new fun tags, utf8 atoms, maps, big creation,
v6 handshake, unlink id, v4 node containers.

86 tests green: frame builders + parsers as pure functions, digest
reference, full live handshake against `erl -sname -setcookie`, and a
wrong-cookie rejection test.

Newer SHA-256 digest (DFLAG_MANDATORY_25_DIGEST) deferred until a peer
requires it.
2026-06-16 11:08:28 -04:00
99feb57767
roadmap: mark phase 2 done 2026-06-16 11:00:14 -04:00
bbb0c316c5
phase 2: EPMD client
Synchronous TCP client for Erlang Port Mapper Daemon. One request type
(PORT_PLEASE2_REQ, tag 122), one response type (PORT2_RESP, tag 119).
Returns EpmdInfo dataclass or None if the node is not registered.

Tests run two layers:
  - Unit tests against recorded byte streams captured from a real EPMD
    answering for `erl -sname testnode` and for an unregistered name.
  - Integration tests spawn `erl -sname erldistpy_itest` in a fixture
    and verify lookup() returns the live port; skipped if erl or EPMD
    are absent.

10 new tests, 68 total green, lint clean.
2026-06-16 11:00:00 -04:00
b9fd28ca3b
phase 0 + 1: repo bones and ETF codec
Repo scaffolding (LICENSE, Makefile, pyproject.toml, README) matching
unfeed conventions. Flat package layout, ruff config, Unlicense.

ETF codec covers the subset needed for gen_call against an Elixir node:
small/int/big_int, atom_utf8 (legacy atom_ext on decode), binary, nil,
list, small/large tuple, new_pid, newer_reference. Booleans round-trip
as atoms true/false; Python None as atom nil; str encodes to utf-8
binary to match Elixir convention.

Golden vectors were generated from real Erlang term_to_binary/1 output
(generator script at docs/etf_vectors.erl). Decode tests verify wire
compatibility; round-trip tests verify encoder consistency.

make all green: 58 passed, lint clean.

Next phases tracked in docs/ROADMAP.md (EPMD, handshake, channel,
gen_call, TLS, unfeed integration).
2026-06-16 10:39:01 -04:00