remoteRF-server 1.1.0.dev35__py3-none-any.whl → 1.1.0.dev37__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.
@@ -149,6 +149,7 @@ def _table() -> list[Command]:
149
149
  Command(("global leave",), "Stop participating", _global("leave"), "Global"),
150
150
  # ---- clusters (sharing devices with other labs) ----
151
151
  Command(("cluster list",), "Clusters shared with other labs", _federation("list"), "Clusters"),
152
+ Command(("cluster info",), "Every lab, shared group and device in a cluster", _federation("info"), "Clusters"),
152
153
  Command(("cluster create",), "Start a cluster: cluster create <name>", _federation("create"), "Clusters"),
153
154
  Command(("cluster invite",), "Invite code for a lab: cluster invite <name>", _federation("invite"), "Clusters"),
154
155
  Command(("cluster join",), "Join with a code: cluster join <code>", _federation("join"), "Clusters"),
@@ -704,6 +704,20 @@ def get_registered_devices() -> Dict[int, Tuple[object, str, str]]:
704
704
  gid: (st.dev, st.salt, st.hsh) for gid, st in _devices.items()
705
705
  }
706
706
 
707
+ def describe_devices(ids) -> list[dict]:
708
+ """Name, type and live state for the given ids (unknown ids are skipped)."""
709
+ _sync_host_devices()
710
+ out = []
711
+ with _state_lock:
712
+ for gid in ids:
713
+ st = _devices.get(int(gid))
714
+ if st is None:
715
+ continue
716
+ out.append({"id": int(gid), "name": (st.name or f"device-{gid}").strip(),
717
+ "type": st.dtype or "", "online": _is_connected(st)})
718
+ return out
719
+
720
+
707
721
  def get_all_devices_str() -> Dict[int, str]:
708
722
  _sync_host_devices()
709
723
 
@@ -38,6 +38,10 @@ def _lab(party: dict) -> str:
38
38
  return f"{party['display_name']} ({short}…)" if party.get("display_name") else party["deployment_id"]
39
39
 
40
40
 
41
+ def _n(count: int, noun: str) -> str:
42
+ return f"{count} {noun}{'' if count == 1 else 's'}"
43
+
44
+
41
45
  def _day(epoch) -> str:
42
46
  import datetime
43
47
 
@@ -64,6 +68,7 @@ def cmd_clusters() -> int:
64
68
  for index, item in enumerate(items):
65
69
  if index:
66
70
  rows.append("")
71
+ owner_row = None
67
72
  if item["role"] == "owner":
68
73
  invites = item["open_invites"]
69
74
  rows.append(f"{item['name']} (yours)" + (f" {invites} open invite{'s' if invites != 1 else ''}" if invites else ""))
@@ -72,20 +77,25 @@ def cmd_clusters() -> int:
72
77
  if not item["members"] and not invites:
73
78
  rows.append(f" nobody yet: `cluster invite {item['name']}`")
74
79
  else:
75
- owner = {"display_name": item.get("peer_name"), "deployment_id": item["peer"]}
80
+ owner_row = owner = {"display_name": item.get("peer_name"), "deployment_id": item["peer"]}
76
81
  rows.append(f"{item['name']} {item['state']}")
77
82
  labs = [(owner, "admin", "", None), (me, "member", "", None)] + [
78
83
  (p, "member", p["state"] or "joining", None) for p in item.get("peers", [])]
79
84
  for lab, role, lab_state, since in labs:
80
- you = " (you)" if lab["deployment_id"] == me["deployment_id"] else ""
81
- line = f" {_lab(lab)} {role}{you}"
85
+ you = lab["deployment_id"] == me["deployment_id"]
86
+ line = f" {_lab(lab)} {role}" + (" (you)" if you else "")
82
87
  if lab_state and lab_state != "active":
83
88
  line += f" {lab_state}"
84
89
  if since:
85
90
  line += f" since {_day(since)}"
86
91
  rows.append(line)
