mjczone-realtime-contract 0.1.0__tar.gz

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.
@@ -0,0 +1,493 @@
1
+ ## Ignore Visual Studio temporary files, build results, and
2
+ ## files generated by popular Visual Studio add-ons.
3
+ ##
4
+ ## Get latest from `dotnet new gitignore`
5
+
6
+ # dotenv files
7
+ .env
8
+
9
+ # User-specific files
10
+ *.rsuser
11
+ *.suo
12
+ *.user
13
+ *.userosscache
14
+ *.sln.docstates
15
+ private/
16
+ tmp/
17
+
18
+ # User-specific files (MonoDevelop/Xamarin Studio)
19
+ *.userprefs
20
+
21
+ # Mono auto generated files
22
+ mono_crash.*
23
+
24
+ # Build results
25
+ [Dd]ebug/
26
+ [Dd]ebugPublic/
27
+ [Rr]elease/
28
+ [Rr]eleases/
29
+ x64/
30
+ x86/
31
+ [Ww][Ii][Nn]32/
32
+ [Aa][Rr][Mm]/
33
+ [Aa][Rr][Mm]64/
34
+ bld/
35
+ [Bb]in/
36
+ [Oo]bj/
37
+ [Ll]og/
38
+ [Ll]ogs/
39
+
40
+ # Visual Studio 2015/2017 cache/options directory
41
+ .vs/
42
+ # Uncomment if you have tasks that create the project's static files in wwwroot
43
+ #wwwroot/
44
+
45
+ # Visual Studio 2017 auto generated files
46
+ Generated\ Files/
47
+
48
+ # MSTest test Results
49
+ [Tt]est[Rr]esult*/
50
+ [Bb]uild[Ll]og.*
51
+
52
+ # NUnit
53
+ *.VisualState.xml
54
+ TestResult.xml
55
+ nunit-*.xml
56
+
57
+ # Build Results of an ATL Project
58
+ [Dd]ebugPS/
59
+ [Rr]eleasePS/
60
+ dlldata.c
61
+
62
+ # Benchmark Results
63
+ BenchmarkDotNet.Artifacts/
64
+
65
+ # .NET
66
+ project.lock.json
67
+ project.fragment.lock.json
68
+ artifacts/
69
+
70
+ # Tye
71
+ .tye/
72
+
73
+ # ASP.NET Scaffolding
74
+ ScaffoldingReadMe.txt
75
+
76
+ # StyleCop
77
+ StyleCopReport.xml
78
+
79
+ # Files built by Visual Studio
80
+ *_i.c
81
+ *_p.c
82
+ *_h.h
83
+ *.ilk
84
+ *.meta
85
+ *.obj
86
+ *.iobj
87
+ *.pch
88
+ *.pdb
89
+ *.ipdb
90
+ *.pgc
91
+ *.pgd
92
+ *.rsp
93
+ # but not Directory.Build.rsp, as it configures directory-level build defaults
94
+ !Directory.Build.rsp
95
+ *.sbr
96
+ *.tlb
97
+ *.tli
98
+ *.tlh
99
+ *.tmp
100
+ *.tmp_proj
101
+ *_wpftmp.csproj
102
+ *.log
103
+ *.tlog
104
+ *.vspscc
105
+ *.vssscc
106
+ .builds
107
+ *.pidb
108
+ *.svclog
109
+ *.scc
110
+
111
+ # Chutzpah Test files
112
+ _Chutzpah*
113
+
114
+ # Visual C++ cache files
115
+ ipch/
116
+ *.aps
117
+ *.ncb
118
+ *.opendb
119
+ *.opensdf
120
+ *.sdf
121
+ *.cachefile
122
+ *.VC.db
123
+ *.VC.VC.opendb
124
+
125
+ # Visual Studio profiler
126
+ *.psess
127
+ *.vsp
128
+ *.vspx
129
+ *.sap
130
+
131
+ # Visual Studio Trace Files
132
+ *.e2e
133
+
134
+ # TFS 2012 Local Workspace
135
+ $tf/
136
+
137
+ # Guidance Automation Toolkit
138
+ *.gpState
139
+
140
+ # ReSharper is a .NET coding add-in
141
+ _ReSharper*/
142
+ *.[Rr]e[Ss]harper
143
+ *.DotSettings.user
144
+
145
+ # TeamCity is a build add-in
146
+ _TeamCity*
147
+
148
+ # DotCover is a Code Coverage Tool
149
+ *.dotCover
150
+
151
+ # AxoCover is a Code Coverage Tool
152
+ .axoCover/*
153
+ !.axoCover/settings.json
154
+
155
+ # Coverlet is a free, cross platform Code Coverage Tool
156
+ coverage*.json
157
+ coverage*.xml
158
+ coverage*.info
159
+
160
+ # Visual Studio code coverage results
161
+ *.coverage
162
+ *.coveragexml
163
+
164
+ # NCrunch
165
+ _NCrunch_*
166
+ .*crunch*.local.xml
167
+ nCrunchTemp_*
168
+
169
+ # MightyMoose
170
+ *.mm.*
171
+ AutoTest.Net/
172
+
173
+ # Web workbench (sass)
174
+ .sass-cache/
175
+
176
+ # Installshield output folder
177
+ [Ee]xpress/
178
+
179
+ # DocProject is a documentation generator add-in
180
+ DocProject/buildhelp/
181
+ DocProject/Help/*.HxT
182
+ DocProject/Help/*.HxC
183
+ DocProject/Help/*.hhc
184
+ DocProject/Help/*.hhk
185
+ DocProject/Help/*.hhp
186
+ DocProject/Help/Html2
187
+ DocProject/Help/html
188
+
189
+ # Click-Once directory
190
+ publish/
191
+
192
+ # Publish Web Output
193
+ *.[Pp]ublish.xml
194
+ *.azurePubxml
195
+ # Note: Comment the next line if you want to checkin your web deploy settings,
196
+ # but database connection strings (with potential passwords) will be unencrypted
197
+ *.pubxml
198
+ *.publishproj
199
+
200
+ # Microsoft Azure Web App publish settings. Comment the next line if you want to
201
+ # checkin your Azure Web App publish settings, but sensitive information contained
202
+ # in these scripts will be unencrypted
203
+ PublishScripts/
204
+
205
+ # NuGet Packages
206
+ *.nupkg
207
+ # NuGet Symbol Packages
208
+ *.snupkg
209
+ # The packages folder can be ignored because of Package Restore
210
+ **/[Pp]ackages/*
211
+ # except build/, which is used as an MSBuild target.
212
+ !**/[Pp]ackages/build/
213
+ # Uncomment if necessary however generally it will be regenerated when needed
214
+ #!**/[Pp]ackages/repositories.config
215
+ # NuGet v3's project.json files produces more ignorable files
216
+ *.nuget.props
217
+ *.nuget.targets
218
+
219
+ # Microsoft Azure Build Output
220
+ csx/
221
+ *.build.csdef
222
+
223
+ # Microsoft Azure Emulator
224
+ ecf/
225
+ rcf/
226
+
227
+ # Windows Store app package directories and files
228
+ AppPackages/
229
+ BundleArtifacts/
230
+ Package.StoreAssociation.xml
231
+ _pkginfo.txt
232
+ *.appx
233
+ *.appxbundle
234
+ *.appxupload
235
+
236
+ # Visual Studio cache files
237
+ # files ending in .cache can be ignored
238
+ *.[Cc]ache
239
+ # but keep track of directories ending in .cache
240
+ !?*.[Cc]ache/
241
+
242
+ # Others
243
+ ClientBin/
244
+ ~$*
245
+ *~
246
+ *.dbmdl
247
+ *.dbproj.schemaview
248
+ *.jfm
249
+ *.pfx
250
+ *.publishsettings
251
+ orleans.codegen.cs
252
+
253
+ # Including strong name files can present a security risk
254
+ # (https://github.com/github/gitignore/pull/2483#issue-259490424)
255
+ #*.snk
256
+
257
+ # Since there are multiple workflows, uncomment next line to ignore bower_components
258
+ # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
259
+ #bower_components/
260
+
261
+ # RIA/Silverlight projects
262
+ Generated_Code/
263
+
264
+ # Backup & report files from converting an old project file
265
+ # to a newer Visual Studio version. Backup files are not needed,
266
+ # because we have git ;-)
267
+ _UpgradeReport_Files/
268
+ Backup*/
269
+ UpgradeLog*.XML
270
+ UpgradeLog*.htm
271
+ ServiceFabricBackup/
272
+ *.rptproj.bak
273
+
274
+ # SQL Server files
275
+ *.mdf
276
+ *.ldf
277
+ *.ndf
278
+
279
+ # Business Intelligence projects
280
+ *.rdl.data
281
+ *.bim.layout
282
+ *.bim_*.settings
283
+ *.rptproj.rsuser
284
+ *- [Bb]ackup.rdl
285
+ *- [Bb]ackup ([0-9]).rdl
286
+ *- [Bb]ackup ([0-9][0-9]).rdl
287
+
288
+ # Microsoft Fakes
289
+ FakesAssemblies/
290
+
291
+ # GhostDoc plugin setting file
292
+ *.GhostDoc.xml
293
+
294
+ # Node.js Tools for Visual Studio
295
+ .ntvs_analysis.dat
296
+ node_modules/
297
+
298
+ # Visual Studio 6 build log
299
+ *.plg
300
+
301
+ # Visual Studio 6 workspace options file
302
+ *.opt
303
+
304
+ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
305
+ *.vbw
306
+
307
+ # Visual Studio 6 auto-generated project file (contains which files were open etc.)
308
+ *.vbp
309
+
310
+ # Visual Studio 6 workspace and project file (working project files containing files to include in project)
311
+ *.dsw
312
+ *.dsp
313
+
314
+ # Visual Studio 6 technical files
315
+ *.ncb
316
+ *.aps
317
+
318
+ # Visual Studio LightSwitch build output
319
+ **/*.HTMLClient/GeneratedArtifacts
320
+ **/*.DesktopClient/GeneratedArtifacts
321
+ **/*.DesktopClient/ModelManifest.xml
322
+ **/*.Server/GeneratedArtifacts
323
+ **/*.Server/ModelManifest.xml
324
+ _Pvt_Extensions
325
+
326
+ # Paket dependency manager
327
+ .paket/paket.exe
328
+ paket-files/
329
+
330
+ # FAKE - F# Make
331
+ .fake/
332
+
333
+ # CodeRush personal settings
334
+ .cr/personal
335
+
336
+ # Python Tools for Visual Studio (PTVS)
337
+ __pycache__/
338
+ *.pyc
339
+
340
+ # Cake - Uncomment if you are using it
341
+ # tools/**
342
+ # !tools/packages.config
343
+
344
+ # Tabs Studio
345
+ *.tss
346
+
347
+ # Telerik's JustMock configuration file
348
+ *.jmconfig
349
+
350
+ # BizTalk build output
351
+ *.btp.cs
352
+ *.btm.cs
353
+ *.odx.cs
354
+ *.xsd.cs
355
+
356
+ # OpenCover UI analysis results
357
+ OpenCover/
358
+
359
+ # Azure Stream Analytics local run output
360
+ ASALocalRun/
361
+
362
+ # MSBuild Binary and Structured Log
363
+ *.binlog
364
+
365
+ # NVidia Nsight GPU debugger configuration file
366
+ *.nvuser
367
+
368
+ # MFractors (Xamarin productivity tool) working folder
369
+ .mfractor/
370
+
371
+ # Local History for Visual Studio
372
+ .localhistory/
373
+
374
+ # Visual Studio History (VSHistory) files
375
+ .vshistory/
376
+
377
+ # BeatPulse healthcheck temp database
378
+ healthchecksdb
379
+
380
+ # Backup folder for Package Reference Convert tool in Visual Studio 2017
381
+ MigrationBackup/
382
+
383
+ # Ionide (cross platform F# VS Code tools) working folder
384
+ .ionide/
385
+
386
+ # Fody - auto-generated XML schema
387
+ FodyWeavers.xsd
388
+
389
+ # VS Code files for those working on multiple tools
390
+ .vscode/*
391
+ !.vscode/settings.json
392
+ !.vscode/tasks.json
393
+ !.vscode/launch.json
394
+ !.vscode/extensions.json
395
+ *.code-workspace
396
+
397
+ # Local History for Visual Studio Code
398
+ .history/
399
+
400
+ # Windows Installer files from build outputs
401
+ *.cab
402
+ *.msi
403
+ *.msix
404
+ *.msm
405
+ *.msp
406
+
407
+ # JetBrains Rider
408
+ *.sln.iml
409
+ .idea/
410
+
411
+ ##
412
+ ## Visual studio for Mac
413
+ ##
414
+
415
+
416
+ # globs
417
+ Makefile.in
418
+ *.userprefs
419
+ *.usertasks
420
+ config.make
421
+ config.status
422
+ aclocal.m4
423
+ install-sh
424
+ autom4te.cache/
425
+ *.tar.gz
426
+ tarballs/
427
+ test-results/
428
+
429
+ # content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
430
+ # General
431
+ .DS_Store
432
+ .AppleDouble
433
+ .LSOverride
434
+
435
+ # Icon must end with two \r
436
+ Icon
437
+
438
+
439
+ # Thumbnails
440
+ ._*
441
+
442
+ # Files that might appear in the root of a volume
443
+ .DocumentRevisions-V100
444
+ .fseventsd
445
+ .Spotlight-V100
446
+ .TemporaryItems
447
+ .Trashes
448
+ .VolumeIcon.icns
449
+ .com.apple.timemachine.donotpresent
450
+
451
+ # Directories potentially created on remote AFP share
452
+ .AppleDB
453
+ .AppleDesktop
454
+ Network Trash Folder
455
+ Temporary Items
456
+ .apdisk
457
+
458
+ # content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
459
+ # Windows thumbnail cache files
460
+ Thumbs.db
461
+ ehthumbs.db
462
+ ehthumbs_vista.db
463
+
464
+ # Dump file
465
+ *.stackdump
466
+
467
+ # Folder config file
468
+ [Dd]esktop.ini
469
+
470
+ # Recycle Bin used on file shares
471
+ $RECYCLE.BIN/
472
+
473
+ # Windows Installer files
474
+ *.cab
475
+ *.msi
476
+ *.msix
477
+ *.msm
478
+ *.msp
479
+
480
+ # Windows shortcuts
481
+ *.lnk
482
+
483
+ # Vim temporary swap files
484
+ *.swp
485
+
486
+ # Dev stack volumes
487
+ .data/
488
+ dist/
489
+
490
+ # Python
491
+ .venv/
492
+ .pytest_cache/
493
+ .ruff_cache/
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: mjczone-realtime-contract
3
+ Version: 0.1.0
4
+ Summary: Channel grammar, envelope and tenant guard for the realtime backbone
5
+ Project-URL: Repository, https://github.com/mjczone/realtime-backbone
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "mjczone-realtime-contract"
3
+ version = "0.1.0"
4
+ description = "Channel grammar, envelope and tenant guard for the realtime backbone"
5
+ license = "MIT"
6
+ requires-python = ">=3.11"
7
+ dependencies = []
8
+
9
+ [project.urls]
10
+ Repository = "https://github.com/mjczone/realtime-backbone"
11
+
12
+ [build-system]
13
+ requires = ["hatchling>=1.27"]
14
+ build-backend = "hatchling.build"
@@ -0,0 +1,55 @@
1
+ from .channel import (
2
+ MAX_CHANNEL_LENGTH,
3
+ RESERVED_TOPICS,
4
+ Channels,
5
+ InvalidChannelError,
6
+ ParsedChannel,
7
+ is_valid_channel,
8
+ is_valid_tenant,
9
+ is_valid_user_id,
10
+ parse_channel,
11
+ try_parse_channel,
12
+ )
13
+ from .envelope import (
14
+ MAX_ENVELOPE_BYTES,
15
+ RESERVED_FAMILIES,
16
+ InvalidEnvelopeError,
17
+ create_envelope,
18
+ envelope_bytes,
19
+ envelope_json,
20
+ is_envelope,
21
+ validate_envelope,
22
+ )
23
+ from .guard import GuardResult, TenantContext, TenantGuardError, assert_tenant, check_tenant
24
+ from .ids import MAX_ENCODED_ID_LENGTH, decode_id, encode_id
25
+ from .ulid import new_id
26
+
27
+ __all__ = [
28
+ "MAX_CHANNEL_LENGTH",
29
+ "MAX_ENCODED_ID_LENGTH",
30
+ "MAX_ENVELOPE_BYTES",
31
+ "RESERVED_FAMILIES",
32
+ "RESERVED_TOPICS",
33
+ "Channels",
34
+ "GuardResult",
35
+ "InvalidChannelError",
36
+ "InvalidEnvelopeError",
37
+ "ParsedChannel",
38
+ "TenantContext",
39
+ "TenantGuardError",
40
+ "assert_tenant",
41
+ "check_tenant",
42
+ "create_envelope",
43
+ "decode_id",
44
+ "encode_id",
45
+ "envelope_bytes",
46
+ "envelope_json",
47
+ "is_envelope",
48
+ "is_valid_channel",
49
+ "is_valid_tenant",
50
+ "is_valid_user_id",
51
+ "new_id",
52
+ "parse_channel",
53
+ "try_parse_channel",
54
+ "validate_envelope",
55
+ ]
@@ -0,0 +1,138 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ MAX_CHANNEL_LENGTH = 255
5
+ TIERS = ("live", "replay", "presence")
6
+ RESERVED_TOPICS = ("all", "grp", "user", "run")
7
+
8
+ _SEGMENT = re.compile(r"[A-Za-z0-9_-]+")
9
+ _TENANT = re.compile(r"[A-Za-z0-9_-]{1,64}")
10
+ _USER_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ParsedChannel:
15
+ tier: str
16
+ tenant: str
17
+ """The tenant ID, or the literal "global" for global channels."""
18
+ topic: str
19
+ id: str | None = None
20
+ user_id: str | None = None
21
+
22
+ @property
23
+ def is_global(self) -> bool:
24
+ return self.tenant == "global"
25
+
26
+
27
+ class InvalidChannelError(ValueError):
28
+ pass
29
+
30
+
31
+ def is_valid_tenant(tenant: str) -> bool:
32
+ return bool(_TENANT.fullmatch(tenant)) and tenant != "global"
33
+
34
+
35
+ def is_valid_user_id(user_id: str) -> bool:
36
+ return bool(_USER_ID.fullmatch(user_id))
37
+
38
+
39
+ def _segments(text: str) -> list[str] | None:
40
+ parts = text.split(".")
41
+ if len(parts) > 2 or not all(_SEGMENT.fullmatch(p) for p in parts):
42
+ return None
43
+ return parts
44
+
45
+
46
+ def try_parse_channel(name: str) -> ParsedChannel | None:
47
+ if not isinstance(name, str) or len(name) > MAX_CHANNEL_LENGTH:
48
+ return None
49
+ tier, colon, rest = name.partition(":")
50
+ if not colon or tier not in TIERS:
51
+ return None
52
+
53
+ if rest.startswith("global."):
54
+ if tier == "presence":
55
+ return None
56
+ parts = _segments(rest[len("global.") :])
57
+ if parts is None or parts[0] in RESERVED_TOPICS:
58
+ return None
59
+ return ParsedChannel(tier, "global", parts[0], parts[1] if len(parts) == 2 else None)
60
+
61
+ if not rest.startswith("t-"):
62
+ return None
63
+ tenant, dot, tail = rest[2:].partition(".")
64
+ if not dot or not is_valid_tenant(tenant):
65
+ return None
66
+
67
+ if tail.startswith("user#"):
68
+ user_id = tail[len("user#") :]
69
+ if tier == "presence" or not is_valid_user_id(user_id):
70
+ return None
71
+ return ParsedChannel(tier, tenant, "user", user_id=user_id)
72
+
73
+ parts = _segments(tail)
74
+ if parts is None:
75
+ return None
76
+ topic = parts[0]
77
+ id_ = parts[1] if len(parts) == 2 else None
78
+ if topic == "all" and (id_ is not None or tier == "presence"):
79
+ return None
80
+ if topic in ("grp", "run") and id_ is None:
81
+ return None
82
+ if topic == "user":
83
+ return None
84
+ return ParsedChannel(tier, tenant, topic, id_)
85
+
86
+
87
+ def parse_channel(name: str) -> ParsedChannel:
88
+ c = try_parse_channel(name)
89
+ if c is None:
90
+ raise InvalidChannelError(f"Invalid channel name: {name!r}")
91
+ return c
92
+
93
+
94
+ def is_valid_channel(name: str) -> bool:
95
+ return try_parse_channel(name) is not None
96
+
97
+
98
+ def _build(name: str, tenant: str, topic: str, id_: str | None = None, user_id: str | None = None) -> str:
99
+ c = parse_channel(name)
100
+ if (c.tenant, c.topic, c.id, c.user_id) != (tenant, topic, id_, user_id):
101
+ raise InvalidChannelError(f"Invalid channel parts for {name!r}")
102
+ return name
103
+
104
+
105
+ def _suffix(id_: str | None) -> str:
106
+ return "" if id_ is None else f".{id_}"
107
+
108
+
109
+ class Channels:
110
+ """Builders per audience. They raise on any input that would not parse back to the same parts."""
111
+
112
+ @staticmethod
113
+ def global_(topic: str, id: str | None = None, tier: str = "live") -> str:
114
+ return _build(f"{tier}:global.{topic}{_suffix(id)}", "global", topic, id)
115
+
116
+ @staticmethod
117
+ def tenant_all(tenant: str, tier: str = "live") -> str:
118
+ return _build(f"{tier}:t-{tenant}.all", tenant, "all")
119
+
120
+ @staticmethod
121
+ def record(tenant: str, topic: str, id: str | None = None, tier: str = "live") -> str:
122
+ return _build(f"{tier}:t-{tenant}.{topic}{_suffix(id)}", tenant, topic, id)
123
+
124
+ @staticmethod
125
+ def group(tenant: str, group: str, tier: str = "live") -> str:
126
+ return _build(f"{tier}:t-{tenant}.grp.{group}", tenant, "grp", group)
127
+
128
+ @staticmethod
129
+ def user(tenant: str, user_id: str, tier: str = "live") -> str:
130
+ return _build(f"{tier}:t-{tenant}.user#{user_id}", tenant, "user", user_id=user_id)
131
+
132
+ @staticmethod
133
+ def run(tenant: str, run_id: str) -> str:
134
+ return _build(f"replay:t-{tenant}.run.{run_id}", tenant, "run", run_id)
135
+
136
+ @staticmethod
137
+ def presence(tenant: str, topic: str, id: str | None = None) -> str:
138
+ return _build(f"presence:t-{tenant}.{topic}{_suffix(id)}", tenant, topic, id)
@@ -0,0 +1,78 @@
1
+ import json
2
+ import re
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from .ulid import new_id
7
+
8
+ MAX_ENVELOPE_BYTES = 65536
9
+ RESERVED_FAMILIES = ("agui", "job", "entity", "notice", "system")
10
+
11
+ _ULID = re.compile(r"[0-9A-HJKMNP-TV-Z]{26}")
12
+ _TYPE = re.compile(r"[a-z][a-z0-9]*\.[A-Za-z0-9_.]+")
13
+ _TENANT = re.compile(r"[A-Za-z0-9_-]{1,64}")
14
+ _SOURCE = re.compile(r"[A-Za-z0-9_.-]{1,128}")
15
+ _TS = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z")
16
+
17
+
18
+ class InvalidEnvelopeError(ValueError):
19
+ pass
20
+
21
+
22
+ def _now() -> str:
23
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
24
+
25
+
26
+ def create_envelope(
27
+ type: str, tenant: str, source: str, data: dict[str, Any], corr: str | None = None
28
+ ) -> dict[str, Any]:
29
+ """Builds an envelope dict, filling in v, id and ts."""
30
+ env: dict[str, Any] = {"v": 1, "id": new_id(), "type": type, "tenant": tenant, "source": source, "ts": _now()}
31
+ if corr is not None:
32
+ env["corr"] = corr
33
+ env["data"] = data
34
+ problem = validate_envelope(env)
35
+ if problem:
36
+ raise InvalidEnvelopeError(f"Invalid envelope: {problem}")
37
+ return env
38
+
39
+
40
+ def _matches(value: Any, pattern: re.Pattern[str]) -> bool:
41
+ return isinstance(value, str) and bool(pattern.fullmatch(value))
42
+
43
+
44
+ def validate_envelope(x: Any) -> str | None:
45
+ """Returns why the value is not a valid envelope, or None when it is. Unknown fields are ignored."""
46
+ if not isinstance(x, dict):
47
+ return "not an object"
48
+ v = x.get("v")
49
+ if v != 1 or isinstance(v, bool) or not isinstance(v, (int, float)):
50
+ return "v must be 1"
51
+ if not _matches(x.get("id"), _ULID):
52
+ return "id must be a ULID"
53
+ if not _matches(x.get("type"), _TYPE) or len(x["type"]) > 128:
54
+ return "type must be <family>.<name>"
55
+ if not _matches(x.get("tenant"), _TENANT):
56
+ return "tenant is invalid"
57
+ if not _matches(x.get("source"), _SOURCE):
58
+ return "source is invalid"
59
+ if not _matches(x.get("ts"), _TS):
60
+ return "ts must be RFC 3339 UTC"
61
+ if "corr" in x and not (isinstance(x["corr"], str) and 1 <= len(x["corr"]) <= 256):
62
+ return "corr must be a string of 1 to 256 characters"
63
+ if not isinstance(x.get("data"), dict):
64
+ return "data must be an object"
65
+ return None
66
+
67
+
68
+ def is_envelope(x: Any) -> bool:
69
+ return validate_envelope(x) is None
70
+
71
+
72
+ def envelope_json(env: dict[str, Any]) -> bytes:
73
+ """The envelope as compact UTF-8 JSON, the bytes that are published and size-checked."""
74
+ return json.dumps(env, separators=(",", ":"), ensure_ascii=False).encode()
75
+
76
+
77
+ def envelope_bytes(env: dict[str, Any]) -> int:
78
+ return len(envelope_json(env))
@@ -0,0 +1,51 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .channel import try_parse_channel
4
+
5
+ INVALID_CHANNEL = "invalid_channel"
6
+ GLOBAL_NOT_ALLOWED = "global_not_allowed"
7
+ NO_TENANT_CONTEXT = "no_tenant_context"
8
+ TENANT_MISMATCH = "tenant_mismatch"
9
+ ENVELOPE_TENANT_MISMATCH = "envelope_tenant_mismatch"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TenantContext:
14
+ tenant: str | None = None
15
+ allow_global: bool = False
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class GuardResult:
20
+ allow: bool
21
+ reason: str | None = None
22
+
23
+
24
+ class TenantGuardError(Exception):
25
+ def __init__(self, reason: str, channel: str):
26
+ super().__init__(f"Tenant guard denied publish to {channel}: {reason}")
27
+ self.reason = reason
28
+ self.channel = channel
29
+
30
+
31
+ def check_tenant(context: TenantContext, channel: str, envelope_tenant: str) -> GuardResult:
32
+ c = try_parse_channel(channel)
33
+ if c is None:
34
+ return GuardResult(False, INVALID_CHANNEL)
35
+ if c.is_global:
36
+ if not context.allow_global:
37
+ return GuardResult(False, GLOBAL_NOT_ALLOWED)
38
+ else:
39
+ if not context.tenant:
40
+ return GuardResult(False, NO_TENANT_CONTEXT)
41
+ if context.tenant != c.tenant:
42
+ return GuardResult(False, TENANT_MISMATCH)
43
+ if envelope_tenant != c.tenant:
44
+ return GuardResult(False, ENVELOPE_TENANT_MISMATCH)
45
+ return GuardResult(True)
46
+
47
+
48
+ def assert_tenant(context: TenantContext, channel: str, envelope_tenant: str) -> None:
49
+ r = check_tenant(context, channel, envelope_tenant)
50
+ if not r.allow:
51
+ raise TenantGuardError(r.reason or "", channel)
@@ -0,0 +1,50 @@
1
+ """Encodes any key as a channel ID, and back. The same rule in every SDK; see spec/channels.md."""
2
+
3
+ import re
4
+
5
+ MAX_ENCODED_ID_LENGTH = 128
6
+
7
+ _PLAIN = frozenset(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-")
8
+ _HEX = re.compile(r"[0-9A-F]{2}")
9
+
10
+
11
+ def encode_id(key: str) -> str:
12
+ """A-Z, a-z, 0-9 and - pass through; every other UTF-8 byte becomes _XX."""
13
+ if not isinstance(key, str) or key == "":
14
+ raise ValueError("encode_id: key must be a non-empty string")
15
+ try:
16
+ data = key.encode()
17
+ except UnicodeEncodeError:
18
+ raise ValueError("encode_id: key is not valid Unicode text") from None
19
+ out = "".join(chr(b) if b in _PLAIN else f"_{b:02X}" for b in data)
20
+ if len(out) > MAX_ENCODED_ID_LENGTH:
21
+ raise ValueError(f"encode_id: encoded ID is {len(out)} characters, over {MAX_ENCODED_ID_LENGTH}")
22
+ return out
23
+
24
+
25
+ def decode_id(id: str) -> str:
26
+ """The reverse of encode_id. Raises ValueError on anything encode_id would not produce."""
27
+ if not isinstance(id, str) or id == "" or len(id) > MAX_ENCODED_ID_LENGTH:
28
+ raise ValueError("decode_id: not an encoded ID")
29
+ data = bytearray()
30
+ i = 0
31
+ while i < len(id):
32
+ c = id[i]
33
+ if c == "_":
34
+ hex_ = id[i + 1 : i + 3]
35
+ if not _HEX.fullmatch(hex_):
36
+ raise ValueError(f"decode_id: bad escape at {i}")
37
+ b = int(hex_, 16)
38
+ if b in _PLAIN:
39
+ raise ValueError(f"decode_id: escaped plain character at {i}")
40
+ data.append(b)
41
+ i += 3
42
+ elif c.isascii() and ord(c) in _PLAIN:
43
+ data.append(ord(c))
44
+ i += 1
45
+ else:
46
+ raise ValueError(f"decode_id: unexpected character at {i}")
47
+ try:
48
+ return data.decode("utf-8")
49
+ except UnicodeDecodeError:
50
+ raise ValueError("decode_id: not valid UTF-8") from None
@@ -0,0 +1,29 @@
1
+ import os
2
+ import threading
3
+ import time
4
+
5
+ _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
6
+ _lock = threading.Lock()
7
+ _last_time = -1
8
+ _last_random = 0
9
+
10
+
11
+ def new_id() -> str:
12
+ """A monotonic ULID: 48-bit millisecond time plus 80 random bits, Crockford base32."""
13
+ global _last_time, _last_random
14
+ with _lock:
15
+ now = time.time_ns() // 1_000_000
16
+ if now <= _last_time:
17
+ _last_random += 1
18
+ if _last_random >= 1 << 80:
19
+ raise OverflowError("ULID random part overflowed within one millisecond")
20
+ now = _last_time
21
+ else:
22
+ _last_time = now
23
+ _last_random = int.from_bytes(os.urandom(10), "big")
24
+ value = (now << 80) | _last_random
25
+ chars = []
26
+ for _ in range(26):
27
+ chars.append(_ALPHABET[value & 31])
28
+ value >>= 5
29
+ return "".join(reversed(chars))
@@ -0,0 +1,61 @@
1
+ import re
2
+
3
+ import pytest
4
+ from mjczone_realtime_contract import (
5
+ MAX_ENVELOPE_BYTES,
6
+ Channels,
7
+ InvalidChannelError,
8
+ InvalidEnvelopeError,
9
+ TenantContext,
10
+ TenantGuardError,
11
+ assert_tenant,
12
+ create_envelope,
13
+ envelope_bytes,
14
+ new_id,
15
+ )
16
+
17
+
18
+ def test_builders():
19
+ assert Channels.global_("maintenance") == "live:global.maintenance"
20
+ assert Channels.tenant_all("acme") == "live:t-acme.all"
21
+ assert Channels.record("acme", "jobs", "01JB7Q") == "live:t-acme.jobs.01JB7Q"
22
+ assert Channels.group("acme", "ops") == "live:t-acme.grp.ops"
23
+ assert Channels.user("acme", "u1", "replay") == "replay:t-acme.user#u1"
24
+ assert Channels.run("acme", "r1") == "replay:t-acme.run.r1"
25
+ assert Channels.presence("acme", "doc", "881") == "presence:t-acme.doc.881"
26
+
27
+
28
+ @pytest.mark.parametrize(
29
+ "tenant,topic,id_",
30
+ [("ac.me", "jobs", None), ("acme", "jobs.1", None), ("acme", "all", "1"), ("global", "x", None)],
31
+ )
32
+ def test_builders_reject_bad_parts(tenant, topic, id_):
33
+ with pytest.raises(InvalidChannelError):
34
+ Channels.record(tenant, topic, id_)
35
+
36
+
37
+ def test_create_envelope():
38
+ e = create_envelope("job.progress", "acme", "w1", {"pct": 1}, corr="c1")
39
+ assert e["v"] == 1 and e["corr"] == "c1" and e["ts"].endswith("Z")
40
+ assert re.fullmatch(r"[0-9A-HJKMNP-TV-Z]{26}", e["id"])
41
+ assert "corr" not in create_envelope("a.b", "t", "s", {})
42
+ with pytest.raises(InvalidEnvelopeError):
43
+ create_envelope("nofamily", "acme", "w", {})
44
+ with pytest.raises(InvalidEnvelopeError):
45
+ create_envelope("a.b", "acme", "w", [1]) # type: ignore[arg-type]
46
+
47
+
48
+ def test_ulids_are_monotonic():
49
+ ids = [new_id() for _ in range(5000)]
50
+ assert ids == sorted(ids) and len(set(ids)) == len(ids)
51
+
52
+
53
+ def test_envelope_bytes_counts_utf8():
54
+ e = create_envelope("a.b", "t", "s", {"s": "é" * (MAX_ENVELOPE_BYTES // 2)})
55
+ assert envelope_bytes(e) > MAX_ENVELOPE_BYTES
56
+
57
+
58
+ def test_assert_tenant():
59
+ with pytest.raises(TenantGuardError) as e:
60
+ assert_tenant(TenantContext("acme"), "live:t-globex.x", "globex")
61
+ assert e.value.reason == "tenant_mismatch"
@@ -0,0 +1,97 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+ from mjczone_realtime_contract import (
6
+ TenantContext,
7
+ check_tenant,
8
+ decode_id,
9
+ encode_id,
10
+ is_valid_channel,
11
+ try_parse_channel,
12
+ validate_envelope,
13
+ )
14
+
15
+ VECTORS = Path(__file__).parents[3] / "spec" / "vectors"
16
+
17
+
18
+ def load(name: str) -> list[dict]:
19
+ return json.loads((VECTORS / name).read_text())
20
+
21
+
22
+ def ids(vectors: list[dict], key: str) -> list[str]:
23
+ return [str(v.get(key, ""))[:60] for v in vectors]
24
+
25
+
26
+ VALID = load("channels.valid.json")
27
+ INVALID = load("channels.invalid.json")
28
+ ENV_VALID = load("envelopes.valid.json")
29
+ ENV_INVALID = load("envelopes.invalid.json")
30
+ GUARD = load("guard.json")
31
+
32
+
33
+ @pytest.mark.parametrize("v", VALID, ids=ids(VALID, "name"))
34
+ def test_valid_channels(v):
35
+ c = try_parse_channel(v["name"])
36
+ assert c is not None
37
+ p = v["parsed"]
38
+ assert (c.tier, c.tenant, c.topic, c.id, c.user_id) == (
39
+ p["tier"],
40
+ p["tenant"],
41
+ p["topic"],
42
+ p.get("id"),
43
+ p.get("userId"),
44
+ )
45
+
46
+
47
+ @pytest.mark.parametrize("v", INVALID, ids=ids(INVALID, "why"))
48
+ def test_invalid_channels(v):
49
+ assert try_parse_channel(v["name"]) is None
50
+
51
+
52
+ @pytest.mark.parametrize("v", ENV_VALID, ids=ids(ENV_VALID, "why"))
53
+ def test_valid_envelopes(v):
54
+ assert validate_envelope(v["envelope"]) is None
55
+
56
+
57
+ @pytest.mark.parametrize("v", ENV_INVALID, ids=ids(ENV_INVALID, "why"))
58
+ def test_invalid_envelopes(v):
59
+ assert validate_envelope(v["envelope"]) is not None
60
+
61
+
62
+ @pytest.mark.parametrize("v", GUARD, ids=ids(GUARD, "why"))
63
+ def test_guard(v):
64
+ ctx = TenantContext(v["context"].get("tenant"), v["context"].get("allowGlobal", False))
65
+ r = check_tenant(ctx, v["channel"], v["envelopeTenant"])
66
+ assert (r.allow, r.reason) == (v["allow"], v.get("reason"))
67
+
68
+
69
+ IDS = json.loads((VECTORS / "ids.json").read_text())
70
+
71
+
72
+ @pytest.mark.parametrize("v", IDS["roundtrip"], ids=[v["why"] for v in IDS["roundtrip"]])
73
+ def test_id_round_trip(v):
74
+ assert encode_id(v["key"]) == v["id"]
75
+ assert decode_id(v["id"]) == v["key"]
76
+
77
+
78
+ @pytest.mark.parametrize("v", IDS["encodeErrors"], ids=[v["why"] for v in IDS["encodeErrors"]])
79
+ def test_encode_id_rejects(v):
80
+ with pytest.raises(ValueError):
81
+ encode_id(v["key"])
82
+
83
+
84
+ @pytest.mark.parametrize("v", IDS["decodeErrors"], ids=[v["why"] for v in IDS["decodeErrors"]])
85
+ def test_decode_id_rejects(v):
86
+ with pytest.raises(ValueError):
87
+ decode_id(v["id"])
88
+
89
+
90
+ def test_encode_id_rejects_a_lone_surrogate():
91
+ with pytest.raises(ValueError):
92
+ encode_id("a\ud800")
93
+
94
+
95
+ def test_an_encoded_id_fits_a_channel():
96
+ assert is_valid_channel(f"live:t-acme.quotes.{encode_id('BRK.B')}")
97
+ assert is_valid_channel(f"live:t-acme.user#{encode_id('auth0|42')}")