pyspecification 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,788 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyspecification
3
+ Version: 0.1.0
4
+ Summary: A lightweight, typed Python library for composing business rules as reusable, executable predicates.
5
+ Author: aaltatan
6
+ Author-email: aaltatan <a.altatan@gmail.com>
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Dist: pydantic>=2.13.4
11
+ Requires-Python: >=3.12
12
+ Project-URL: Homepage, https://github.com/aaltatan/pyspecification
13
+ Project-URL: Issues, https://github.com/aaltatan/pyspecification/issues
14
+ Description-Content-Type: text/markdown
15
+
16
+ # pyspecification
17
+
18
+ A lightweight, typed Python library for composing business rules as reusable, executable predicates.
19
+
20
+ This project was inspired by the work and ideas shared by [ArjanCodes](https://github.com/arjancodes), especially the concepts demonstrated in his video: ["The Most Overengineered Python Pattern I've Ever Built"](https://youtu.be/KqfMiuL3cx4?si=WAn01N2I0OO3KOgc).
21
+
22
+ `pyspecification` focuses on a functional style:
23
+
24
+ - rules are first-class callables
25
+ - predicates compose with `&`, `|`, and `~`
26
+ - registry-based registration keeps rules organized
27
+ - structured rule definitions can be compiled from dictionaries or JSON-like payloads
28
+ - generated schemas make rule metadata portable and machine-readable
29
+
30
+ It is especially useful for filtering, validation, authorization checks, and declarative rule engines without introducing a heavy framework.
31
+
32
+ ---
33
+
34
+ ## Why use pyspecification?
35
+
36
+ This package helps you turn complex condition logic into small, readable, testable rule fragments.
37
+
38
+ Instead of writing nested `if` logic like this:
39
+
40
+ ```python
41
+ if user.is_admin or (user.name.lower().startswith("admin") and 18 <= user.age <= 30):
42
+ allow_access = True
43
+ else:
44
+ allow_access = False
45
+ ```
46
+
47
+ you can define rules as composable predicates:
48
+
49
+ ```python
50
+ rule = is_admin() | (name__istartswith("admin") & age__between(18, 30))
51
+ ```
52
+
53
+ This keeps your logic:
54
+
55
+ - declarative
56
+ - reusable
57
+ - easy to combine
58
+ - friendly to validation and filtering pipelines
59
+ - easy to inspect and serialize
60
+
61
+ ---
62
+
63
+ ## Features
64
+
65
+ - Object-based predicate rules for dataclasses and domain models
66
+ - Subscriptable rules for dictionaries, lists, and generic lookup-based data
67
+ - `Predicate` objects that support logical composition
68
+ - Registry pattern for rule registration and lookup
69
+ - Custom argument processors for coercion and normalization
70
+ - `PredicateCompiler` for compiling structured rule dictionaries into executable predicates
71
+ - `RuleSchema` validation for declarative rule payloads
72
+ - JSON schema generation for rule arguments and return types
73
+ - Support for both logical and bitwise operator modes
74
+ - Hidden rules and custom naming for internal/private rule registration
75
+
76
+ ---
77
+
78
+ ## Installation
79
+
80
+ ```bash
81
+ pip install pyspecification
82
+ ```
83
+
84
+ ```bash
85
+ uv add pyspecification
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Core concepts
91
+
92
+ ### 1. Predicate
93
+
94
+ A `Predicate[T, R]` wraps a function `fn: T -> R` and adds composition behavior.
95
+
96
+ ```python
97
+ from pyspecification import Predicate
98
+
99
+
100
+ def is_adult(user: object) -> bool:
101
+ return user.age >= 18
102
+
103
+
104
+ def is_admin_predicate(user: object) -> bool:
105
+ return user.is_admin
106
+
107
+
108
+ adult_predicate: Predicate[User, bool] = Predicate(is_adult, operator="logical")
109
+ is_admin_predicate: Predicate[User, bool] = Predicate(is_admin_predicate, operator="logical")
110
+ ```
111
+
112
+ You can combine predicates using:
113
+
114
+ ```python
115
+ rule = adult_predicate & is_admin_predicate
116
+ rule = adult_predicate | is_admin_predicate
117
+ rule = ~adult_predicate
118
+ ```
119
+
120
+ `operator` can be either:
121
+
122
+ - `"logical"` for `and` / `or` / `not`
123
+ - `"bitwise"` for `&` / `|` / `~`
124
+
125
+ When combining predicates, both sides must use the same operator mode.
126
+
127
+ ---
128
+
129
+ ### 2. Object rules
130
+
131
+ Use `@object_rule` to create reusable predicates from object-based functions.
132
+
133
+ ```python
134
+ from dataclasses import dataclass
135
+
136
+ from pyspecification import object_rule
137
+
138
+
139
+ @dataclass
140
+ class User:
141
+ name: str
142
+ age: int
143
+ is_admin: bool = False
144
+
145
+
146
+ @object_rule()
147
+ def name__istartswith(user: User, value: str) -> bool:
148
+ return user.name.lower().startswith(value.lower())
149
+
150
+
151
+ @object_rule()
152
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
153
+ return user.age >= min_age and user.age <= max_age
154
+
155
+
156
+ @object_rule()
157
+ def is_admin(user: User) -> bool:
158
+ return user.is_admin
159
+
160
+
161
+ rule = is_admin() | (name__istartswith("admin") & age__between(18, 30))
162
+ ```
163
+
164
+ This yields a predicate that can be evaluated against a model instance:
165
+
166
+ ```python
167
+ user = User(name="Abdullah", age=25, is_admin=True)
168
+ print(rule(user)) # True
169
+ ```
170
+
171
+ The rule function itself is a factory that returns a `Predicate`.
172
+
173
+ ---
174
+
175
+ ### 3. Subscriptable rules
176
+
177
+ Use `@subscriptable_rule` for dictionary or list-like lookup data.
178
+
179
+ ```python
180
+ from typing import Any
181
+
182
+ from pyspecification import subscriptable_rule
183
+
184
+
185
+ @subscriptable_rule()
186
+ def string__ieq(obj: dict[str, Any], key: str, value: str) -> bool:
187
+ return obj[key].lower() == value.lower()
188
+
189
+
190
+ @subscriptable_rule()
191
+ def number__le(obj: dict[str, Any], key: str, value: int) -> bool:
192
+ return obj[key] <= value
193
+
194
+
195
+ rule = string__ieq("gender", "male") & number__le("rank", 10)
196
+
197
+ person = {"gender": "Male", "rank": 9}
198
+ print(rule(person)) # True
199
+ ```
200
+
201
+ This pattern is ideal for filtering dictionaries and JSON-like records.
202
+
203
+ ---
204
+
205
+ ### 4. SQLAlchemy integration example
206
+
207
+ One of the strongest real-world use cases is turning rule definitions into SQLAlchemy filter expressions for database queries.
208
+
209
+ ```python
210
+ from typing import Any
211
+
212
+ from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler, RuleSchema
213
+ from sqlalchemy import ColumnElement, and_, create_engine, or_
214
+ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
215
+
216
+
217
+ class Base(DeclarativeBase):
218
+ pass
219
+
220
+
221
+ class User(Base):
222
+ __tablename__ = "users"
223
+
224
+ id: Mapped[int] = mapped_column(primary_key=True)
225
+ name: Mapped[str]
226
+ age: Mapped[int]
227
+ is_admin: Mapped[bool] = mapped_column(default=False)
228
+
229
+
230
+ engine = create_engine("sqlite:///:memory:")
231
+ SessionLocal = sessionmaker(bind=engine)
232
+
233
+ # create database and seed data
234
+ Base.metadata.create_all(engine)
235
+ with SessionLocal() as session:
236
+ session.add_all(
237
+ [
238
+ User(name="Abdullah", age=18, is_admin=True),
239
+ User(name="Bob", age=16, is_admin=True),
240
+ User(name="Charlie", age=20, is_admin=False),
241
+ User(name="David", age=12, is_admin=False),
242
+ User(name="Eve", age=8, is_admin=True),
243
+ ]
244
+ )
245
+ session.commit()
246
+
247
+
248
+ rules = ObjectRulesRegistry[type[User], ColumnElement[bool]](operator="bitwise")
249
+
250
+
251
+ @rules.rule()
252
+ def is_admin(model: type[User]) -> ColumnElement[bool]:
253
+ return model.is_admin == True # noqa: E712
254
+
255
+
256
+ @rules.rule()
257
+ def name__iendswith(model: type[User], value: str) -> ColumnElement[bool]:
258
+ return model.name.iendswith(value)
259
+
260
+
261
+ @rules.rule()
262
+ def age__ge(model: type[User], value: int) -> ColumnElement[bool]:
263
+ return model.age >= value
264
+
265
+
266
+ @rules.rule()
267
+ def age__le(model: type[User], value: int) -> ColumnElement[bool]:
268
+ return model.age <= value
269
+
270
+
271
+ compiler = PredicateCompiler(
272
+ rules.rules,
273
+ lambda schema: Predicate(
274
+ lambda _: and_(True) if schema["operator"] == "and" else or_(False),
275
+ operator="bitwise",
276
+ ),
277
+ )
278
+
279
+ filter_rule_data = {
280
+ "operator": "or",
281
+ "expressions": [
282
+ {"-is_admin": []},
283
+ {"age__ge": [18]},
284
+ ],
285
+ }
286
+
287
+ predicate = compiler.compile(RuleSchema(**filter_rule_data).model_dump())
288
+
289
+ with SessionLocal() as session:
290
+ users = session.query(User).filter(predicate(User)).all()
291
+ print([user.name for user in users])
292
+ ```
293
+
294
+ This pattern is especially useful when you want:
295
+
296
+ - declarative backend filters
297
+ - admin dashboards with rule-driven queries
298
+ - object-level permission evaluation
299
+ - SQLAlchemy-friendly business logic without hard-coded SQL fragments
300
+
301
+ > The project includes a full end-to-end SQLAlchemy example in the test suite under [tests/e2e/test_sqlalchemy_filtering_system.py](tests/e2e/test_sqlalchemy_filtering_system.py).
302
+
303
+ ---
304
+
305
+ ## Custom processors
306
+
307
+ Rules can apply argument processors to coerce values before evaluation.
308
+
309
+ ```python
310
+ from datetime import datetime
311
+
312
+ from pyspecification import SubscriptableRulesRegistry
313
+
314
+
315
+ rules = SubscriptableRulesRegistry[dict[str, object], str, bool](operator="logical")
316
+
317
+
318
+ @rules.rule(processors=(lambda value: datetime.strptime(value, "%Y-%m-%d"), {}))
319
+ def datetime__gt(obj: dict[str, object], key: str, value: datetime) -> bool:
320
+ return obj[key] > value
321
+
322
+
323
+ predicate = rules["datetime__gt"]("birthdate", "2001-06-01")
324
+ print(predicate({"birthdate": datetime(2005, 1, 1)})) # True
325
+ ```
326
+
327
+ The processor tuple format is:
328
+
329
+ ```python
330
+ (default_processor, named_processors_map)
331
+ ```
332
+
333
+ For example:
334
+
335
+ ```python
336
+ processors = (
337
+ int,
338
+ {"value": str},
339
+ )
340
+ ```
341
+
342
+ This means:
343
+
344
+ - positional args are passed through `int`
345
+ - keyword args with name `"value"` are passed through `str`
346
+
347
+ If conversion fails, the library raises `ProcessArgumentError`.
348
+
349
+ ---
350
+
351
+ ## Compiling structured rule definitions
352
+
353
+ `PredicateCompiler` turns declarative rule dictionaries into executable predicates.
354
+
355
+ ```python
356
+ from dataclasses import dataclass
357
+
358
+ from pyspecification import Predicate, PredicateCompiler, object_rule
359
+
360
+
361
+ @dataclass
362
+ class User:
363
+ name: str
364
+ age: int
365
+ is_admin: bool = False
366
+
367
+
368
+ @object_rule()
369
+ def is_admin(user: User) -> bool:
370
+ return user.is_admin
371
+
372
+
373
+ @object_rule()
374
+ def name__istartswith(user: User, value: str) -> bool:
375
+ return user.name.lower().startswith(value.lower())
376
+
377
+
378
+ @object_rule()
379
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
380
+ return user.age >= min_age and user.age <= max_age
381
+
382
+
383
+ rules = {
384
+ "is_admin": is_admin,
385
+ "name__istartswith": name__istartswith,
386
+ "age__between": age__between,
387
+ }
388
+
389
+ compiler = PredicateCompiler(
390
+ rules,
391
+ lambda schema: Predicate(lambda _: schema["operator"] == "and", operator="logical"),
392
+ )
393
+
394
+ rule_data = {
395
+ "operator": "or",
396
+ "expressions": [
397
+ {"is_admin": []},
398
+ {
399
+ "expressions": [
400
+ {"name__istartswith": "admin"},
401
+ {"age__between": [18, 30]},
402
+ ]
403
+ },
404
+ ],
405
+ }
406
+
407
+ predicate = compiler.compile(rule_data)
408
+ print(predicate(User("admin", 25, True))) # True
409
+ ```
410
+
411
+ ### Supported structured syntax
412
+
413
+ Simple predicate forms:
414
+
415
+ ```python
416
+ {"is_admin": []}
417
+ {"-is_admin": []}
418
+ {"name__istartswith": "admin"}
419
+ {"age__between": [18, 30]}
420
+ {"name__startswith": {"value": "admin"}}
421
+ ```
422
+
423
+ Nested expressions:
424
+
425
+ ```python
426
+ {
427
+ "operator": "and",
428
+ "expressions": [
429
+ {"is_admin": []},
430
+ {"name__istartswith": "admin"},
431
+ ],
432
+ }
433
+ ```
434
+
435
+ And inverted wrappers:
436
+
437
+ ```python
438
+ {
439
+ "operator": "or",
440
+ "inverse": True,
441
+ "expressions": [
442
+ {"is_admin": []},
443
+ {"age__between": [18, 30]},
444
+ ],
445
+ }
446
+ ```
447
+
448
+ The compiler raises `CompilationError` if a rule name is missing or the payload is malformed.
449
+
450
+ ---
451
+
452
+ ## Rule schema validation
453
+
454
+ `RuleSchema` validates declarative rule payloads.
455
+
456
+ ```python
457
+ from pyspecification import RuleSchema
458
+
459
+ rule_data = {
460
+ "operator": "and",
461
+ "expressions": [
462
+ {"name__startswith": "admin"},
463
+ {"age__gt": 18},
464
+ ],
465
+ }
466
+
467
+ schema = RuleSchema(**rule_data)
468
+ print(schema.model_dump())
469
+ ```
470
+
471
+ This is useful when you want to validate incoming rule definitions before compile-time execution.
472
+
473
+ You can also use `RuleSchema` to represent nested predicate trees as typed, portable data.
474
+
475
+ ---
476
+
477
+ ## JSON schema generation
478
+
479
+ `get_json_schema` inspects a rule function and returns JSON-schema-like metadata for parameters and return value.
480
+
481
+ ```python
482
+ from dataclasses import dataclass
483
+
484
+ from pyspecification import get_json_schema, object_rule
485
+
486
+
487
+ @dataclass
488
+ class User:
489
+ name: str
490
+ age: int
491
+ is_admin: bool = True
492
+
493
+
494
+ @object_rule()
495
+ def name__istartswith(user: User, value: str) -> bool:
496
+ return user.name.lower().startswith(value.lower())
497
+
498
+
499
+ print(get_json_schema(name__istartswith))
500
+ # {
501
+ # "value": {"type": "string"},
502
+ # "return": {"type": "boolean"},
503
+ # }
504
+ ```
505
+
506
+ This is useful for:
507
+
508
+ - generating UIs for rule configuration
509
+ - building admin tools and dashboards
510
+ - describing rule inputs to other systems
511
+ - documenting business rules programmatically
512
+
513
+ ---
514
+
515
+ ## Use cases
516
+
517
+ ### 1. Filtering datasets
518
+
519
+ This library is excellent for building dynamic dataset filters during API requests or internal analytics queries.
520
+
521
+ ```python
522
+ from dataclasses import dataclass
523
+
524
+ from pyspecification import ObjectRulesRegistry
525
+
526
+
527
+ @dataclass
528
+ class User:
529
+ name: str
530
+ age: int
531
+ is_admin: bool
532
+
533
+
534
+ registry = ObjectRulesRegistry[User, bool](operator="logical")
535
+
536
+
537
+ @registry.rule()
538
+ def is_admin(user: User) -> bool:
539
+ return user.is_admin
540
+
541
+
542
+ @registry.rule()
543
+ def age__gte(user: User, value: int) -> bool:
544
+ return user.age >= value
545
+
546
+
547
+ users = [
548
+ User("Alice", 27, True),
549
+ User("Bob", 19, False),
550
+ User("Charlie", 31, True),
551
+ ]
552
+
553
+ predicate = registry["is_admin"]() & registry["age__gte"](20)
554
+ filtered = [user for user in users if predicate(user)]
555
+ ```
556
+
557
+ ### 2. SQLAlchemy-backed filtering and query composition
558
+
559
+ This is one of the most useful real-world patterns for the package. You can define a reusable rule set and compile it into SQLAlchemy boolean expressions for database queries.
560
+
561
+ ```python
562
+ from pyspecification import ObjectRulesRegistry, Predicate, PredicateCompiler, RuleSchema
563
+ from sqlalchemy import ColumnElement, and_, or_
564
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
565
+
566
+
567
+ class Base(DeclarativeBase):
568
+ pass
569
+
570
+
571
+ class User(Base):
572
+ __tablename__ = "users"
573
+
574
+ id: Mapped[int] = mapped_column(primary_key=True)
575
+ name: Mapped[str]
576
+ age: Mapped[int]
577
+ is_admin: Mapped[bool]
578
+
579
+
580
+ rules = ObjectRulesRegistry[type[User], ColumnElement[bool]](operator="bitwise")
581
+
582
+
583
+ @rules.rule()
584
+ def is_admin(model: type[User]) -> ColumnElement[bool]:
585
+ return model.is_admin == True # noqa: E712
586
+
587
+
588
+ @rules.rule()
589
+ def age__ge(model: type[User], value: int) -> ColumnElement[bool]:
590
+ return model.age >= value
591
+
592
+
593
+ compiler = PredicateCompiler(
594
+ rules.rules,
595
+ lambda schema: Predicate(
596
+ lambda _: and_(True) if schema["operator"] == "and" else or_(False),
597
+ operator="bitwise",
598
+ ),
599
+ )
600
+
601
+ filter_rule = {
602
+ "operator": "or",
603
+ "expressions": [
604
+ {"is_admin": []},
605
+ {"age__ge": [18]},
606
+ ],
607
+ }
608
+
609
+ query_predicate = compiler.compile(RuleSchema(**filter_rule).model_dump())
610
+ ```
611
+
612
+ This makes it easy to expose admin filters, user search rules, and role-based queries without manually stitching SQL conditions together.
613
+
614
+ ### 3. Authorization and access rules
615
+
616
+ You can model business policies as rules and compose them into policy expressions.
617
+
618
+ ```python
619
+ rule = is_admin() | (is_manager() & is_active())
620
+ ```
621
+
622
+ This allows readable authorization checks without large condition trees.
623
+
624
+ ### 3. Dynamic rule engines
625
+
626
+ The compiler and schema APIs make it easy to store or receive rules as structured data.
627
+
628
+ Examples:
629
+
630
+ - frontend sends a filter model to backend
631
+ - admin system stores JSON rules in a database
632
+ - rules are reloaded at runtime based on configuration
633
+
634
+ ### 4. Validation pipelines
635
+
636
+ Rules can be assembled from reusable predicate pieces and evaluated against model instances or dictionary records.
637
+
638
+ This is ideal for:
639
+
640
+ - data validation
641
+ - compliance checks
642
+ - workflow gating
643
+ - feature flags and user segmentation
644
+
645
+ ---
646
+
647
+ ## Recommended patterns
648
+
649
+ ### Prefer named rule functions
650
+
651
+ ```python
652
+ @object_rule()
653
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
654
+ return user.age >= min_age and user.age <= max_age
655
+ ```
656
+
657
+ This gives you readable names and predictable rule lookup keys.
658
+
659
+ ### Keep rules small and pure
660
+
661
+ Rules should do one thing and avoid hidden side effects.
662
+
663
+ ### Use registries for larger systems
664
+
665
+ If your project has many rules, registries provide structure and reduce duplication.
666
+
667
+ ### Validate schema before compile
668
+
669
+ If you load rules from external sources, validate them via `RuleSchema` before compiling.
670
+
671
+ ---
672
+
673
+ ## Exceptions
674
+
675
+ The library raises explicit exceptions for rule issues:
676
+
677
+ - `RuleNotRegisteredError`
678
+ - `RuleAlreadyRegisteredError`
679
+ - `RuleKeyDoesNotExistError`
680
+ - `CompilationError`
681
+ - `ProcessArgumentError`
682
+
683
+ These are especially useful when rules are dynamically loaded or assembled from user input.
684
+
685
+ ---
686
+
687
+ ## Example scripts in this repository
688
+
689
+ This project includes runnable examples under the `scripts/` directory:
690
+
691
+ - `scripts/rules_example.py` — basic object-based rule composition
692
+ - `scripts/reg_example.py` — registry usage and compiled rule predicate patterns
693
+ - `scripts/json_schema_example.py` — JSON schema generation examples
694
+
695
+ The test suite under `tests/` also demonstrates behavior for:
696
+
697
+ - registry registration
698
+ - predicate composition
699
+ - compiler validation
700
+ - schema serialization
701
+ - filtering workflows
702
+
703
+ ---
704
+
705
+ ## Example complete workflow
706
+
707
+ ```python
708
+ from dataclasses import dataclass
709
+
710
+ from pyspecification import ObjectRulesRegistry, PredicateCompiler, RuleSchema
711
+
712
+
713
+ @dataclass
714
+ class User:
715
+ name: str
716
+ age: int
717
+ is_admin: bool = False
718
+
719
+
720
+ registry = ObjectRulesRegistry[User, bool](operator="logical")
721
+
722
+
723
+ @registry.rule()
724
+ def is_admin(user: User) -> bool:
725
+ return user.is_admin
726
+
727
+
728
+ @registry.rule()
729
+ def name__istartswith(user: User, value: str) -> bool:
730
+ return user.name.lower().startswith(value.lower())
731
+
732
+
733
+ @registry.rule()
734
+ def age__between(user: User, min_age: int, max_age: int) -> bool:
735
+ return user.age >= min_age and user.age <= max_age
736
+
737
+
738
+ rule_definition = {
739
+ "operator": "or",
740
+ "expressions": [
741
+ {"is_admin": []},
742
+ {
743
+ "expressions": [
744
+ {"name__istartswith": "admin"},
745
+ {"age__between": [18, 30]},
746
+ ]
747
+ },
748
+ ],
749
+ }
750
+
751
+ schema = RuleSchema(**rule_definition)
752
+ compiler = PredicateCompiler(registry.rules, lambda spec: Predicate(lambda _: True, operator="logical"))
753
+ predicate = compiler.compile(schema.model_dump())
754
+
755
+ users = [
756
+ User("Abdullah", 18, True),
757
+ User("admin", 20, False),
758
+ User("Charlie", 12, False),
759
+ ]
760
+
761
+ print([predicate(user) for user in users])
762
+ ```
763
+
764
+ ---
765
+
766
+ ## Summary
767
+
768
+ `pyspecification` brings together rule registration, predicate composition, schema validation, and runtime compilation in a compact library designed around functional and declarative rule authoring.
769
+
770
+ It is a practical fit for projects that need to:
771
+
772
+ - express business rules clearly
773
+ - compose conditions without nested `if` chains
774
+ - validate dynamic rule payloads
775
+ - support filtering and policy evaluation
776
+ - keep rule logic easy to test and maintain
777
+
778
+ If you want a rule system that feels Pythonic, composable, and lightweight, `pyspecification` is built for that workflow.
779
+
780
+ ---
781
+
782
+ ## License
783
+
784
+ This project is licensed under the GNU General Public License v3.0 or later.
785
+
786
+ See the full text in [LICENSE](LICENSE).
787
+
788
+ The project is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL v3 for more details.