87
- if item["groups"]:
88
- rows.append(" sharing: " + ", ".join(group["group_name"] for group in item["groups"]))
92
+ if you:
93
+ rows.append(" sharing: " + (", ".join(
94
+ f"{g['group_name']} ({_n(len(g['devices']), 'device')})" for g in item["groups"]) or "nothing"))
95
+ else:
96
+ sharing = lab.get("sharing") if lab is not owner_row else item.get("peer_sharing")
97
+ if sharing:
98
+ rows.append(f" sharing {_n(sharing['groups'], 'group')}, {_n(sharing['devices'], 'device')}")
89
99
  rows.append(f" {item['students']} of your users enrolled, {item['visitors']} visiting")
90
100
  print()
91
101
  print_panel("Clusters", rows, literal=True)
@@ -93,6 +103,58 @@ def cmd_clusters() -> int:
93
103
  return 0
94
104
 
95
105
 
106
+ def cmd_cluster_info(name: str) -> int:
107
+ """Every lab in the cluster, each shared group with its limits, each device."""
108
+ from ...common.utils.panel import print_panel
109
+ from ..device_manager import describe_devices
110
+ from ..user_group_cli import _fmt_duration
111
+ from . import cluster
112
+
113
+ config = load_config()
114
+ state = load_state(Path(config["identity_dir"]))
115
+ item = {i["name"]: i for i in cluster.list_clusters()}.get(name)
116
+ if item is None:
117
+ raise cluster.ClusterError(f"no cluster named {name}")
118
+ me = {"display_name": config["display_name"], "deployment_id": state.deployment_id}
119
+ mine = {"groups": len(item["groups"]), "detail": [
120
+ {"name": g["group_name"], "max_reservation_time_sec": g["max_reservation_time_sec"],
121
+ "max_reservations": g["max_reservations"], "lifetime_sec": g["lifetime_sec"],
122
+ "devices": describe_devices(g["devices"])}
123
+ for g in item["groups"]]}
124
+ if item["role"] == "owner":
125
+ labs = [(me, "admin", mine)] + [(m, "member", m.get("sharing")) for m in item["members"]]
126
+ else:
127
+ owner = {"display_name": item.get("peer_name"), "deployment_id": item["peer"]}
128
+ labs = [(owner, "admin", item.get("peer_sharing")), (me, "member", mine)] + [
129
+ (p, "member", p.get("sharing")) for p in item.get("peers", [])]
130
+ rows = []
131
+ for index, (lab, role, sharing) in enumerate(labs):
132
+ if index:
133
+ rows.append("")
134
+ rows.append(f"{_lab(lab)} {role}" + (" (you)" if lab["deployment_id"] == me["deployment_id"] else ""))
135
+ if sharing is None:
136
+ rows.append(" (no details from this lab yet)")
137
+ continue
138
+ if not sharing.get("detail"):
139
+ rows.append(" shares nothing")
140
+ for group in sharing["detail"]:
141
+ limits = " · ".join([
142
+ _n(len(group["devices"]), "device"),
143
+ f"{_fmt_duration(group['max_reservation_time_sec'])} max per reservation",
144
+ f"{_n(group['max_reservations'], 'reservation')} at once",
145
+ "no expiry" if group["lifetime_sec"] <= 0 else f"expires after {_fmt_duration(group['lifetime_sec'])}",
146
+ ])
147
+ rows.append(f" {group['name']} — {limits}")
148
+ width = max([len(d["name"]) for d in group["devices"]] + [4])
149
+ for device in group["devices"]:
150
+ rows.append(f" {device['id']:>6} {device['name']:<{width}} {device['type'] or '?':<12} "
151
+ + ("online" if device["online"] else "offline"))
152
+ print()
153
+ print_panel(f"Cluster {name}", rows, literal=True)
154
+ print()
155
+ return 0
156
+
157
+
96
158
  def _ask_for(verb: str) -> str:
97
159
  """Prompt for the missing cluster name (or invite code), offering the obvious default."""
98
160
  from . import cluster
