fix(clients): render ReplayGap as the typed cross-CLI row in the .NET and Java CLIs (NEXT-02)
The .NET and Java stream-events commands handed the raw ReplayGap sentinel
MxEvent to their protobuf JSON formatters (Java text mode printed
'0 MX_EVENT_FAMILY_UNSPECIFIED'), while the Go/Python/Rust CLIs already emit
the typed row (CLI-35/36). Both now branch on the sentinel: Java text mode
prints 'REPLAY_GAP requested_after=<n> oldest_available=<n>' and JSON mode a
hand-built {"replayGap":{...}} line via nextItem()/isReplayGap(); the .NET
CLI emits the same hand-built row in jsonl/text (its text mode is
JSON-per-line) and inside the --json events array. Rows are hand-built so
the cursors are JSON numbers like the other three CLIs, not the proto3 JSON
mapping's quoted uint64 strings — the CrossLanguageSmokeMatrix divergence
table collapses to a single converged contract.
Tests: .NET MxGatewayClientCliTests 35/35 (new RendersReplayGapAsTypedRow
covers jsonl + aggregate); Java gradle test 52/52 (new
streamEventsRendersReplayGapAsTypedRow covers --json + text over the
in-process harness). No generated-file churn.
This commit is contained in:
@@ -1418,14 +1418,16 @@ public static class MxGatewayClientCli
|
|||||||
.WithCancellation(cancellationToken)
|
.WithCancellation(cancellationToken)
|
||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
if (jsonLines)
|
if (json && !jsonLines)
|
||||||
{
|
|
||||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
|
||||||
}
|
|
||||||
else if (json)
|
|
||||||
{
|
{
|
||||||
events.Add(gatewayEvent);
|
events.Add(gatewayEvent);
|
||||||
}
|
}
|
||||||
|
else if (gatewayEvent.ReplayGap is { } replayGap)
|
||||||
|
{
|
||||||
|
// Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw
|
||||||
|
// sentinel MxEvent (NEXT-02, mirroring the Go/Python/Rust CLIs).
|
||||||
|
output.WriteLine(FormatReplayGapRow(replayGap));
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||||
@@ -1835,7 +1837,31 @@ public static class MxGatewayClientCli
|
|||||||
|
|
||||||
private static JsonElement EventToJsonElement(MxEvent gatewayEvent)
|
private static JsonElement EventToJsonElement(MxEvent gatewayEvent)
|
||||||
{
|
{
|
||||||
return JsonDocument.Parse(ProtobufJsonFormatter.Format(gatewayEvent)).RootElement.Clone();
|
string row = gatewayEvent.ReplayGap is { } replayGap
|
||||||
|
? FormatReplayGapRow(replayGap)
|
||||||
|
: ProtobufJsonFormatter.Format(gatewayEvent);
|
||||||
|
return JsonDocument.Parse(row).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the
|
||||||
|
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
|
||||||
|
/// formatter's quoted uint64 strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="replayGap">Replay gap sentinel payload.</param>
|
||||||
|
/// <returns>A single-line JSON row describing the gap.</returns>
|
||||||
|
private static string FormatReplayGapRow(ReplayGap replayGap)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
replayGap = new
|
||||||
|
{
|
||||||
|
requestedAfterSequence = replayGap.RequestedAfterSequence,
|
||||||
|
oldestAvailableSequence = replayGap.OldestAvailableSequence,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JsonOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MxValue ParseValue(CliArguments arguments)
|
private static MxValue ParseValue(CliArguments arguments)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Client.Cli;
|
using ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
@@ -585,6 +586,84 @@ public sealed class MxGatewayClientCliTests
|
|||||||
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
|
||||||
|
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_StreamEvents_RendersReplayGapAsTypedRow()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
FakeCliClient fakeClient = new();
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
ReplayGap = new ReplayGap
|
||||||
|
{
|
||||||
|
RequestedAfterSequence = 7,
|
||||||
|
OldestAvailableSequence = 42,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fakeClient.Events.Add(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 43,
|
||||||
|
});
|
||||||
|
|
||||||
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-events",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--max-events",
|
||||||
|
"2",
|
||||||
|
],
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
string[] rows = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
Assert.Equal(2, rows.Length);
|
||||||
|
using JsonDocument gapRow = JsonDocument.Parse(rows[0]);
|
||||||
|
JsonElement gap = gapRow.RootElement.GetProperty("replayGap");
|
||||||
|
Assert.Equal(7UL, gap.GetProperty("requestedAfterSequence").GetUInt64());
|
||||||
|
Assert.Equal(42UL, gap.GetProperty("oldestAvailableSequence").GetUInt64());
|
||||||
|
Assert.Equal(JsonValueKind.Number, gap.GetProperty("requestedAfterSequence").ValueKind);
|
||||||
|
Assert.DoesNotContain("MX_EVENT_FAMILY_UNSPECIFIED", rows[0], StringComparison.Ordinal);
|
||||||
|
Assert.Contains("workerSequence", rows[1], StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// The aggregate --json shape carries the same typed row inside the events array.
|
||||||
|
using var aggregateOutput = new StringWriter();
|
||||||
|
int aggregateExit = await MxGatewayClientCli.RunAsync(
|
||||||
|
[
|
||||||
|
"stream-events",
|
||||||
|
"--endpoint",
|
||||||
|
"http://localhost:5000",
|
||||||
|
"--api-key",
|
||||||
|
"test-api-key",
|
||||||
|
"--session-id",
|
||||||
|
"session-fixture",
|
||||||
|
"--max-events",
|
||||||
|
"2",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
aggregateOutput,
|
||||||
|
error,
|
||||||
|
_ => fakeClient);
|
||||||
|
|
||||||
|
Assert.Equal(0, aggregateExit);
|
||||||
|
using JsonDocument aggregate = JsonDocument.Parse(aggregateOutput.ToString());
|
||||||
|
JsonElement firstRow = aggregate.RootElement.GetProperty("events")[0];
|
||||||
|
Assert.Equal(42UL, firstRow.GetProperty("replayGap").GetProperty("oldestAvailableSequence").GetUInt64());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
|||||||
+22
-1
@@ -5,6 +5,7 @@ import com.zb.mom.ww.mxgateway.client.DeployEventStream;
|
|||||||
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
|
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
|
||||||
import com.zb.mom.ww.mxgateway.client.LazyBrowseNode;
|
import com.zb.mom.ww.mxgateway.client.LazyBrowseNode;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxEventStream;
|
import com.zb.mom.ww.mxgateway.client.MxEventStream;
|
||||||
|
import com.zb.mom.ww.mxgateway.client.MxEventStreamItem;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
|
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
|
||||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
||||||
@@ -59,6 +60,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.PingCommand;
|
import mxaccess_gateway.v1.MxaccessGateway.PingCommand;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||||
@@ -1654,12 +1656,31 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
|
MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
while (events.hasNext()) {
|
while (events.hasNext()) {
|
||||||
MxEvent event = events.next();
|
MxEventStreamItem item = events.nextItem();
|
||||||
|
if (item.isReplayGap()) {
|
||||||
|
// Render the ReplayGap sentinel as the typed cross-CLI row (NEXT-02,
|
||||||
|
// mirroring the Go/Python/Rust/.NET CLIs) instead of the raw sentinel
|
||||||
|
// event, whose text form printed "0 MX_EVENT_FAMILY_UNSPECIFIED".
|
||||||
|
ReplayGap gap = item.replayGap();
|
||||||
|
if (json) {
|
||||||
|
client.out().printf(
|
||||||
|
"{\"replayGap\":{\"requestedAfterSequence\":%s,\"oldestAvailableSequence\":%s}}%n",
|
||||||
|
Long.toUnsignedString(gap.getRequestedAfterSequence()),
|
||||||
|
Long.toUnsignedString(gap.getOldestAvailableSequence()));
|
||||||
|
} else {
|
||||||
|
client.out().printf(
|
||||||
|
"REPLAY_GAP requested_after=%s oldest_available=%s%n",
|
||||||
|
Long.toUnsignedString(gap.getRequestedAfterSequence()),
|
||||||
|
Long.toUnsignedString(gap.getOldestAvailableSequence()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MxEvent event = item.event();
|
||||||
if (json) {
|
if (json) {
|
||||||
client.out().println(protoJson(event));
|
client.out().println(protoJson(event));
|
||||||
} else {
|
} else {
|
||||||
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
|
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
count++;
|
count++;
|
||||||
if (limit > 0 && count >= limit) {
|
if (limit > 0 && count >= limit) {
|
||||||
events.close();
|
events.close();
|
||||||
|
|||||||
+54
@@ -43,6 +43,7 @@ import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
@@ -902,6 +903,59 @@ final class MxGatewayCliTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamEventsRendersReplayGapAsTypedRow() {
|
||||||
|
// NEXT-02: the ReplayGap sentinel must render as the typed cross-CLI
|
||||||
|
// row (numeric cursors under a replayGap key in --json, a REPLAY_GAP
|
||||||
|
// line in text mode), never as the raw sentinel event — text mode
|
||||||
|
// used to print "0 MX_EVENT_FAMILY_UNSPECIFIED".
|
||||||
|
MxEvent gap = MxEvent.newBuilder()
|
||||||
|
.setReplayGap(ReplayGap.newBuilder()
|
||||||
|
.setRequestedAfterSequence(7L)
|
||||||
|
.setOldestAvailableSequence(42L)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
MxEvent dataChange = MxEvent.newBuilder()
|
||||||
|
.setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
|
||||||
|
.setSessionId("session-cli")
|
||||||
|
.setWorkerSequence(43L)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
|
||||||
|
harness.setScriptedEvents(List.of(gap, dataChange));
|
||||||
|
CliRun jsonRun = execute(
|
||||||
|
new HarnessClientFactory(harness),
|
||||||
|
"stream-events",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, jsonRun.exitCode(), "errors:\n" + jsonRun.errors());
|
||||||
|
String jsonOut = jsonRun.output();
|
||||||
|
assertTrue(
|
||||||
|
jsonOut.contains(
|
||||||
|
"{\"replayGap\":{\"requestedAfterSequence\":7,\"oldestAvailableSequence\":42}}"),
|
||||||
|
jsonOut);
|
||||||
|
assertTrue(jsonOut.contains("\"family\":\"MX_EVENT_FAMILY_ON_DATA_CHANGE\""), jsonOut);
|
||||||
|
assertFalse(jsonOut.contains("MX_EVENT_FAMILY_UNSPECIFIED"), jsonOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
|
||||||
|
harness.setScriptedEvents(List.of(gap, dataChange));
|
||||||
|
CliRun textRun = execute(
|
||||||
|
new HarnessClientFactory(harness),
|
||||||
|
"stream-events",
|
||||||
|
"--session-id",
|
||||||
|
"session-cli");
|
||||||
|
|
||||||
|
assertEquals(0, textRun.exitCode(), "errors:\n" + textRun.errors());
|
||||||
|
String textOut = textRun.output();
|
||||||
|
assertTrue(textOut.contains("REPLAY_GAP requested_after=7 oldest_available=42"), textOut);
|
||||||
|
assertFalse(textOut.contains("MX_EVENT_FAMILY_UNSPECIFIED"), textOut);
|
||||||
|
assertTrue(textOut.contains("43 MX_EVENT_FAMILY_ON_DATA_CHANGE"), textOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- galaxy-discover / galaxy-watch over the in-process harness (Task 6) ----
|
// ---- galaxy-discover / galaxy-watch over the in-process harness (Task 6) ----
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -40,27 +40,24 @@ reports the next deliverable sequence rather than `0` (see [Sessions](Sessions.m
|
|||||||
The default smoke sequence opens a fresh stream (no cursor) and does not exercise
|
The default smoke sequence opens a fresh stream (no cursor) and does not exercise
|
||||||
the gap path; a resume-with-gap fixture case is tracked separately (TST-24).
|
the gap path; a resume-with-gap fixture case is tracked separately (TST-24).
|
||||||
|
|
||||||
The CLIs differ in how they *print* that library-level signal. Three of them consume
|
All five CLIs consume the typed gap and emit a dedicated row rather than a
|
||||||
the typed gap and emit a dedicated row rather than a degenerate event row; the other
|
degenerate event row (the .NET and Java halves were the last to convert — NEXT-02):
|
||||||
two hand the raw sentinel `MxEvent` straight to the formatter, so they print the
|
|
||||||
sentinel itself, whose `replayGap` field carries the same cursors:
|
|
||||||
|
|
||||||
| CLI | Text mode | JSON mode |
|
| CLI | Text mode | JSON mode |
|
||||||
|-----|-----------|-----------|
|
|-----|-----------|-----------|
|
||||||
| `mxgw-rs` (Rust, canonical) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` as one entry of the `events` array |
|
| `mxgw-rs` (Rust, canonical) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` as one entry of the `events` array |
|
||||||
| `mxgw-go` (Go) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | one `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` line, counted toward `-limit` like any other row |
|
| `mxgw-go` (Go) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | one `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` line, counted toward `-limit` like any other row |
|
||||||
| `mxgw-py` (Python) | same JSON dump as `--json` | `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` as one entry of the `events` array |
|
| `mxgw-py` (Python) | same JSON dump as `--json` | `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` as one entry of the `events` array |
|
||||||
| `mxgw-dotnet` (.NET) | the raw sentinel `MxEvent` as protobuf JSON, including its `replayGap` field | same, as one entry of the `events` array |
|
| `mxgw-dotnet` (.NET) | one `{"replayGap": {...}}` line (its "text" mode is JSON-per-line) | the same row — per line with `--jsonl`, as one entry of the `events` array with `--json` |
|
||||||
| `mxgw-java` (Java) | the sentinel's `worker_sequence` and `family` (`0 MX_EVENT_FAMILY_UNSPECIFIED`) | the raw sentinel `MxEvent` as protobuf JSON, including its `replayGap` field |
|
| `mxgw-java` (Java) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | one `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` line |
|
||||||
|
|
||||||
Rust, Go, and Python emit the same two key names and, deliberately, the same JSON
|
All five emit the same two key names and, deliberately, the same JSON value
|
||||||
value **types**: the cursors are JSON numbers (`7`), not strings. That is why the
|
**types**: the cursors are JSON numbers (`7`), not strings. That is why every
|
||||||
Go CLI types the row by hand instead of marshalling `ReplayGap` with `protojson` —
|
CLI types the row by hand instead of marshalling `ReplayGap` through its
|
||||||
the proto3 JSON mapping renders 64-bit integers as strings (`"7"`), which is also
|
protobuf JSON formatter — the proto3 JSON mapping renders 64-bit integers as
|
||||||
why the .NET and Java rows, which pass the sentinel through a protobuf JSON
|
strings (`"7"`). Normal event rows still come from the protobuf formatters, so
|
||||||
formatter, carry **quoted** cursors. A matrix runner must therefore compare parsed
|
a matrix runner must still compare parsed values, not raw bytes, when it mixes
|
||||||
values, not raw bytes, and must not assume the same value type across all five
|
gap rows with event rows.
|
||||||
CLIs.
|
|
||||||
|
|
||||||
Two further formatting differences among the three canonical CLIs, none of them
|
Two further formatting differences among the three canonical CLIs, none of them
|
||||||
semantic: Python sorts object keys and uses `", "` / `": "` separators
|
semantic: Python sorts object keys and uses `", "` / `": "` separators
|
||||||
|
|||||||
Reference in New Issue
Block a user