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.
- mjczone_realtime_contract-0.1.0/.gitignore +493 -0
- mjczone_realtime_contract-0.1.0/PKG-INFO +7 -0
- mjczone_realtime_contract-0.1.0/pyproject.toml +14 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/__init__.py +55 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/channel.py +138 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/envelope.py +78 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/guard.py +51 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/ids.py +50 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/py.typed +0 -0
- mjczone_realtime_contract-0.1.0/src/mjczone_realtime_contract/ulid.py +29 -0
- mjczone_realtime_contract-0.1.0/tests/test_contract.py +61 -0
- mjczone_realtime_contract-0.1.0/tests/test_vectors.py +97 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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')}")
|