From 9f6ee4da87faef36fb3d93715bdb5cdb65b3cd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=20V=C3=AEjdea?= Date: Sat, 23 Dec 2017 11:52:31 +0100 Subject: [PATCH] Improve RelatedField and callable default handling - callable default values will now be properly called - PrimaryKeyRelatedField and SlugRelatedField will now return an appropriate type based on the relation model's Field - mock views now have a request object bound even when public is True --- src/drf_yasg/generators.py | 44 ++--- src/drf_yasg/openapi.py | 2 + src/drf_yasg/utils.py | 160 ++++++++++++++++-- testproj/articles/migrations/0001_initial.py | 12 +- .../migrations/0002_auto_20171221_1635.py | 28 --- testproj/articles/models.py | 1 + testproj/articles/serializers.py | 11 +- testproj/articles/views.py | 8 +- testproj/db.sqlite3 | Bin 163840 -> 155648 bytes testproj/snippets/migrations/0001_initial.py | 10 +- .../migrations/0002_auto_20171205_0505.py | 27 --- testproj/snippets/serializers.py | 18 +- testproj/users/serializers.py | 9 +- tests/conftest.py | 29 +++- tests/reference.yaml | 31 +++- tests/test_reference_schema.py | 2 +- tests/test_schema_generator.py | 24 +-- 17 files changed, 274 insertions(+), 142 deletions(-) delete mode 100644 testproj/articles/migrations/0002_auto_20171221_1635.py delete mode 100644 testproj/snippets/migrations/0002_auto_20171205_0505.py diff --git a/src/drf_yasg/generators.py b/src/drf_yasg/generators.py index 6eeef64..3f1979f 100644 --- a/src/drf_yasg/generators.py +++ b/src/drf_yasg/generators.py @@ -2,14 +2,12 @@ import re from collections import defaultdict, OrderedDict import uritemplate -from coreapi.compat import force_text from rest_framework.schemas.generators import SchemaGenerator, EndpointEnumerator as _EndpointEnumerator -from rest_framework.schemas.inspectors import get_pk_description from . import openapi from .inspectors import SwaggerAutoSchema from .openapi import ReferenceResolver -from .utils import get_schema_type_from_model_field +from .utils import inspect_model_field, get_model_field PATH_PARAMETER_RE = re.compile(r'{(?P\w+)}') @@ -82,9 +80,9 @@ class OpenAPISchemaGenerator(object): :return: the generated Swagger specification :rtype: openapi.Swagger """ - endpoints = self.get_endpoints(None if public else request) + endpoints = self.get_endpoints(request) components = ReferenceResolver(openapi.SCHEMA_DEFINITIONS) - paths = self.get_paths(endpoints, components) + paths = self.get_paths(endpoints, components, public) url = self._gen.url if not url and request is not None: @@ -114,9 +112,9 @@ class OpenAPISchemaGenerator(object): return view def get_endpoints(self, request=None): - """Iterate over all the registered endpoints in the API. + """Iterate over all the registered endpoints in the API and return a fake view with the right parameters. - :param rest_framework.request.Request request: used for returning only endpoints available to the given request + :param rest_framework.request.Request request: request to bind to the endpoint views :return: {path: (view_class, list[(http_method, view_instance)]) :rtype: dict """ @@ -151,11 +149,12 @@ class OpenAPISchemaGenerator(object): """ return self._gen.get_keys(subpath, method, view) - def get_paths(self, endpoints, components): + def get_paths(self, endpoints, components, public): """Generate the Swagger Paths for the API from the given endpoints. :param dict endpoints: endpoints as returned by get_endpoints :param ReferenceResolver components: resolver/container for Swagger References + :param bool public: if True, all endpoints are included regardless of access through `request` :rtype: openapi.Paths """ if not endpoints: @@ -169,7 +168,7 @@ class OpenAPISchemaGenerator(object): path_parameters = self.get_path_parameters(path, view_cls) operations = {} for method, view in methods: - if not self._gen.has_view_permissions(path, method, view): + if not public and not self._gen.has_view_permissions(path, method, view): continue operation_keys = self.get_operation_keys(path[len(prefix):], method, view) @@ -209,35 +208,20 @@ class OpenAPISchemaGenerator(object): :rtype: list[openapi.Parameter] """ parameters = [] + queryset = getattr(view_cls, 'queryset', None) model = getattr(getattr(view_cls, 'queryset', None), 'model', None) for variable in uritemplate.variables(path): - pattern = None - type = openapi.TYPE_STRING - description = None - if model is not None: - # Attempt to infer a field description if possible. - try: - model_field = model._meta.get_field(variable) - except Exception: # pragma: no cover - model_field = None - else: - type = get_schema_type_from_model_field(model_field) + model, model_field = get_model_field(queryset, variable) + attrs = inspect_model_field(model, model_field) + if hasattr(view_cls, 'lookup_value_regex') and getattr(view_cls, 'lookup_field', None) == variable: + attrs['pattern'] = view_cls.lookup_value_regex - if model_field is not None and model_field.help_text: - description = force_text(model_field.help_text) - elif model_field is not None and model_field.primary_key: - description = get_pk_description(model, model_field) - - if hasattr(view_cls, 'lookup_value_regex') and getattr(view_cls, 'lookup_field', None) == variable: - pattern = view_cls.lookup_value_regex field = openapi.Parameter( name=variable, required=True, in_=openapi.IN_PATH, - type=type, - pattern=pattern, - description=description, + **attrs ) parameters.append(field) diff --git a/src/drf_yasg/openapi.py b/src/drf_yasg/openapi.py index 7e6e34b..8b3d3b0 100644 --- a/src/drf_yasg/openapi.py +++ b/src/drf_yasg/openapi.py @@ -309,6 +309,7 @@ class Items(SwaggerDict): :param .Items items: only valid if `type` is ``array`` """ super(Items, self).__init__(**extra) + assert type is not None, "type is required!" self.type = type self.format = format self.enum = enum @@ -372,6 +373,7 @@ class Schema(SwaggerDict): # common error raise AssertionError( "the `requires` attribute of schema must be an array of required properties, not a boolean!") + assert type is not None, "type is required!" self.description = description self.required = required self.type = type diff --git a/src/drf_yasg/utils.py b/src/drf_yasg/utils.py index cab2beb..27c713e 100644 --- a/src/drf_yasg/utils.py +++ b/src/drf_yasg/utils.py @@ -1,3 +1,4 @@ +import logging from collections import OrderedDict from django.core.validators import RegexValidator @@ -5,21 +6,19 @@ from django.db import models from django.utils.encoding import force_text from rest_framework import serializers from rest_framework.mixins import RetrieveModelMixin, DestroyModelMixin, UpdateModelMixin +from rest_framework.schemas.inspectors import get_pk_description from rest_framework.settings import api_settings +from rest_framework.utils import json, encoders from . import openapi from .errors import SwaggerGenerationError +logger = logging.getLogger(__name__) + #: used to forcibly remove the body of a request via :func:`.swagger_auto_schema` no_body = object() -def get_schema_type_from_model_field(model_field): - if isinstance(model_field, models.AutoField): - return openapi.TYPE_INTEGER - return openapi.TYPE_STRING - - def is_list_view(path, method, view): """Check if the given path/method appears to represent a list view (as opposed to a detail/instance view). @@ -164,6 +163,87 @@ def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_bod return decorator +def get_model_field(queryset, field_name): + """Try to get information about a model and model field from a queryset. + + :param queryset: the queryset + :param field_name: the target field name + :returns: the model and target field from the queryset as a 2-tuple; both elements can be ``None`` + :rtype: tuple + """ + model = getattr(queryset, 'model', None) + try: + model_field = model._meta.get_field(field_name) + except Exception: # pragma: no cover + model_field = None + + return model, model_field + + +model_field_to_swagger_type = { + models.AutoField: (openapi.TYPE_INTEGER, None), + models.BinaryField: (openapi.TYPE_STRING, openapi.FORMAT_BINARY), + models.BooleanField: (openapi.TYPE_BOOLEAN, None), + models.NullBooleanField: (openapi.TYPE_BOOLEAN, None), + models.DateTimeField: (openapi.TYPE_STRING, openapi.FORMAT_DATETIME), + models.DateField: (openapi.TYPE_STRING, openapi.FORMAT_DATE), + models.DecimalField: (openapi.TYPE_NUMBER, None), + models.DurationField: (openapi.TYPE_INTEGER, None), + models.FloatField: (openapi.TYPE_NUMBER, None), + models.IntegerField: (openapi.TYPE_INTEGER, None), + models.IPAddressField: (openapi.TYPE_STRING, openapi.FORMAT_IPV4), + models.GenericIPAddressField: (openapi.TYPE_STRING, openapi.FORMAT_IPV6), + models.SlugField: (openapi.TYPE_STRING, openapi.FORMAT_SLUG), + models.TextField: (openapi.TYPE_STRING, None), + models.TimeField: (openapi.TYPE_STRING, None), + models.UUIDField: (openapi.TYPE_STRING, openapi.FORMAT_UUID), + models.CharField: (openapi.TYPE_STRING, None), +} + + +def inspect_model_field(model, model_field): + """Extract information from a django model field instance. + + :param model: the django model + :param model_field: a field on the model + :return: description, type, format and pattern extracted from the model field + :rtype: OrderedDict + """ + if model is not None and model_field is not None: + for model_field_class, tf in model_field_to_swagger_type.items(): + if isinstance(model_field, model_field_class): + swagger_type, format = tf + break + else: + swagger_type, format = None, None + + if format is None or format == openapi.FORMAT_SLUG: + pattern = find_regex(model_field) + else: + pattern = None + + if model_field.help_text: + description = force_text(model_field.help_text) + elif model_field.primary_key: + description = get_pk_description(model, model_field) + else: + description = None + else: + description = None + swagger_type = None + format = None + pattern = None + + result = OrderedDict([ + ('description', description), + ('type', swagger_type or openapi.TYPE_STRING), + ('format', format), + ('pattern', pattern) + ]) + # TODO: filter none + return result + + def serializer_field_to_swagger(field, swagger_object_type, definitions=None, **kwargs): """Convert a drf Serializer or Field instance into a Swagger object. @@ -183,17 +263,50 @@ def serializer_field_to_swagger(field, swagger_object_type, definitions=None, ** description = force_text(field.help_text) if field.help_text else None description = description if swagger_object_type != openapi.Items else None # Items has no description either - def SwaggerType(**instance_kwargs): + def SwaggerType(existing_object=None, **instance_kwargs): if swagger_object_type == openapi.Parameter and 'required' not in instance_kwargs: instance_kwargs['required'] = field.required if swagger_object_type != openapi.Items and 'default' not in instance_kwargs: default = getattr(field, 'default', serializers.empty) if default is not serializers.empty: - instance_kwargs['default'] = default + if callable(default): + try: + if hasattr(default, 'set_context'): + default.set_context(field) + default = default() + except Exception as e: + logger.warning("default for %s is callable but it raised an exception when " + "called; 'default' field will not be added to schema", field, exc_info=True) + default = None + + if default is not None: + try: + default = field.to_representation(default) + # JSON roundtrip ensures that the value is valid JSON; + # for example, sets get transformed into lists + default = json.loads(json.dumps(default, cls=encoders.JSONEncoder)) + except Exception as e: + logger.warning("'default' on schema for %s will not be set because " + "to_representation raised an exception", field, exc_info=True) + default = None + + if default is not None: + instance_kwargs['default'] = default + if swagger_object_type == openapi.Schema and 'read_only' not in instance_kwargs: if field.read_only: instance_kwargs['read_only'] = True instance_kwargs.update(kwargs) + instance_kwargs.pop('title', None) + instance_kwargs.pop('description', None) + + if existing_object is not None: + existing_object.title = title + existing_object.description = description + for attr, val in instance_kwargs.items(): + setattr(existing_object, attr, val) + return existing_object + return swagger_object_type(title=title, description=description, **instance_kwargs) # arrays in Schema have Schema elements, arrays in Parameter and Items have Items elements @@ -246,8 +359,27 @@ def serializer_field_to_swagger(field, swagger_object_type, definitions=None, ** unique_items=True, # is this OK? ) elif isinstance(field, serializers.PrimaryKeyRelatedField): - model = field.queryset.model - return SwaggerType(type=get_schema_type_from_model_field(model._meta.pk)) + if field.pk_field: + result = serializer_field_to_swagger(field.pk_field, swagger_object_type, definitions, **kwargs) + return SwaggerType(existing_object=result) + + attrs = {'type': openapi.TYPE_STRING} + try: + model = field.queryset.model + pk_field = model._meta.pk + except Exception: + logger.warning("an exception was raised when attempting to extract the primary key related to %s; " + "falling back to plain string" % field, exc_info=True) + else: + attrs.update(inspect_model_field(model, pk_field)) + + return SwaggerType(**attrs) + elif isinstance(field, serializers.HyperlinkedRelatedField): + return SwaggerType(type=openapi.TYPE_STRING, format=openapi.FORMAT_URI) + elif isinstance(field, serializers.SlugRelatedField): + model, model_field = get_model_field(field.queryset, field.slug_field) + attrs = inspect_model_field(model, model_field) + return SwaggerType(**attrs) elif isinstance(field, serializers.RelatedField): return SwaggerType(type=openapi.TYPE_STRING) # ------ CHOICES @@ -262,7 +394,7 @@ def serializer_field_to_swagger(field, swagger_object_type, definitions=None, ** elif isinstance(field, serializers.ChoiceField): return SwaggerType(type=openapi.TYPE_STRING, enum=list(field.choices.keys())) # ------ BOOL - elif isinstance(field, serializers.BooleanField): + elif isinstance(field, (serializers.BooleanField, serializers.NullBooleanField)): return SwaggerType(type=openapi.TYPE_BOOLEAN) # ------ NUMERIC elif isinstance(field, (serializers.DecimalField, serializers.FloatField)): @@ -271,6 +403,8 @@ def serializer_field_to_swagger(field, swagger_object_type, definitions=None, ** elif isinstance(field, serializers.IntegerField): # TODO: min_value max_value return SwaggerType(type=openapi.TYPE_INTEGER) + elif isinstance(field, serializers.DurationField): + return SwaggerType(type=openapi.TYPE_INTEGER) # ------ STRING elif isinstance(field, serializers.EmailField): return SwaggerType(type=openapi.TYPE_STRING, format=openapi.FORMAT_EMAIL) @@ -317,8 +451,10 @@ def serializer_field_to_swagger(field, swagger_object_type, definitions=None, ** type=openapi.TYPE_OBJECT, additional_properties=child_schema ) + elif isinstance(field, serializers.ModelField): + return SwaggerType(type=openapi.TYPE_STRING) - # TODO unhandled fields: TimeField DurationField HiddenField ModelField NullBooleanField? JSONField + # TODO unhandled fields: TimeField HiddenField JSONField # everything else gets string by default return SwaggerType(type=openapi.TYPE_STRING) diff --git a/testproj/articles/migrations/0001_initial.py b/testproj/articles/migrations/0001_initial.py index 75452fd..6d82083 100644 --- a/testproj/articles/migrations/0001_initial.py +++ b/testproj/articles/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 2.0 on 2017-12-05 04:05 +# Generated by Django 2.0 on 2017-12-23 09:07 +from django.conf import settings from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -8,6 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -15,12 +18,13 @@ class Migration(migrations.Migration): name='Article', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(help_text='Main article headline', max_length=255, unique=True)), - ('body', models.TextField(help_text='Article content', max_length=5000)), - ('slug', models.SlugField(blank=True, help_text='Unique URL slug identifying the article', unique=True)), + ('title', models.CharField(help_text='title model help_text', max_length=255, unique=True)), + ('body', models.TextField(help_text='article model help_text', max_length=5000)), + ('slug', models.SlugField(blank=True, help_text='slug model help_text', unique=True)), ('date_created', models.DateTimeField(auto_now_add=True)), ('date_modified', models.DateTimeField(auto_now=True)), ('cover', models.ImageField(blank=True, upload_to='article/original/')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to=settings.AUTH_USER_MODEL)), ], ), ] diff --git a/testproj/articles/migrations/0002_auto_20171221_1635.py b/testproj/articles/migrations/0002_auto_20171221_1635.py deleted file mode 100644 index f21f376..0000000 --- a/testproj/articles/migrations/0002_auto_20171221_1635.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 2.0 on 2017-12-21 15:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('articles', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='article', - name='body', - field=models.TextField(help_text='article model help_text', max_length=5000), - ), - migrations.AlterField( - model_name='article', - name='slug', - field=models.SlugField(blank=True, help_text='slug model help_text', unique=True), - ), - migrations.AlterField( - model_name='article', - name='title', - field=models.CharField(help_text='title model help_text', max_length=255, unique=True), - ), - ] diff --git a/testproj/articles/models.py b/testproj/articles/models.py index 3031616..24878b8 100644 --- a/testproj/articles/models.py +++ b/testproj/articles/models.py @@ -7,5 +7,6 @@ class Article(models.Model): slug = models.SlugField(help_text="slug model help_text", unique=True, blank=True) date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) + author = models.ForeignKey('auth.User', related_name='articles', on_delete=models.CASCADE) cover = models.ImageField(upload_to='article/original/', blank=True) diff --git a/testproj/articles/serializers.py b/testproj/articles/serializers.py index d488ec9..ee90e2c 100644 --- a/testproj/articles/serializers.py +++ b/testproj/articles/serializers.py @@ -14,12 +14,19 @@ class ArticleSerializer(serializers.ModelSerializer): class Meta: model = Article - fields = ('title', 'body', 'slug', 'date_created', 'date_modified', + fields = ('title', 'author', 'body', 'slug', 'date_created', 'date_modified', 'references', 'uuid', 'cover', 'cover_name') read_only_fields = ('date_created', 'date_modified', 'references', 'uuid', 'cover_name') lookup_field = 'slug' - extra_kwargs = {'body': {'help_text': 'body serializer help_text'}} + extra_kwargs = { + 'body': {'help_text': 'body serializer help_text'}, + 'author': { + 'default': serializers.CurrentUserDefault(), + 'help_text': "The ID of the user that created this article; if none is provided, " + "defaults to the currently logged in user." + }, + } class ImageUploadSerializer(serializers.Serializer): diff --git a/testproj/articles/views.py b/testproj/articles/views.py index 10eb2ce..d66e058 100644 --- a/testproj/articles/views.py +++ b/testproj/articles/views.py @@ -20,6 +20,11 @@ class NoPagingAutoSchema(SwaggerAutoSchema): return False +class ArticlePagination(LimitOffsetPagination): + default_limit = 5 + max_limit = 25 + + @method_decorator(name='list', decorator=swagger_auto_schema( operation_description="description from swagger_auto_schema via method_decorator" )) @@ -41,8 +46,7 @@ class ArticleViewSet(viewsets.ModelViewSet): lookup_value_regex = r'[a-z0-9]+(?:-[a-z0-9]+)' serializer_class = serializers.ArticleSerializer - pagination_class = LimitOffsetPagination - max_page_size = 5 + pagination_class = ArticlePagination filter_backends = (DjangoFilterBackend, OrderingFilter) filter_fields = ('title',) ordering_fields = ('date_modified','date_created') diff --git a/testproj/db.sqlite3 b/testproj/db.sqlite3 index 18280dff6bba2fcf1b5770f8b06f9a0e64f826b3..532719e25144fa0be7b7ed911e26561061456079 100644 GIT binary patch delta 1884 zcmah~eQXnD7{BMPYuB$`%RcI$UAwZu#^`qM-Syg=B9yKBf?gTx7_BBPd7_-QeF-*h#{q4nwl#C8X?-l z)JVK_4S+nIUe?VsbiYx`THSv$$^hlz8E>CKDp}bIz*A;;cj=`ER=xt@c#3B@pHA9s z>#770&%4o1t#r~xRRC_j9DGN$bko*Z3D63_^=PE}CR72i9K*Y*0ksrq;wk{f%PWk5 zD(PGkx9;JVC_0$(a#^X_j+)j^(;TO$pol8W(&~%FGh$XUTkC8UCP>l4cHvc6y<##~ zI8e5z@QoB0C4; zPS3^)%52_LixaNu)#+tH(R6NlCA-wsF@v74YnMIE<6=xND$ z_r@j)T+dXoFPRDs#l`%g-ca#iB+$1%KRhHBM2|4qzh`D*WFWAOrjW;lXxFM{z8kTf zoQLx=l=S6UyLlxaz1=L`$+2#Z;TB*0LfDt1&|GRNhGH{`0^+zSmdhq$VkS4Ao-DZM z4v9ihI1uR-GJ#||84eAi;MgD%Li>_|Vu}hB{YV%aMd8p+G15PDAQBo%g^QDigT0dD z&WX`MHWKnPfw56W=no$bXTr474oCX^DA1cu3dJEY*gr}KLi;j8DCJfHq=e)oJ(#7+ zO1v%#xhQVAhL)9hXym0mx+NHYV1Iqaq`ZqWU~*~xz|7bcjBKg7p>NZFq`js7yn4H# zL)}mnR=uK96Enu!Mmzq7V)6~cL4(`?HO!9x~J(a zbqv8%pw{2TwguazPxZF-<%5YvbLn^#Jv^x8vA%S9Hw=T+roW~#HeioSzw15njzRcQ zs~oS^(%ufoOMEXZLFtA7UFHt`8EhRUW4b%Kr?sc5uWHV!ud7>B^C~}af(YUtVlsXR z=g4W}cg9^3GNp9KtgOc3a=~*cxi15)&@87jFd{#ggeT;$Gw{5+yqw&ubWpchcJ#m& zqJ29jpPB%d{9YE!#K!IJ^5{vGbD@rrBMI0B4*BH-i~%j*O+dHGzQ7^5a|%jTl-lCA zm&MAOX;=qcrS;SB4nT|aE72osMVN$*az=!k;FNty*a9{=orL?!g2WVDGdMIBhJk1K zLTn-}?@2?Qq^qfw<7tp}3nrV!5_G`=lA}3jD1D!WCQxA*Tr9odgLi>ksO2@5Va32L zX5)!@*_;5UIiD40lyuBTS38?zA^{=d7~z$*G1v;NG8coBy2)HT;dB%d^9A{G3_d>6 zzR=*&SVk0Q{Iau0i#WQnUr{k0rQCch5%a_!HEHSD>-UBHjzOW(iF$A^h#9udKB+EuaN!(HaiIj delta 2049 zcmah~S!@$m6!o>^-SOC2WCC&Q5cc5Y%|2t6#3UhXc1UoX#%<*|apEQMLSjfr)k0E) zstQWek(j^IQq-UJ14tXIq7_T2sF1oq_kQt#`qQYiRevg4(PxJx4oDr(lg96yb7#)= zx#J_3^+%2vUSDAq6bi*UyqtKc@Y*}Gkp|&>-&tZ^nQ>Y{TqjNu5u(;SZ$4vw##~-{ zv-CviF4MoJ4^1N`-lQ~MHBQfxmVoi_)-tuizR43F&Bg~t2FDOWR49BPn+{Qke zO_MAjNm*T2v3Q?jXnbFqkW$sPMF1;^BG0s&rFW{?Vt}F5&5_X#; z#x52Cw8-JpW0a=sjl}?k1WMRukgnNL5kLt%O)<@SNmtWQ1dt*{Q*4V)+Eqgq12jV+ zafenqS;G|ph+{Yg?a)X+Ed@~id1$DSxmk}yn_b@r32N$7GdL&X_s&eTcW+L?9f;g_I1XUwM z65&)d9ZJTdqv32kof<1M8C4E_{^>#o#s)`;kBLi)atp4ow05~#RSyMTW^gpAjgO6C z`7-HbbUx`CqwnxmwzPdls& z1484BH?TB?V|L-K8@@n>&!#DSAo}2BVs!tZ$id8Le25tuJv2N?C5I=xi9pil?;Q6{ z29iO)_mGzgCWDDstiwM{_D*&U_fC3H(BBmcw)e8Veh=~{BC%e-!m-$iVHW!CJI-V*_`HxQv23uyF$J;8e9PQqHkdv#9y8SIn{{t!ztdJ~rq%N$ zf0c-;5ls0}(Kh3;$!*OrA-~iLP4drPcs2J)Gkl=TIlXWJa+wzJt0a}`*Id&Mcot@_ zX=s@JLEj0|MI$S%GfKHzK4`QyD!)=#L8BzP%~ws0#v#L2eM0xC)~30msaOA|?g357 zmD0`1*UVE3ABnVluXbi4o+m9z7D_B z*S%-Ydegn87L9G6OS)pLl27$REwR>g5B|(YW~WDEd!By?SXSmE9io<|v5<5p)|_6% zT~pwaPvtr=*Kgx_IO>t#?t{t|g$hrl+U4tgP@^sog`6EKi0ZA_u}yTe5zt ze3R@;!d6%%pGe}SPE~O7#eUcVb@Jc+(1R771K=)MImI#Z$pOfzX^pMTfj!yfSOm&| z%DoVQ_W;&Pf2dmI#1IU?8u`m1_y$(XXQHqHYUMAY@DDD3BL<(F>a;eRhG{Vy?jMoQ z$Du+pn9JqQ;vj2q!#t1MXpsl|!D@6)Z`-D|xm<9R&wE{st71p{^2V)p)F$R~4VuU45`bmyHryth z|2J}Tzr$U^`b6~SkWypOoKq0z6svH}vxLD1Iz?5L$5UD*UnZ25B%W~2jVL$b8Yr6P zL~ijLp%@vc2bxn|}2ghSt9*H