@@ -100,7 +162,7 @@ def _ask_for(verb: str) -> str:
100
162
  if verb == "join":
101
163
  return _input("Invite code from the other lab: ")
102
164
  names = [name for name, item in sorted(cluster.load().items()) if item.get("role") == "owner"]
103
- if verb in ("remove", "groups", "codes"):
165
+ if verb in ("remove", "groups", "codes", "info"):
104
166
  names = sorted(cluster.load())
105
167
  if verb == "accept":
106
168
  names = [name for name, item in sorted(cluster.load().items()) if item.get("state") == "pending"]
@@ -108,6 +170,7 @@ def _ask_for(verb: str) -> str:
108
170
  "invite": "Which cluster to invite a lab into",
109
171
  "accept": "Which cluster's pending join to accept",
110
172
  "groups": "Which cluster to share groups with",
173
+ "info": "Which cluster to show",
111
174
  "codes": "Which cluster to make student codes for",
112
175
  "remove": "Which cluster to leave (revokes the contract)"}[verb]
113
176
  if verb != "create" and names:
@@ -163,7 +226,7 @@ def _input(prompt: str) -> str:
163
226
 
164
227
 
165
228
  _USAGE = ("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code> | "
166
- "cluster groups [<name>] | cluster codes [<name>] [count]")
229
+ "cluster info [<name>] | cluster groups [<name>] | cluster codes [<name>] [count]")
167
230
 
168
231
 
169
232
  def cmd_cluster(argv: list[str]) -> int:
@@ -179,7 +242,7 @@ def cmd_cluster(argv: list[str]) -> int:
179
242
  words = [_ask_for(verb), words[0]] # `cluster codes 3`: the count came first
180
243
  if not words[0]:
181
244
  return 2
182
- if not words and verb in ("create", "invite", "accept", "join", "remove", "groups", "codes"):
245
+ if not words and verb in ("create", "invite", "accept", "join", "remove", "groups", "codes", "info"):
183
246
  # Leaving the argument out asks for it (docs/deployment-groups.md §8).
184
247
  answer = _ask_for(verb)
185
248
  if not answer:
@@ -205,6 +268,8 @@ def cmd_cluster(argv: list[str]) -> int:
205
268
  _print(f" Accepted. {arg} activates once both servers confirm (about a minute).", Sty.GRAY)
206
269
  elif verb == "remove" and arg:
207
270
  _print(f" {arg}: {cluster.remove(arg)}.", Sty.GRAY)
271
+ elif verb == "info" and arg:
272
+ return cmd_cluster_info(arg)
208
273
  elif verb == "groups" and len(words) == 1:
209
274
  cluster._find(cluster.load(), arg)
210
275
  return _share_groups(arg)
@@ -233,7 +298,7 @@ def main(argv) -> int:
233
298
  # `cluster <verb> …` from the table; `global cluster …` is the old spelling.
234
299
  if argv and argv[0] in ("cluster", "clusters"):
235
300
  argv = argv[1:] or ["list"]
236
- if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove", "groups", "codes"):
301
+ if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove", "groups", "codes", "info"):
237
302
  return cmd_cluster(argv)
238
303
  print(_USAGE, file=sys.stderr)
239
304
  return 2
@@ -404,7 +404,8 @@ def list_clusters() -> list[dict]:
404
404
  except ContractError:
405
405
  pass
406
406
  members.append({"deployment_id": peer, "display_name": member.get("display_name"),
407
- "state": state, "joined_at": member.get("joined_at")})
407
+ "state": state, "joined_at": member.get("joined_at"),
408
+ "sharing": member.get("sharing")})
408
409
  item.update(members=members, open_invites=len(cluster["invites"]))
409
410
  else:
410
411
  state = cluster.get("state")
@@ -414,9 +415,11 @@ def list_clusters() -> list[dict]:
414
415
  state = store.show(cluster["contract_id"], 1)["state"]
415
416
  except ContractError:
416
417
  pass
