Skip to content

Add Python client for UiPath.Ipc#125

Open
eduard-dumitru wants to merge 28 commits into
masterfrom
feature/python
Open

Add Python client for UiPath.Ipc#125
eduard-dumitru wants to merge 28 commits into
masterfrom
feature/python

Conversation

@eduard-dumitru
Copy link
Copy Markdown
Collaborator

@eduard-dumitru eduard-dumitru commented May 28, 2026

Summary

Adds a new Python client for UiPath.Ipc — pure-Python, asyncio-based, speaks the same wire protocol as the .NET package, so a Python consumer can talk to any existing UiPath.Ipc server.

Scope: client only (matches the TypeScript package's scope). Server, callbacks, and streams are explicitly out of scope.

  • Foundation: wire DTOs (Request, Response, CancellationRequest, Error, MessageType), framing (5-byte header + UTF-8 JSON payload), and ClientTransport ABC.
  • Transports: NamedPipeClientTransport (cross-platform, with brief FileNotFoundError retry to ride out the .NET pipe-accept race) and TcpClientTransport.
  • Client: IpcClient with lazy connect, dynamic __getattr__ proxy via client.get_proxy(IContract), auto-reconnect on transport disconnect, optional request_timeout.
  • Errors: RemoteException carrying message / type_name / stack_trace / inner chain (with __cause__ set so Python tracebacks display the full chain).
  • Cancellation: CancelledError in a call sends a CancellationRequest frame with the matching id. No CancellationToken parameter on signatures — Pythonic, task-based.
  • Tests: 76 unit tests (wire DTOs, framing, connection, proxy, errors, cancellation, timeout, reconnect, wire-shape compatibility) + 7 integration tests against a dedicated .NET test server (src/IpcSample.PythonClientTestServer/, net8.0, console logging, callback-free contracts).
  • Polish: README mirroring .NET README structure, PEP 561 py.typed marker, hatchling-built py3-none-any wheel.

The old client+server sketch is preserved under src/Clients/python/_attempt0/ as a reference.

Wire-compatibility tests

A dedicated tests/wire/test_dotnet_compatibility.py track asserts the serialized JSON shape against the .NET schema in UiPath.CoreIpc/Wire/Dtos.cs — field sets, types, sentinels (e.g. Request.TimeoutInSeconds must be a non-null double, with 0 as the "no timeout" marker). These catch a class of regression at unit-test time that would otherwise only surface when the live integration suite runs.

Test plan

  • CI passes (or, until CI knows about Python: locally pytest --no-integration from src/Clients/python/uipath-ipc/ shows 76 passed).
  • Locally pytest (default — includes integration) shows 83 passed in ~3 s on Windows with the .NET SDK installed.
  • python -m build produces uipath_ipc-0.1.0-py3-none-any.whl and the matching sdist.
  • Install the wheel into a fresh venv; import uipath_ipc works and exposes the documented public surface.
  • Manual interop: launch IpcSample.PythonClientTestServer and exercise proxy.AddFloats, proxy.AddComplexNumbers, proxy.DivideByZero (raises RemoteException).

🤖 Generated with Claude Code

eduard-dumitru and others added 24 commits March 31, 2026 20:35
Implements a Python port of the CoreIpc framework, wire-compatible
with the .NET server/client. Includes RPC server and client with
TCP and Named Pipe transports, asyncio-based, zero mandatory deps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add playground/interop_with_dotnet.py that starts the .NET
IpcSample.ConsoleServer and calls IComputingService + ISystemService
from the Python client over named pipes.

Fix named pipe client to use ProactorEventLoop's native pipe I/O
instead of blocking win32file calls in an executor, which deadlocked
on concurrent read/write with a non-overlapped handle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Preserves the prior client+server sketch as a reference while we rebuild
the Python client from scratch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty package skeleton (pyproject.toml + README + __init__) ready for the
phased port. Built with hatchling, requires Python >= 3.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add pytest as a dev extra in pyproject.toml + [tool.pytest.ini_options]
- Configure VS pyproj to use pytest as the test framework
- Add tests/ folder with a smoke test that verifies the package imports
- Update CoreIpc.sln to drop the old Py projects and add uipath-ipc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the explicit <Compile> and <Folder> lists with a single
**\*.py glob (excluding .venv, __pycache__, build, dist, egg-info).
Manage files via the filesystem to keep the glob intact; PTVS will
rewrite the entry on UI-driven Add/Remove.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces uipath_ipc.wire with the four wire message types (Request,
Response, CancellationRequest, Error) and the MessageType enum. All
DTOs are frozen+slotted dataclasses with explicit to_dict/from_dict
(snake_case <-> PascalCase) and to_json/from_json convenience methods.

Covers the .NET wire-format gotcha that Request.Parameters is a list
of *already JSON-encoded* strings, one per argument.

14 tests in tests/wire/test_messages.py verify round-tripping and
match captured .NET-shape JSON payloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5-byte header (uint8 MessageType + int32 LE PayloadLength) + payload.
read_frame and write_frame operate against asyncio.StreamReader and
a structural FrameWriter protocol (any object with write/drain).

Also pulls in pytest-asyncio as a dev extra and enables asyncio_mode=auto
so async test funcs run without per-test markers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-platform: \<server>\pipe\<name> on Windows (via ProactorEventLoop's
create_pipe_connection), /tmp/CoreFxPipe_<name> Unix Domain Socket on
POSIX (matches .NET's NamedPipeClient cross-platform convention).

ClientTransport ABC abstracts the connect step, returning an
(asyncio.StreamReader, asyncio.StreamWriter) pair that downstream layers
(connection, proxy) consume. Transport instances are frozen+slotted
dataclasses; each connect() opens a fresh stream.

Top-level package re-exports ClientTransport + NamedPipeClientTransport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps one (StreamReader, StreamWriter) pair with a background receive
loop that decodes frames and resolves pending response futures by
Request.id. send_request(req) sends a Request frame and awaits the
matching Response.

Supports `async with` for lifecycle management. Failure modes covered:
underlying-stream close fails all in-flight futures; send on a closed
connection raises ConnectionError.

Exception translation (Error -> RemoteException) and cancellation
forwarding (CancellationRequest on caller cancel) are deferred to
Phase C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IpcClient lazily opens an IpcConnection over the configured
ClientTransport; reused across calls. get_proxy(contract) returns a
proxy that satisfies the contract type. __getattr__ on the proxy
intercepts method access — each call json.dumps each positional arg
into Request.Parameters, sends the Request, awaits the matching
Response, json.loads(Data) for non-null Data (or returns None).

Server-returned Errors raise RemoteException (placeholder — Phase C.1
will refine the exception model: chain, type-name mapping).

Keyword arguments are not supported (.NET wire is positional only);
unknown method names raise AttributeError up front.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RemoteException now exposes message, type_name, stack_trace, and inner
as first-class attributes. from_error(error) walks the nested wire
Error chain producing a matching RemoteException chain, and sets
__cause__ so Python tracebacks display the full chain naturally.

str(exc) renders as "[Type] Message" when type is known.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On asyncio.CancelledError during send_request, IpcConnection fires off
a best-effort CancellationRequest frame (matching the original
Request.id) before re-raising. The send is a background task so the
caller's cancellation propagates immediately and the message goes out
asynchronously on the same writer.

Failures during the cancellation send are swallowed (writer may already
be closing). The original CancelledError reaches the caller intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IpcClient(transport, request_timeout=5.0) configures a single knob
that both:
  - sets Request.TimeoutInSeconds on every outgoing call (server-side
    deadline), and
  - wraps each proxy call in asyncio.wait_for(...) (client-side
    deadline → asyncio.TimeoutError).

When the client-side timeout fires, asyncio.wait_for cancels
send_request, which triggers the existing C.2 cancellation forwarding
— the server receives a CancellationRequest matching the timed-out id.

Per-call override is left to the caller via `async with asyncio.timeout(t)`
or `asyncio.wait_for(...)`. No timeout parameter on signatures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IpcConnection's receive loop now sets is_closed=True in a finally,
regardless of which path exited (clean EOF, OSError, unexpected
exception). IpcClient._ensure_connected sees the dead connection,
acloses it cleanly (idempotent), and re-dials via the transport.

The proxy instance is stable across reconnects — same get_proxy
result keeps working after the underlying stream is replaced.

In-flight calls when the drop happens still propagate the underlying
error (no silent retry). Auto-reconnect only fires on the *next* call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps asyncio.open_connection(host, port) behind the same
ClientTransport interface. Same shape as NamedPipeClientTransport —
frozen+slotted dataclass, connect() returns the standard
(StreamReader, StreamWriter) pair.

Includes a loopback smoke test that spins up an asyncio TCP server,
connects, and exchanges bytes — covering the actual networking path
in addition to the constructor/immutability unit tests.

Re-exported at the top-level package alongside the named-pipe one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README.md replaces the stub; mirrors the .NET README structure
  (install, quick start, contracts, cancellation/timeouts/errors,
  auto-reconnect, transports, what's out of scope) but Python-idiomatic
  throughout.
- src/uipath_ipc/py.typed (PEP 561 marker) — signals to mypy/pyright
  that this package ships inline type information.
- pyproject.toml: explicit [tool.hatch.build.targets.wheel].packages
  so hatchling reliably picks up the src layout and includes py.typed.
- Smoke tests now verify the documented public surface stays exported
  and that py.typed travels into the package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tests/integration/ holds tests that exercise the Python client against
the real IpcSample.ConsoleServer. A session-scoped fixture launches
`dotnet run --framework net6.0`, waits for "Server started" on stdout,
yields, then signals CTRL_BREAK (Win) / SIGINT (POSIX) to shut it down.

Gated behind `--integration`. Default `pytest` skips them so the unit
loop stays fast (62 passed, 7 skipped, ~0.36s). Run with
`pytest --integration` to exercise the live interop path.

Coverage: AddFloats, MultiplyInts, EchoString, AddComplexNumbers,
DivideByZero (verifying RemoteException with type_name), and
multi-call reuse on a single client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The --integration flag becomes --no-integration. Integration tests
now run as part of the default `pytest` invocation; pass
--no-integration to skip them.

VS Test Explorer's Run All will now include the .NET interop suite
(launching IpcSample.ConsoleServer via dotnet run). First cold run
incurs the dotnet build cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.NET's NamedPipeServerStream pattern creates pipe instances on demand
— there's a small window between accepting one connection and creating
the next during which CreateFile returns ERROR_FILE_NOT_FOUND. This
shows up as `FileNotFoundError: [WinError 2]` for clients connecting
in the wrong moment (test sessions, server restarts, deploys).

NamedPipeClientTransport._connect_windows now retries on
FileNotFoundError with a bounded backoff (total ~1.85s across 6 tries)
before giving up. All other errors still propagate immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds IpcSample.PythonClientTestServer/ — a net8.0 console host purpose-
built for the Python integration suite:
  - AddConsole() logging so handler activity is visible.
  - Simple callback-free service implementations (the existing
    IpcSample.ConsoleServer's MultiplyInts depends on a client-side
    callback, unusable from a callback-less Python client).
  - Stable "READY pipe=<name>" startup marker.
  - Pipe name configurable via CLI arg (defaults to "uipath-ipc-py-test").

The Python integration fixture launches this project, runs a background
thread to drain the server's stdout, and dumps the full transcript at
session teardown for diagnostics.

Fixes a Python-side wire bug: .NET Request.TimeoutInSeconds is a
non-nullable `double`, with 0 as the "no timeout, use default" sentinel.
Emitting JSON null on this field made Newtonsoft.Json reject the entire
Request during constructor binding and the server silently dropped the
connection. Request.to_dict now emits 0.0 in place of None;
from_dict symmetrically decodes 0/0.0 back to None.

Adds tests/wire/test_dotnet_compatibility.py — 14 tests asserting the
serialized wire shape literally matches the .NET schema
(UiPath.CoreIpc/Wire/Dtos.cs). Catches this class of regression at
unit-test time without needing the integration suite to run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VS's Python Tools (and other IDE debuggers) need debugpy on the active
interpreter to launch a debug session. Without it, "Debug Test" in VS
Test Explorer fails with the vague "Path to debug adapter executable
not specified" dialog.

Putting it in [project.optional-dependencies].dev means a fresh
`pip install -e ".[dev]"` after clone Just Works for debugging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI-side changes needed before the Python work can land green:

- Switch Npm@1's customFeed from the org-level `npm-packages` feed
  (managed outside CoreIpc) to a project-scoped `uipath-ipc-deps`
  (CoreIpc/9a5bdfb1-...). Same npmjs.org upstream, project ownership,
  PyPI upstream already enabled for the eventual Python publishing.

- Disable the org-wide Safe Chain Guard pipeline decorator via
  SCG_KILL_SWITCH=true at pipeline scope. The Aikido shim that SCG
  injects ahead of every npm/python invocation started failing
  installs with Azure-Storage-SAS-shaped 403s (last green CoreIpc
  build was 2026-04-30, after the SCG rollout). Pipeline scope is
  required — task-env scope is too late, the decorator runs in
  pre-job. CoreIpc temporarily opts out of SCG-side malware scanning;
  revisit when the DevOps fix lands.

See azp-nodejs.yaml's inline comment for the full Slack-referenced
story so the next person on this trail doesn't repeat the dead-ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eduard-dumitru and others added 3 commits May 29, 2026 00:04
Reshapes the pipeline so:

- Publishing is opt-in via parameters (`publishNuGet`, `publishNpm`,
  default false). Default runs build + test, never push. When a
  publish parameter is true, its stage runs and gates on its
  environment's approval check.
- NuGet now follows the same approval-gated pattern as NPM, via a
  new `NuGet-Packages` environment that mirrors `NPM-Packages`'
  approval check. The `dotnet nuget push` moves out of
  azp-dotnet-dist.yaml (which built+pushed unconditionally) into a
  new `azp-nuget.publish.steps.yaml` under the gated stage.
- Publish stages can replay against a previous successful build via
  `reuseArtifactsFromBuildId`. When set, the Build stage is Skipped
  (not Failed) and the Publish stages download from the specified
  build. When unset, both behave as before.
- Job names move from environment-centric (".NET on Windows",
  "node.js on Windows", "node.js on Ubuntu") to deliverable-centric
  ("NuGet — .NET on Windows", "NPM — Node + Web on Windows",
  "NPM — Node + Web on Linux (test-only)"). The "test-only" marker
  signals the Linux job is a cross-platform check, not a second
  source of artifacts.
- Rejecting an approval no longer leaves the run as Failed — the
  Publish stages start in `Skipped` state when their parameter is
  false, so the rejection-as-failure footgun is gone.

Out of this pass: Python jobs in Build, Python publish stage, and any
move of `PublishSymbols` into the gated NuGet publish — left as
follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the NPM publish so the project-scoped Azure Artifacts feed
(uipath-ipc-deps) is the primary, always-working target — the pipeline's
build-service identity is already an administrator on it, no PAT
rotation involved.

The existing GitHub Packages publish stays wired up but is marked
continueOnError: true. It's currently expected to fail: post Mini
Shai-Hulud (2026-05-11/12 npm supply-chain incident), UiPath revoked
classic PATs org-wide and migrated everyone to fine-grained PATs, which
don't expose the Packages permission at org level. Per Liviu Bud's
2026-05-25 #dev announcement, a sanctioned pipeline-auth replacement
is in progress but not yet available.

When the platform team ships the replacement, updating the PublishNPM
service connection and dropping continueOnError will restore the
GitHub Packages publish without any other code change.

Until then, runs of Publish_NPM finish as "Succeeded with issues"
rather than Failed — packages still ship to uipath-ipc-deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous attempt used publishFeedCombined which the Npm@1 task ignored,
making it fall back to a default registry URL (uipath.pkgs.visualstudio
.com/_packaging/npm/registry/ — no project, no feed id) and 404 on PUT.

The correct input name on the task is publishFeed. The value format
"project/feedId" stays the same as we used for customFeed on the
install side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant