33from __future__ import annotations
44
55import os
6- import re
76import tempfile
87from contextlib import suppress
9- from typing import Any , Dict , Mapping , Tuple
8+ from typing import Any
109
1110import yaml
1211from hypothesis import given , settings
1312from hypothesis import strategies as st
14-
15- # StrictYAML is a runtime dependency:
16- # pip install strictyaml hypothesis
17- from strictyaml import Any as SyAny
18- from strictyaml import (
19- Bool ,
20- EmptyList ,
21- EmptyNone ,
22- Enum ,
23- Float ,
24- Int ,
25- Map ,
26- MapPattern ,
27- Regex ,
28- Seq ,
29- Str ,
30- as_document ,
31- load ,
32- )
13+ from strictyaml import as_document , load
3314
3415from dfetch .__main__ import DfetchFatalException , run
3516from dfetch .manifest .manifest import Manifest
5738else :
5839 settings .load_profile ("dev" )
5940
41+ # Avoid control chars and NUL to prevent OS/path/subprocess issues in tests
42+ SAFE_TEXT = st .text (
43+ alphabet = st .characters (
44+ min_codepoint = 32 , blacklist_categories = ("Cs" ,)
45+ ), # no controls/surrogates
46+ min_size = 0 ,
47+ max_size = 64 ,
48+ )
6049
61- def _classname (obj : Any ) -> str :
62- return obj .__class__ .__name__
50+ # NUMBER = Int() | Float() with finite floats
51+ SAFE_NUMBER = st .one_of (
52+ st .integers (),
53+ st .floats (allow_nan = False , allow_infinity = False ),
54+ )
6355
6456
65- def _get_map_items (m : Map ) -> Mapping [Any , Any ]:
66- """
67- StrictYAML's Map stores the key->validator mapping internally.
68- It has varied attribute names across versions; try common ones.
69- """
70- for attr in ("_validator" , "_map" , "map" , "mapping" ):
71- val = getattr (m , attr , None )
72- if isinstance (val , Mapping ):
73- return val
74- raise TypeError ("Unsupported StrictYAML Map internals; cannot find mapping dict." )
57+ def opt_str ():
58+ """Small helper for optional text fields."""
59+ return st .none () | SAFE_TEXT
60+
61+
62+ remote_entry = st .builds (
63+ lambda name , url_base , default : {
64+ k : v
65+ for k , v in {
66+ "name" : name ,
67+ "url-base" : url_base ,
68+ "default" : default ,
69+ }.items ()
70+ if v is not None
71+ },
72+ name = SAFE_TEXT .filter (lambda s : len (s ) > 0 ),
73+ url_base = SAFE_TEXT .filter (lambda s : len (s ) > 0 ),
74+ default = st .none () | st .booleans (),
75+ )
7576
77+ vcs_enum = st .sampled_from (["git" , "svn" ])
78+
79+ ignore_list = st .lists (SAFE_TEXT , min_size = 1 , max_size = 5 )
80+
81+ project_entry = st .builds (
82+ lambda name , dst , branch , tag , revision , url , repo_path , remote , patch , vcs , src , ignore : {
83+ k : v
84+ for k , v in {
85+ "name" : name ,
86+ "dst" : dst ,
87+ "branch" : branch ,
88+ "tag" : tag ,
89+ "revision" : revision ,
90+ "url" : url ,
91+ "repo-path" : repo_path ,
92+ "remote" : remote ,
93+ "patch" : patch ,
94+ "vcs" : vcs ,
95+ "src" : src ,
96+ "ignore" : ignore ,
97+ }.items ()
98+ if v is not None
99+ },
100+ name = SAFE_TEXT .filter (lambda s : len (s ) > 0 ),
101+ dst = opt_str (),
102+ branch = opt_str (),
103+ tag = opt_str (),
104+ revision = opt_str (),
105+ url = opt_str (),
106+ repo_path = opt_str (),
107+ remote = opt_str (),
108+ patch = opt_str (),
109+ vcs = st .none () | vcs_enum ,
110+ src = opt_str (),
111+ ignore = st .one_of (ignore_list , st .just ([])),
112+ )
76113
77- def _unwrap_optional_key (k : Any ) -> Tuple [str , bool ]:
78- """
79- Returns (key_name, is_optional).
80- Optional('b', default=...) is used *as a key* inside Map({...}).
81- """
82- if _classname (k ) == "Optional" :
83- for attr in ("_key" , "key" ):
84- name = getattr (k , attr , None )
85- if isinstance (name , str ):
86- return name , True
87- return str (k ), True
88- if isinstance (k , str ):
89- return k , False
90- return str (k ), False
91-
92-
93- def _enum_values (e : Enum ) -> Any :
94- vals = getattr (e , "_restricted_to" , None )
95- if vals :
96- return list (vals )
97- raise TypeError ("Unsupported StrictYAML Enum internals; cannot read choices." )
98-
99-
100- def _regex_pattern (r : Regex ) -> re .Pattern :
101- for attr in ("_regex" , "regex" , "pattern" ):
102- pat = getattr (r , attr , None )
103- if isinstance (pat , (str , re .Pattern )):
104- return re .compile (pat ) if isinstance (pat , str ) else pat
105- raise TypeError ("Unsupported StrictYAML Regex internals; cannot read pattern." )
106-
107-
108- def _mappattern_parts (mp : MapPattern ) -> Tuple [Any , Any , int | None , int | None ]:
109- key_v = None
110- val_v = None
111- min_k = getattr (mp , "minimum_keys" , None )
112- max_k = getattr (mp , "maximum_keys" , None )
113- for attr in ("_key_validator" , "key_validator" ):
114- key_v = getattr (mp , attr , None ) or key_v
115- for attr in ("_value_validator" , "value_validator" ):
116- val_v = getattr (mp , attr , None ) or val_v
117- if key_v is None or val_v is None :
118- raise TypeError ("Unsupported StrictYAML MapPattern internals." )
119- return key_v , val_v , min_k , max_k
120-
121-
122- def strictyaml_to_strategy (
123- validator : Any , * , default_text_alphabet = st .characters (), default_max_list = 5
124- ):
125- """
126- Convert a StrictYAML validator into a Hypothesis strategy that yields
127- *Python data structures* which conform to the schema.
128- """
129- name = _classname (validator )
130-
131- if isinstance (validator , Str ):
132- return st .text (alphabet = default_text_alphabet )
133-
134- if isinstance (validator , Int ):
135- return st .integers ()
136-
137- if isinstance (validator , Float ):
138- return st .floats (allow_nan = False , allow_infinity = False )
139-
140- if isinstance (validator , Bool ):
141- return st .booleans ()
142-
143- if isinstance (validator , Enum ):
144- values = _enum_values (validator )
145- return st .sampled_from (values )
146-
147- if isinstance (validator , Regex ):
148- pattern = _regex_pattern (validator )
149- return st .from_regex (pattern , fullmatch = True )
150-
151- if isinstance (validator , Seq ):
152- item_v = None
153- for attr in ("_validator" , "validator" , "_item_validator" , "item_validator" ):
154- item_v = getattr (validator , attr , None ) or item_v
155- if item_v is None :
156- raise TypeError (
157- "Unsupported StrictYAML Seq internals; cannot find item validator."
158- )
159- return st .lists (
160- strictyaml_to_strategy (
161- item_v ,
162- default_text_alphabet = default_text_alphabet ,
163- default_max_list = default_max_list ,
164- ),
165- min_size = 1 ,
166- max_size = default_max_list ,
167- )
168-
169- if isinstance (validator , EmptyList ):
170- return st .just ([])
171-
172- if isinstance (validator , Map ):
173- items = _get_map_items (validator )
174- required : Dict [str , Any ] = {}
175- optional : Dict [str , Any ] = {}
176-
177- for raw_key , val_validator in items .items ():
178- key_name , is_opt = _unwrap_optional_key (raw_key )
179- if is_opt :
180- optional [key_name ] = strictyaml_to_strategy (
181- val_validator ,
182- default_text_alphabet = default_text_alphabet ,
183- default_max_list = default_max_list ,
184- )
185- else :
186- required [key_name ] = strictyaml_to_strategy (
187- val_validator ,
188- default_text_alphabet = default_text_alphabet ,
189- default_max_list = default_max_list ,
190- )
191-
192- base = st .fixed_dictionaries (required )
193-
194- def with_optional (base_dict : Dict [str , Any ]):
195- if not optional :
196- return st .just (base_dict )
197- opt_kv_strats = [st .tuples (st .just (k ), s ) for k , s in optional .items ()]
198-
199- chosen = st .lists (st .one_of (* opt_kv_strats ), unique_by = lambda kv : kv [0 ])
200- return chosen .map (lambda kvs : {** base_dict , ** dict (kvs )})
201-
202- return base .flatmap (with_optional )
203-
204- if isinstance (validator , MapPattern ):
205- key_v , val_v , min_k , max_k = _mappattern_parts (validator )
206- key_strat = strictyaml_to_strategy (
207- key_v ,
208- default_text_alphabet = default_text_alphabet ,
209- default_max_list = default_max_list ,
210- )
211- val_strat = strictyaml_to_strategy (
212- val_v ,
213- default_text_alphabet = default_text_alphabet ,
214- default_max_list = default_max_list ,
215- )
216-
217- return st .dictionaries (
218- keys = key_strat ,
219- values = val_strat ,
220- min_size = min_k or 0 ,
221- max_size = max_k or default_max_list ,
222- )
223-
224- if _classname (validator ) in ("OrValidator" , "Or" ):
225- children = None
226-
227- for attr in ("validators" , "_validators" , "choices" , "_choices" ):
228- vs = getattr (validator , attr , None )
229- if isinstance (vs , (list , tuple )) and len (vs ) > 0 :
230- children = list (vs )
231- break
232-
233- if children is None :
234- left = None
235- right = None
236- for la in ("_a" , "a" , "_left" , "left" , "_lhs" , "lhs" , "_validator_a" ):
237- if getattr (validator , la , None ) is not None :
238- left = getattr (validator , la )
239- break
240- for ra in ("_b" , "b" , "_right" , "right" , "_rhs" , "rhs" , "_validator_b" ):
241- if getattr (validator , ra , None ) is not None :
242- right = getattr (validator , ra )
243- break
244- if left is not None and right is not None :
245- children = [left , right ]
246-
247- if not children :
248- raise TypeError (
249- "Unsupported StrictYAML OrValidator internals; no children found."
250- )
251-
252- branch_strats = [
253- strictyaml_to_strategy (
254- c ,
255- default_text_alphabet = default_text_alphabet ,
256- default_max_list = default_max_list ,
257- )
258- for c in children
259- ]
260- return st .one_of (branch_strats )
261-
262- if isinstance (validator , SyAny ):
263- leaf = st .one_of (
264- st .booleans (),
265- st .integers (),
266- st .floats (allow_nan = False , allow_infinity = False ),
267- st .text (),
268- )
269- return st .recursive (
270- leaf ,
271- lambda inner : st .one_of (
272- st .lists (inner , max_size = 3 ),
273- st .dictionaries (st .text (), inner , max_size = 3 ),
274- ),
275- max_leaves = 10 ,
276- )
277-
278- if isinstance (validator , EmptyNone ):
279- return st .none ()
280-
281- # If we reach here, add more mappings (e.g., Decimal, Datetime, Email, etc.) as needed.
282- raise NotImplementedError (
283- f"No strategy mapping implemented for StrictYAML validator: { name } "
284- )
114+ remotes_seq = st .none () | st .lists (remote_entry , min_size = 1 , max_size = 4 )
115+ projects_seq = st .lists (project_entry , min_size = 1 , max_size = 6 )
116+
117+ manifest_strategy = st .builds (
118+ lambda version , remotes , projects : {
119+ "manifest" : {
120+ "version" : version ,
121+ ** ({"remotes" : remotes } if remotes is not None else {}),
122+ "projects" : projects ,
123+ }
124+ },
125+ version = SAFE_NUMBER ,
126+ remotes = remotes_seq ,
127+ projects = projects_seq ,
128+ )
285129
286130
287131def validate_with_strictyaml (data : Any , yaml_schema : Any ) -> None :
@@ -292,17 +136,14 @@ def validate_with_strictyaml(data: Any, yaml_schema: Any) -> None:
292136 as_document (data , yaml_schema ) # will raise YAMLSerializationError on mismatch
293137
294138
295- data_strategy = strictyaml_to_strategy (schema )
296-
297-
298- @given (data_strategy )
139+ @given (manifest_strategy )
299140def test_data_conforms_to_schema (data ):
300141 """Validate by attempting to serialize via StrictYAML."""
301142 # If data violates the schema, this raises and Hypothesis will shrink to a minimal counterexample.
302143 validate_with_strictyaml (data , schema )
303144
304145
305- @given (data_strategy )
146+ @given (manifest_strategy )
306147def test_manifest_can_be_created (data ):
307148 """Validate by attempting to construct a Manifest."""
308149 try :
@@ -311,7 +152,7 @@ def test_manifest_can_be_created(data):
311152 pass
312153
313154
314- @given (data_strategy )
155+ @given (manifest_strategy )
315156def test_check (data ):
316157 """Validate check comand."""
317158 with suppress (DfetchFatalException ):
@@ -322,7 +163,7 @@ def test_check(data):
322163 run (["check" ])
323164
324165
325- @given (data_strategy )
166+ @given (manifest_strategy )
326167def test_update (data ):
327168 """Validate update comand."""
328169 with suppress (DfetchFatalException ):
@@ -337,7 +178,7 @@ def test_update(data):
337178
338179 settings .load_profile ("manual" )
339180
340- example = data_strategy .example ()
181+ example = manifest_strategy .example ()
341182 print ("One generated example:\n " , example )
342183
343184 # Show the YAML StrictYAML would emit for the example:
0 commit comments