417
- peers = [{"deployment_id": peer, "display_name": party.get("display_name"), "state": party.get("state")}
418
+ peers = [{"deployment_id": peer, "display_name": party.get("display_name"), "state": party.get("state"),
419
+ "sharing": party.get("sharing")}
418
420
  for peer, party in sorted(cluster.get("peers", {}).items())]
419
- item.update(peer=cluster["peer"], peer_name=cluster.get("peer_name"), state=state, peers=peers)
421
+ item.update(peer=cluster["peer"], peer_name=cluster.get("peer_name"), state=state, peers=peers,
422
+ peer_sharing=cluster.get("sharing"))
420
423
  item.update(groups=groups(name), **_account_counts(name))
421
424
  out.append(item)
422
425
  return out
@@ -445,12 +448,34 @@ def groups(name: str) -> list[dict]:
445
448
  out = []
446
449
  with _usergroups() as conn:
447
450
  for group_id in cluster.get("groups", []):
448
- row = conn.execute("SELECT group_name FROM UserGroups WHERE group_id=?", (group_id,)).fetchone()
451
+ row = conn.execute(
452
+ "SELECT group_name,devices_whitelist,max_reservation_time_sec,max_reservations,lifetime_sec "
453
+ "FROM UserGroups WHERE group_id=?", (group_id,)).fetchone()
449
454
  if row is not None:
450
- out.append({"group_id": group_id, "group_name": row[0]})
455
+ try:
456
+ devices = {int(d) for d in json.loads(row[1] or "[]")}
457
+ except (ValueError, TypeError):
458
+ devices = set()
459
+ out.append({"group_id": group_id, "group_name": row[0], "devices": sorted(devices),
460
+ "max_reservation_time_sec": int(row[2]), "max_reservations": int(row[3]),
461
+ "lifetime_sec": int(row[4])})
451
462
  return out
452
463
 
453
464
 
465
+ def _sharing(name: str) -> dict:
466
+ """What the other labs in the cluster get told about what we share:
467
+ counts, plus each shared group's limits and devices (`cluster info`)."""
468
+ from ..device_manager import describe_devices
469
+
470
+ shared = groups(name)
471
+ detail = [{
472
+ "name": g["group_name"], "max_reservation_time_sec": g["max_reservation_time_sec"],
473
+ "max_reservations": g["max_reservations"], "lifetime_sec": g["lifetime_sec"],
474
+ "devices": describe_devices(g["devices"]),
475
+ } for g in shared]
476
+ return {"groups": len(shared), "devices": len({d for g in shared for d in g["devices"]}), "detail": detail}
477
+
478
+
454
479
  def _account_counts(name: str) -> dict:
455
480
  with _usergroups() as conn:
456
481
  rows = dict(conn.execute(
@@ -687,6 +712,33 @@ def _publish_roster(name: str, cluster: dict) -> None:
687
712
  cluster["roster"] = active
688
713
 
689
714
 
715
+ def _publish_status(name: str, cluster: dict) -> None:
716
+ """Any lab: post our counts for this cluster when they change."""
717
+ sharing = _sharing(name)
718
+ if sharing == cluster.get("status"):
719
+ return
720
+ key, _, me = g._signing_material()
721
+ owner = me if cluster["role"] == "owner" else cluster["peer"]
722
+ status = {"cluster_name": name, "owner": owner, "deployment_id": me, "issued_at": int(time.time()), **sharing}
723
+ signature = base64.b64encode(key.sign(canonical_json(status).encode())).decode("ascii")
724
+ g.call("POST", "/v2/broker/clusters/roster", {"protocol_version": "2", "status": status, "signature": signature})
725
+ cluster["status"] = sharing
726
+
727
+
728
+ def _take_statuses(name: str, cluster: dict, statuses: list[dict]) -> None:
729
+ """Attach each partner's verified counts to its row."""
730
+ owner = g._signing_material()[2] if cluster["role"] == "owner" else cluster["peer"]
731
+ parties = dict(_parties(cluster))
732
+ for item in statuses:
733
+ status = item.get("status") or {}
734
+ peer = status.get("deployment_id")
735
+ if status.get("owner") != owner or status.get("cluster_name") != name or peer not in parties:
736
+ continue
737
+ if _roster_signed_by(peer, status, item.get("signature", "")):
738
+ parties[peer]["sharing"] = {"groups": status["groups"], "devices": status["devices"],
739
+ "detail": status.get("detail", [])}
740
+
741
+
690
742
  def _roster_signed_by(owner: str, roster: dict, signature: str) -> bool:
691
743
  from cryptography.exceptions import InvalidSignature
692
744
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
@@ -774,7 +826,7 @@ def tick(notify=None, home=None) -> None:
774
826
  return
775
827
  directory = None
776
828
  broker_invites = None
777
- rosters = None
829
+ board = None # rosters and status notes, one GET for all clusters
778
830
  for name, cluster in clusters.items():
779
831
  if cluster["role"] == "owner":
780
832
  if cluster["invites"]:
@@ -797,9 +849,15 @@ def tick(notify=None, home=None) -> None:
797
849
  if directory is None:
798
850
  directory = g.call("GET", "/v2/broker/contracts")["contracts"]
799
851
  _advance(cluster, cluster["peer"], directory, label=name, notify=notify, is_member=True)
800
- if rosters is None:
801
- rosters = g.call("GET", "/v2/broker/clusters/roster")["rosters"]
802
- _mesh(name, cluster, rosters, directory, notify)
852
+ if board is None:
853
+ board = g.call("GET", "/v2/broker/clusters/roster")
854
+ if cluster["role"] != "owner":
855
+ _mesh(name, cluster, board["rosters"], directory, notify)
856
+ try:
857
+ _publish_status(name, cluster)
858
+ except g.GlobalError as exc:
859
+ notify(f"[cluster] {name}: could not publish what we share: {exc}")
860
+ _take_statuses(name, cluster, board.get("statuses", []))
803
861
  save(clusters)
804
862
  if home is not None:
805
863
  enroll_students(home, notify)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.1.0.dev35
3
+ Version: 1.1.0.dev37
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -52,10 +52,10 @@ remoteRF_server/host/host_tunnel_server.py,sha256=sz06xOtFbaDZSy1UQfw6faKhJmuOy5
52
52
  remoteRF_server/server/__init__.py,sha256=-1h_xQjiE66oiWRO-vnLGJ4smpgUNhFuV-ZreW9_tD4,667
53
53
  remoteRF_server/server/acc_perms.py,sha256=2loCvstVu8uvsb2uCv2Sk7iNQ5GyO_sBIkPQFzqOaRI,13334
54
54
  remoteRF_server/server/cert_provider.py,sha256=CRDrZL4DSf-eL6wzPJzwucrzPLOr2PvNRq5V--oZtr0,6272
55
- remoteRF_server/server/commands.py,sha256=xG_RAYm0nHDqN74b-QPHizw2OodfrYAvdYlVNNi7tGE,17279
55
+ remoteRF_server/server/commands.py,sha256=6HcvEpznOblV57NMuOJTl2VY2js1wbuLV9mwxGDqMeg,17399
56
56
  remoteRF_server/server/console.py,sha256=J91LfwPVmBgielOxfE740NOYduekggyZkqi-47y_A4w,4472
57
57
  remoteRF_server/server/control.py,sha256=Uq1Fk0zIoHHX_EXfz3OuNle8urvO_UhAiB5Lk5cs2e4,5177
58
- remoteRF_server/server/device_manager.py,sha256=nRTvk_jeZFU3QnlM6qf3DIckU2V2e_gyDeD5gW14KQI,38802
58
+ remoteRF_server/server/device_manager.py,sha256=PPuCwlPSmjfVpCqy_hCaOr9WvFgE8mqtLMhWOAgXNDA,39281
59
59
  remoteRF_server/server/direct_path.py,sha256=CQs0uPTANJq6Bn-TzsV0APmD3hYKH8xYvczXZZk1OXk,15229
60
60
  remoteRF_server/server/dynamic_v2.py,sha256=AMhQEFhNAvCNcF-PdfCCQQLmeoaR7s1Os8et1KFGn_8,70814
61
61
  remoteRF_server/server/global_onboarding.py,sha256=boLAKXCKvcsIY3TDiFwduMaHdDMB9Cr104MyWJYZ-BA,39891
@@ -70,8 +70,8 @@ remoteRF_server/server/deployment/__init__.py,sha256=YV86uafd8XiGD7sLaqNfO5xxmt5
70
70
  remoteRF_server/server/deployment/capabilities.py,sha256=20RxmGG03VoQQZnV3jGhc-3i9B5hOl5fCopnsErFjPk,872
71
71
  remoteRF_server/server/federation/__init__.py,sha256=auqdGiGnEGIKQiXX2jFVt8iWmfyZ-V3w4dUmtWDKAZE,735
72
72
  remoteRF_server/server/federation/admin.py,sha256=t3F17FIytekzclPICmaFJEA4-F5OuWcDx3rgmFga17A,9187
73
- remoteRF_server/server/federation/cli.py,sha256=m3fzURqmnZH1pO7wHyqg5EUoo4ZPiw7P3K_9q8SXQu8,10703
74
- remoteRF_server/server/federation/cluster.py,sha256=yKGFRAI-RHB_xZivJkxaK_Fniz2XGRUfArJXM_DH2WQ,33207
73
+ remoteRF_server/server/federation/cli.py,sha256=rMA81mk656tP29cPntTIlLE95JTUeJ-RdNZSTU_eJuI,13995
74
+ remoteRF_server/server/federation/cluster.py,sha256=bQpT7jq-2d87ae6AkmsKktozBsULSy3QrIbAVgBLMQg,36238
75
75
  remoteRF_server/server/federation/contracts.py,sha256=NYh7lb_9etJHFfzXwevjC4HfSBUypLxg5xGeBgydLF4,29305
76
76
  remoteRF_server/server/federation/destination.py,sha256=XawmGCiVbL_MfNNn8xwV9_bYxUDW1L7efCpL11K2JAc,35909
77
77
  remoteRF_server/server/federation/devices.py,sha256=n5MvnD0UZyl4sFpiekG9ProanWXTYizYq6YchCflLgY,18061
@@ -90,9 +90,9 @@ remoteRF_server/tools/gen_certs.py,sha256=Vbyu8JjRu__UYPVmyCRXt5SOw0Za1HV7g1md3G
90
90
  remoteRF_server/tools/gist_status.py,sha256=SFv2iQgQrIKT9gxKrU-_Dr7S0p0rE4wQM6J3OC6FcoM,4649
91
91
  remoteRF_server/tools/gist_status_testing.py,sha256=IYw7UkPjAUpKDTtIlnYGb5g6SJkwz4TRGUfJJ6f4LhQ,2690
92
92
  remoteRF_server/tools/introspect_uhd.py,sha256=vUpPb0rM9tsHPxWadS3EfMDE-gMd-TvNV0TjrZOQqBY,4531
93
- remoterf_server-1.1.0.dev35.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
- remoterf_server-1.1.0.dev35.dist-info/METADATA,sha256=meRywJNeTPy2Yol5Gc-G6zGPXnvPdyIkH-JlIe9H93Y,42069
95
- remoterf_server-1.1.0.dev35.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
- remoterf_server-1.1.0.dev35.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
- remoterf_server-1.1.0.dev35.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
- remoterf_server-1.1.0.dev35.dist-info/RECORD,,
93
+ remoterf_server-1.1.0.dev37.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
+ remoterf_server-1.1.0.dev37.dist-info/METADATA,sha256=L_odnm8vq5_QVx26laNnT9KsfhijKBkDPfgubrd_hig,42069
95
+ remoterf_server-1.1.0.dev37.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
+ remoterf_server-1.1.0.dev37.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
+ remoterf_server-1.1.0.dev37.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
+ remoterf_server-1.1.0.dev37.dist-info/RECORD,,