Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion jsf/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,19 @@ def __parse_object(
self, name: str, path: str, schema: Dict[str, Any], root: Optional[AllTypes] = None
) -> Object:
_, is_nullable = self.__is_field_nullable(schema)
schema_without_props = {
k: v
for k, v in schema.items()
if k not in ("properties", "patternProperties", "dependencies")
}
model = Object.from_dict(
{
"name": name,
"path": path,
"is_nullable": is_nullable,
"allow_none_optionals": self.allow_none_optionals,
"max_recursive_depth": self.max_recursive_depth,
**schema,
**schema_without_props,
}
)
root = model if root is None else root
Expand Down Expand Up @@ -292,6 +297,18 @@ def __parse_definition(
isinstance(x, dict) for x in schema.get("items", [])
):
return self.__parse_tuple(name, path, schema, root)
# arrays without an "items" definition should still be valid and
# simply produce empty lists
return Array.from_dict(
{
"name": name,
"path": path,
"is_nullable": is_nullable,
"allow_none_optionals": self.allow_none_optionals,
"max_recursive_depth": self.max_recursive_depth,
**schema,
}
)
else:
return self.__parse_primitive(name, path, schema)
elif "$ref" in schema:
Expand Down
41 changes: 33 additions & 8 deletions jsf/schema_types/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
class Array(BaseSchema):
items: Optional[BaseSchema] = None
contains: Optional[BaseSchema] = None # NOTE: Validation only
minItems: Optional[int] = 0
# If `items` is provided in the schema, JSON Schema treats the array as
# having an item type. In that case JSF should emit at least one element
# by default. Using ``None`` here allows us to distinguish between the
# schema omitting ``minItems`` and explicitly setting ``minItems`` to ``0``
# which callers may rely on.
minItems: Optional[int] = None
maxItems: Optional[int] = 5
uniqueItems: Optional[bool] = False
fixed: Optional[Union[int, str]] = Field(None, alias="$fixed")
Expand All @@ -22,33 +27,53 @@ def generate(self, context: Dict[str, Any]) -> Optional[List[Any]]:
try:
return super().generate(context)
except ProviderNotSetException:
if self.items is None:
# No item schema means we cannot infer what the array should
# contain, therefore return an empty list.
return []

if isinstance(self.fixed, str):
self.minItems = self.maxItems = eval(self.fixed, context)()
elif isinstance(self.fixed, int):
self.minItems = self.maxItems = self.fixed

depth = context["state"]["__depth__"]

# ``minItems`` may be ``None`` when it wasn't provided in the
# schema. In that scenario we want non-empty arrays if an item
# schema exists. When the user explicitly sets ``minItems`` to 0
# we honour that and allow empty arrays.
min_items = (
int(self.minItems)
if self.minItems is not None
else (0 if self.items is None else 1)
)
max_items = int(self.maxItems) if self.maxItems is not None else 5

output = []
for _ in range(random.randint(int(self.minItems), int(self.maxItems))):
for _ in range(random.randint(min_items, max_items)):
output.append(self.items.generate(context))
context["state"]["__depth__"] = depth
if self.uniqueItems and self.items.type == "object":
output = [dict(s) for s in {frozenset(d.items()) for d in output}]
while len(output) < self.minItems:
while len(output) < min_items:
output.append(self.items.generate(context))
output = [dict(s) for s in {frozenset(d.items()) for d in output}]
context["state"]["__depth__"] = depth
elif self.uniqueItems:
output = set(output)
while len(output) < self.minItems:
while len(output) < min_items:
output.add(self.items.generate(context))
context["state"]["__depth__"] = depth
output = list(output)
return output

def model(self, context: Dict[str, Any]) -> Tuple[Type, Any]:
_type = eval(
f"List[Union[{','.join([self.items.model(context)[0].__name__])}]]",
context["__internal__"],
)
if self.items is None:
_type = List[Any]
else:
_type = eval(
f"List[Union[{','.join([self.items.model(context)[0].__name__])}]]",
context["__internal__"],
)
return self.to_pydantic(context, _type)
4 changes: 2 additions & 2 deletions jsf/schema_types/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ class PropertyNames(BaseModel):


class Object(BaseSchema):
properties: Dict[str, BaseSchema] = {}
properties: List[BaseSchema] = []
additionalProperties: Optional[Union[bool, BaseSchema]] = None
required: Optional[List[str]] = None
propertyNames: Optional[PropertyNames] = None
minProperties: Optional[int] = None
maxProperties: Optional[int] = None
dependencies: Optional[Union[PropertyDependency, SchemaDependency]] = None
patternProperties: Optional[Dict[str, BaseSchema]] = None
patternProperties: Optional[List[BaseSchema]] = None

@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "Object":
Expand Down
31 changes: 31 additions & 0 deletions jsf/tests/data/empty-list-pro.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "http://json-schema.org/schema#",
"type": "object",
"properties": {
"content": {
"type": "object",
"properties": {
"list": {
"type": "array"
},
"non-empty-sub-list": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"sub-list": {
"type": "array",
"items": {
"type": "array"
}
}
},
"required": ["list", "sub-list"]
}
},
"required": ["content"]
}
8 changes: 8 additions & 0 deletions jsf/tests/data/empty-list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "object",
"properties": {
"items": {
"type": "array"
}
}
}
40 changes: 40 additions & 0 deletions jsf/tests/test_default_fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,43 @@ def test_use_defaults_and_examples(TestData):
assert d["name"] in ["Chop", "Luna", "Thanos"]
breed = d.get("breed")
assert breed is None or breed == "Mixed Breed"

def test_gen_empty_list(TestData):
with open(TestData / "empty-list.json") as file:
schema = json.load(file)
p = JSF(schema, allow_none_optionals=0.0)

fake_data = [p.generate(use_defaults=True, use_examples=True) for _ in range(10)]
for d in fake_data:
assert isinstance(d, dict)
assert "items" in d
assert isinstance(d["items"], list)
assert len(d["items"]) == 0

def test_gen_empty_list_pro(TestData):
with open(TestData / "empty-list-pro.json") as file:
schema = json.load(file)
p = JSF(schema, allow_none_optionals=0.0)

fake_data = [p.generate(use_defaults=True, use_examples=True) for _ in range(10)]
for d in fake_data:
assert isinstance(d, dict)
assert "content" in d

assert isinstance(d["content"], dict)
assert "list" in d["content"]

assert isinstance(d["content"]["list"], list)
assert len(d["content"]["list"]) == 0

assert "non-empty-sub-list" in d["content"]
assert isinstance(d["content"]["non-empty-sub-list"], list)
assert len(d["content"]["non-empty-sub-list"]) >= 1
assert isinstance(d["content"]["non-empty-sub-list"][0], list)
assert any(len(sublist) >= 1 and all(isinstance(item, str) for item in sublist) for sublist in d["content"]["non-empty-sub-list"])

assert "sub-list" in d["content"]
assert isinstance(d["content"]["sub-list"], list)
assert len(d["content"]["sub-list"]) >= 1
assert isinstance(d["content"]["sub-list"][0], list)
assert len(d["content"]["sub-list"][0]) == 0
Loading