User Guide ========== .. testsetup:: * import plato from plato import derivedfield, formclass, sample, Shared from plato.providers.faker import FromFaker plato.seed(0) fake = FromFaker() Defining formclasses -------------------- In Plato you define the hierarchical structure of the test data to generate with classes using the `.formclass` decorator. This is similar to Python's :py:mod:`dataclasses`. In fact, a class decorated with `.formclass` will be converted to a special :py:func:`~dataclasses.dataclass`. Like in :py:mod:`dataclasses`, you define fields on the `.formclass` using type annotations and optionally default values:: @formclass class MyFormclass: mandatory_field: str constant_field: int = 42 generated_field: str = fake.first_name() In the following, declaring different types of fields is discussed. Mandatory fields ^^^^^^^^^^^^^^^^ For mandatory fields a value must be provided when instantiating the `.formclass`. To make a field mandatory, skip the default value assignment in the field declaration. .. testcode:: @formclass class MyFormclass: mandatory_field: str MyFormclass(mandatory_field="the value") # OK MyFormclass() # but this raises .. testoutput:: Traceback (most recent call last): ... TypeError: __init__() missing 1 required positional argument: 'mandatory_field' Constant fields ^^^^^^^^^^^^^^^ By assigning a constant default value in a field declaration, the field becomes optional on instantiation. It will be initialized with the provided value if not given, but can be overwritten with a different value. .. testcode:: @formclass class MyFormclass: constant_field: str = "default value" print(MyFormclass().constant_field) print(MyFormclass(constant_field="different value").constant_field) .. testoutput:: default value different value Generated fields ^^^^^^^^^^^^^^^^ Mandatory and constant fields do not really add anything over standard :py:mod:`dataclasses`. The benefit of Plato is that it allows you to also assign `.providers` to have data generated dynamically. In the following example we will use the `.FromFaker` provider that exposes the API of the `Faker `_ library for generating basic values. .. testcode:: from plato.providers.faker import FromFaker fake = FromFaker() @formclass class MyFormclass: generated_field: str = fake.first_name() When instantiating this formclass, it will have the provider instance assigned to the field: .. testcode:: print(MyFormclass().generated_field) .. testoutput:: To get an instance with actual generated values, use the `~plato.formclasses.sample()` function: .. testcode:: print(sample(MyFormclass()).generated_field) .. testoutput:: Alicia It is possible to overwrite providers with either constant values or different providers: .. testcode:: print(sample(MyFormclass( generated_field="fixed value" )).generated_field) print(sample(MyFormclass( generated_field=fake.postcode() )).generated_field) .. testoutput:: fixed value 16000 The power of Plato is that a `.formclass` instance happens to be also a provider. Thus, a hierachical structure can be declared and the data is generated accordingly. .. testcode:: @formclass class ComposedClass: field0: MyFormclass = MyFormclass() field1: MyFormclass = MyFormclass() field_with_postcode: MyFormclass = MyFormclass( generated_field=fake.postcode() ) from dataclasses import asdict from pprint import pprint pprint(asdict(sample(ComposedClass()))) .. testoutput:: {'field0': {'generated_field': 'Joseph'}, 'field1': {'generated_field': 'Sean'}, 'field_with_postcode': {'generated_field': '83827'}} Class variables ^^^^^^^^^^^^^^^ If no type annotation is given or the :py:class:`typing.ClassVar` annotation is used, no field is generated and a regular class variable is declared. .. testsetup:: import typing .. testcode:: @formclass class MyFormclass: class_var0 = "value0" class_var1: typing.ClassVar[str] = "value1" print(MyFormclass.class_var0) print(MyFormclass.class_var1) .. testoutput:: value0 value1 .. testcode:: MyFormclass(class_var0="foo") .. testoutput:: Traceback (most recent call last): ... TypeError: __init__() got an unexpected keyword argument 'class_var0' Class variables are not sampled by the `~plato.formclasses.sample()` function. Methods ^^^^^^^ Methods can be added to a `.formclass`. .. testcode:: @formclass class MyFormclass: name: str = "world" def greet(self): print(f"Hello, {self.name}!") MyFormclass().greet() sample(MyFormclass()).greet() .. testoutput:: Hello, world! Hello, world! Properties ^^^^^^^^^^ Properties can be added to a `.formclass`. .. testcode:: @formclass class MyFormclass: name: str = "world" @property def greeting(self) -> str: return f"Hello, {self.name}!" print(MyFormclass().greeting) .. testoutput:: Hello, world! Note that properties are considered fields, in particular when converting the resulting :py:func:`~dataclasses.dataclass` to other types. .. testcode:: from dataclasses import asdict, fields print( "Is greeting a field?", "greeting" in {f.name for f in fields(MyFormclass)} ) print( "Is greeting part of dict conversion?", "greeting" in asdict(MyFormclass()) ) .. testoutput:: Is greeting a field? False Is greeting part of dict conversion? False Derived fields ^^^^^^^^^^^^^^ It can be useful to derive the value of certain fields from other fields. This can be achieved by declaring a method with the `.derivedfield` decorator. Add the dependent fields as arguments to the method. .. testcode:: @formclass class User: first_name: str = fake.first_name() last_name: str = fake.last_name() @derivedfield def email(self, first_name, last_name) -> str: return f"{first_name}.{last_name}@example.net" from dataclasses import asdict from pprint import pprint pprint(asdict(sample(User()))) .. testoutput:: {'email': 'Denise.Wright@example.net', 'first_name': 'Denise', 'last_name': 'Wright'} A derived field can be overwritten with a different value or provider when needed. .. testcode:: pprint(asdict(sample(User(email="my-alias@mailz.org")))) .. testoutput:: {'email': 'my-alias@mailz.org', 'first_name': 'Melissa', 'last_name': 'Harris'} Init-only variables ^^^^^^^^^^^^^^^^^^^ Sometimes you need values to derive specific field values, but you do not want to store the original value as field in the formclass. Use the `InitVar` type for this. A field with this type can be provided as usual when constructing the formclass; you even must provide it if it has no default value. While, it is not included in the fields of your formclass, you can use it for your derived fields. Just add an argument with the same name to yout `.derivedfield` method. .. testcode:: from datetime import date, timedelta from plato import InitVar @formclass class User: first_name: str = fake.first_name() last_name: str = fake.last_name() email_domain: InitVar[str] = "example.net" @derivedfield def email(self, email_domain) -> str: return f"{self.first_name}.{self.last_name}@{email_domain}" pprint(asdict(sample(User(email_domain="mailz.org")))) .. testoutput:: {'email': 'Denise.Wright@mailz.org', 'first_name': 'Denise', 'last_name': 'Wright'} Note that `email_domain` is missing from the output. Using formclasses ----------------- When instantiating a `.formclass`, you obtain a "template" for test data. This allows to change specific values or providers as required by the respective test case. To generate the actual test data, call the `~plato.formclasses.sample()` on such a "template". .. testcode:: fake = FromFaker() @formclass class User: first_name: str = fake.first_name() last_name: str = fake.last_name() bio: str = "" @derivedfield def email(self) -> str: return f"{self.first_name}.{self.last_name}@example.net" from dataclasses import asdict from pprint import pprint template = User(first_name="Plato", bio=fake.sentence()) pprint(asdict(sample(template))) .. testoutput:: {'bio': 'Leg forget run book rise stage house.', 'email': 'Plato.Wright@example.net', 'first_name': 'Plato', 'last_name': 'Wright'} Sampling the same template multiple times will give different values; making it easy to generate multiple test data instances. .. testcode:: pprint(asdict(sample(template))) pprint(asdict(sample(template))) .. testoutput:: {'bio': 'Station bag whole mission west amount son car.', 'email': 'Plato.Harris@example.net', 'first_name': 'Plato', 'last_name': 'Harris'} {'bio': 'Though each energy catch pick ever strong bed.', 'email': 'Plato.Hernandez@example.net', 'first_name': 'Plato', 'last_name': 'Hernandez'} Seeding and reproducibility ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Test failures should be reproducible. This also requires that the test data used can be reproduced. Plato ensures this by always using the same default random number generator seed. If desired, the seed can be set manually. You might want to configure your test framework to do this before every test so that the set of executed tests does not affect the test data. .. testcode:: @formclass class MyFormclass: first_name: str = fake.first_name() plato.seed(42) print(sample(MyFormclass()).first_name) plato.seed(42) print(sample(MyFormclass()).first_name) .. testoutput:: Billy Billy To make things even more reproducible, Plato is designed in a way that leaves generated values unaffected if fields are added to or removed from a `.formclass`. .. testcode:: @formclass class MyFormclass: last_name: str = fake.last_name() plato.seed(42) print(sample(MyFormclass()).last_name) @formclass class MyFormclass: first_name: str = fake.first_name() last_name: str = fake.last_name() plato.seed(42) print(sample(MyFormclass()).last_name) .. testoutput:: Bullock Bullock However, generated values will change if the field name changes. .. testcode:: @formclass class MyFormclass: first_name: str = fake.last_name() plato.seed(42) print(sample(MyFormclass()).first_name) @formclass class MyFormclass: last_name: str = fake.last_name() plato.seed(42) print(sample(MyFormclass()).last_name) .. testoutput:: Williams Bullock Providers --------- While a `.formclass` defines the hierarchical structure of test data, a `.Provider` defines how individual values are generated. Currently, Plato does not really have any providers of its own, but provides the `.FromFaker` class to create providers based on the `Faker `_ library. Its delegates all method calls to Faker, but returns a `.Provider` usuable with Plato, instead of a value. .. testcode:: fake = FromFaker() print(fake.name()) print(sample(fake.name())) .. testoutput:: Randy Garcia The `.FromFaker` can be passed an existing :py:doc:`Faker ` instance. This allows for example to make use of Faker's localization feature. .. testcode:: from faker import Faker fake = FromFaker(Faker(["en-US", "de-DE"])) print("English name:", sample(fake["en-US"].name())) print("German name:", sample(fake["de-DE"].name())) .. testoutput:: English name: Danielle Fletcher German name: Prof. Helmut Hentschel .. testcleanup:: fake = FromFaker() Sharing values ^^^^^^^^^^^^^^ Sometimes it is desirable to share the value generated with a provider across multiple fields of a `.formclass`. This can be done with the special `.Shared` provider decorator. .. testcode:: shared @formclass class Address: street: str = fake.street_address() city: str = fake.city() postal_code: str = fake.postcode() @formclass class Order: billing_address: Address = Shared(Address()) shipping_address: Address = billing_address from dataclasses import asdict from pprint import pprint pprint(asdict(sample(Order()))) .. testoutput:: shared {'billing_address': {'city': 'North Reginaburgh', 'postal_code': '03314', 'street': '310 Edwin Shore Suite 986'}, 'shipping_address': {'city': 'North Reginaburgh', 'postal_code': '03314', 'street': '310 Edwin Shore Suite 986'}} This is in particular useful to include fields of a child class within the parent class. .. testcode:: shared @formclass class Customer: mailing_address: Address = Shared(Address()) postal_code: str = mailing_address.postal_code pprint(asdict(sample(Customer()))) Note that both *postal_codes* in the output are the same: .. testoutput:: shared {'mailing_address': {'city': 'New Shane', 'postal_code': '20059', 'street': '81646 Rebecca Rapids Suite 486'}, 'postal_code': '20059'} Implementing custom providers ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Custom providers can be implemented with the abstract `.Provider` base class. It only requires to provide an implementation of the :py:meth:`.Provider.sample` method. That method gets passed a `.Context` which provides a `.Context.seed` that should be used as random number generator seed to ensure reproducability. Alternatively, a seeded :py:class:`~random.Random` instance is provided as `.Context.rng`. .. testcode:: provider from plato.context import Context from plato.providers import Provider class RandomFloatProvider(Provider): def sample(self, context: Context) -> float: return context.rng.random() @formclass class MyFormclass: number: float = RandomFloatProvider() print(sample(MyFormclass()).number) .. testoutput:: provider 0.9355289927699928 Recipes and typical use cases ----------------------------- Convert to JSON ^^^^^^^^^^^^^^^ To convert generated test data to JSON, use :py:func:`~dataclasses.asdict` to convert the object into a dictionary first, then use the :py:mod:`json` module to convert that dictionary to JSON. .. testcode:: json from dataclasses import asdict import json @formclass class MyFormclass: string_value: str = fake.name() number_value: int = 42 data = sample(MyFormclass()) json = json.dumps(asdict(data)) print(json) .. testoutput:: json {"string_value": "Edwin Ford", "number_value": 42} Use Plato as builder ^^^^^^^^^^^^^^^^^^^^ TODO High-level states / variants ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ TODO Fill database with SQLAlchemy ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ TODO