11#!/usr/bin/env python
22"""
3- Test runner for JSON-LD.
3+ Test runner for the JSON-LD test-suite.
4+
5+ This module provides a small command-line test harness used to execute the
6+ JSON-LD test manifests that accompany the project and to produce an EARL
7+ (Evaluation and Report Language) report summarizing results.
8+
9+ Behavior and features
10+ - Loads one or more JSON-LD test manifest files (or directories containing a
11+ `manifest.jsonld`) and constructs a `unittest.TestSuite` from the entries.
12+ - Supports running tests from the local test directories bundled with the
13+ repository or from manifests passed on the command line.
14+ - Uses the `pyld` library under test to run each test case (expand, compact,
15+ frame, normalize, to_rdf, etc.), then compares results against expected
16+ outputs. Comparison is order-insensitive where appropriate.
17+ - Can write an EARL report using `--earl <file>` for CI / interoperability
18+ with W3C testing tools.
19+
20+ Usage
21+ - Run the script directly: `python tests/runtests.py [MANIFEST_OR_DIR ...]`
22+ - Common options (see `-h` for full list):
23+ - `-e, --earl <file>` : write an EARL report to `<file>`
24+ - `-b, --bail` : stop at the first failing test
25+ - `-l, --loader` : choose the network loader (`requests` or `aiohttp`)
26+ - `-n, --number` : focus on tests containing the given test identifier
27+ - `-v, --verbose` : print verbose test data
28+
29+ Key classes and functions
30+ - `TestRunner`: command-line entrypoint; builds the root manifest and runs the
31+ test suite.
32+ - `Manifest`: loads a manifest document and converts its entries into
33+ `unittest.TestSuite` / `Test` instances.
34+ - `Test` (subclass of `unittest.TestCase`): encapsulates execution and
35+ verification logic for a single JSON-LD test case.
36+ - Utility helpers: `read_json`, `read_file`, `create_document_loader`, and
37+ `equalUnordered` (used for order-insensitive comparisons).
438
539.. module:: runtests
6- :synopsis: Test harness for pyld
40+ :synopsis: Test harness for pyld
741
842.. moduleauthor:: Dave Longley
943.. moduleauthor:: Olaf Conradi <olaf@conradi.org>
3569 'https://github.com/json-ld/normalization/tests'
3670]
3771
72+ # `LOCAL_BASES` lists remote bases used by the official JSON-LD test
73+ # repositories. When a test refers to a URL starting with one of these
74+ # bases the runner attempts to map that URL to a local file in the
75+ # test-suite tree (when possible) so tests can be run offline.
76+
3877class TestRunner (unittest .TextTestRunner ):
3978 """
4079 Loads test manifests and runs tests.
@@ -44,7 +83,9 @@ def __init__(self, stream=sys.stderr, descriptions=True, verbosity=1):
4483 unittest .TextTestRunner .__init__ (
4584 self , stream , descriptions , verbosity )
4685
47- # command line args
86+ # The runner uses an ArgumentParser to accept a list of manifests or
87+ # test directories and several runner-specific flags (e.g. which
88+ # document loader to use or whether to bail on failure).
4889 self .options = {}
4990 self .parser = ArgumentParser ()
5091
@@ -82,6 +123,11 @@ def main(self):
82123 elif self .options .loader == 'aiohttp' :
83124 jsonld ._default_document_loader = jsonld .aiohttp_document_loader ()
84125
126+ # The document loader drives how remote HTTP documents are fetched.
127+ # Tests can choose to run using the 'requests' based loader or the
128+ # 'aiohttp' async loader; here we select the default based on the
129+ # CLI option.
130+
85131 # config runner
86132 self .failfast = self .options .bail
87133
@@ -143,6 +189,9 @@ def main(self):
143189 global ROOT_MANIFEST_DIR
144190 #ROOT_MANIFEST_DIR = os.path.dirname(root_manifest['filename'])
145191 ROOT_MANIFEST_DIR = root_manifest ['filename' ]
192+ # Build a Manifest object from the root manifest structure. The
193+ # Manifest will recursively load manifests and produce a
194+ # `unittest.TestSuite` containing all discovered tests.
146195 suite = Manifest (root_manifest , root_manifest ['filename' ]).load ()
147196
148197 # run tests
@@ -189,16 +238,29 @@ def load(self):
189238 if is_jsonld_type (entry , 'mf:Manifest' ):
190239 self .suite = unittest .TestSuite (
191240 [self .suite , Manifest (entry , filename ).load ()])
241+ # If the entry is itself a manifest, recurse into it and
242+ # append its TestSuite. This mirrors the structure of the
243+ # W3C test manifests where manifests can include other
244+ # manifests via 'entries' or 'sequence'.
192245 # don't add tests that are not focused
193246
194247 # assume entry is a test
195248 elif not ONLY_IDENTIFIER or ONLY_IDENTIFIER in entry ['@id' ]:
196249 self .suite .addTest (Test (self , entry , filename ))
197250
251+ # For simple test entries we construct a `Test` object which
252+ # wraps the execution and assertion logic for that test case.
253+
198254 return self .suite
199255
200256
201257class Test (unittest .TestCase ):
258+ """
259+ # A Test instance stores the manifest and test description (as
260+ # loaded from the JSON-LD manifest). The boolean flags below are
261+ # used to distinguish positive/negative/syntax tests so the
262+ # runner knows whether an exception is the expected outcome.
263+ """
202264 def __init__ (self , manifest , data , filename ):
203265 unittest .TestCase .__init__ (self )
204266 #self.maxDiff = None
@@ -263,6 +325,10 @@ def setUp(self):
263325 os .path .basename (str .replace (manifest .filename , '.jsonld' , '' )) + data ['@id' ])
264326 self .base = self .manifest .data ['baseIri' ] + data ['input' ]
265327
328+ # When manifests define a `baseIri` the runner patches the test
329+ # `@id` and computes a `base` URL used by the document loader so
330+ # relative references are resolved consistently during testing.
331+
266332 # skip based on id regular expression
267333 skip_id_re = test_info .get ('skip' , {}).get ('idRegex' , [])
268334 for regex in skip_id_re :
@@ -300,6 +366,11 @@ def setUp(self):
300366 if re .match (regex , data .get ('@id' , data .get ('id' , '' ))):
301367 data ['runLocal' ] = True
302368
369+ # Tests listed under 'runLocal' are forced to use the local file
370+ # loader variant rather than fetching remote URLs. This is useful
371+ # for reproducing the official test-suite behavior without network
372+ # access.
373+
303374 def runTest (self ):
304375 data = self .data
305376 global TEST_TYPES
@@ -315,8 +386,20 @@ def runTest(self):
315386 else :
316387 expect = read_test_property (self ._get_expect_property ())(self )
317388
389+ # The following try/except handles three primary scenarios:
390+ # - Positive tests: compute the result and compare to expected JSON
391+ # (order-insensitive where appropriate).
392+ # - Negative tests: assert that the library raises the expected
393+ # JSON-LD error code.
394+ # - Pending tests: tests expected to fail are marked 'pending' and
395+ # their unexpected success is reported specially.
396+
318397 try :
319398 result = getattr (jsonld , fn )(* params )
399+ # Invoke the tested pyld function (e.g. `expand`, `compact`,
400+ # `normalize`). `fn` is the function name and `params` is a
401+ # list of callables that are invoked with `self` to produce
402+ # the actual arguments (this allows lazy file loading).
320403 if self .is_negative and not self .pending :
321404 raise AssertionError ('Expected an error; one was not raised' )
322405 if self .is_syntax and not self .pending :
@@ -382,6 +465,11 @@ def runTest(self):
382465
383466# Compare values with order-insensitive array tests
384467def equalUnordered (result , expect ):
468+ """
469+ `equalUnordered` implements a simple structural equivalence check that
470+ ignores ordering in lists. It is used to compare JSON-LD results where
471+ arrays are considered unordered by the test-suite semantics.
472+ """
385473 if isinstance (result , list ) and isinstance (expect , list ):
386474 return (len (result ) == len (expect ) and
387475 all (any (equalUnordered (v1 , v2 ) for v2 in expect ) for v1 in result ))
@@ -391,6 +479,8 @@ def equalUnordered(result, expect):
391479 else :
392480 return (result == expect )
393481
482+
483+
394484def is_jsonld_type (node , type_ ):
395485 node_types = []
396486 node_types .extend (get_jsonld_values (node , '@type' ))
@@ -400,6 +490,20 @@ def is_jsonld_type(node, type_):
400490
401491
402492def get_jsonld_values (node , property ):
493+ """
494+ Safely extract a (possibly multi-valued) property from a JSON-LD node.
495+
496+ The JSON-LD manifests sometimes use single values or lists for the same
497+ properties. This helper returns a list in either case so callers can
498+ uniformly iterate over the returned value.
499+
500+ Args:
501+ node: dict-like JSON-LD node.
502+ property: property name to extract (string).
503+
504+ Returns:
505+ A list of values for the property (empty list if property missing).
506+ """
403507 rval = []
404508 if property in node :
405509 rval = node [property ]
@@ -409,6 +513,13 @@ def get_jsonld_values(node, property):
409513
410514
411515def get_jsonld_error_code (err ):
516+ """
517+ Walk a JsonLdError chain to extract the most specific error `code`.
518+
519+ Many pyld error types wrap a cause. This helper attempts to return the
520+ structured `code` attribute from a `jsonld.JsonLdError` (if present),
521+ otherwise it falls back to stringifying the exception.
522+ """
412523 if isinstance (err , jsonld .JsonLdError ):
413524 if err .code :
414525 return err .code
@@ -418,11 +529,22 @@ def get_jsonld_error_code(err):
418529
419530
420531def read_json (filename ):
532+ """Read and parse a JSON file from `filename`.
533+
534+ Returns the parsed Python object.
535+ """
421536 with open (filename ) as f :
422537 return json .load (f )
423538
424539
425540def read_file (filename ):
541+ """Read a file and return its contents as text.
542+
543+ This wrapper ensures consistent text handling across Python 2/3 by
544+ decoding bytes for older Python versions. In the current project we
545+ expect Python 3, but the compatibility guard is kept to match the
546+ original test-runner behavior.
547+ """
426548 with open (filename ) as f :
427549 if sys .version_info [0 ] >= 3 :
428550 return f .read ()
@@ -431,6 +553,14 @@ def read_file(filename):
431553
432554
433555def read_test_url (property ):
556+ """
557+ Return a callable that reads a URL-like property from a test entry.
558+
559+ Some test entries store input locations as relative paths resolved
560+ against the manifest's `baseIri`. This factory returns a function that
561+ accepts a `Test` instance and returns the fully-resolved URL (or
562+ `None` if the property is missing).
563+ """
434564 def read (test ):
435565 if property not in test .data :
436566 return None
@@ -442,6 +572,15 @@ def read(test):
442572
443573
444574def read_test_property (property ):
575+ """
576+ Return a callable that reads a test-local property and returns either
577+ parsed JSON (for `.jsonld`) or raw text.
578+
579+ The returned function accepts a `Test` instance and resolves the
580+ filename relative to the test's directory. If the file ends with
581+ `.jsonld` it is parsed as JSON; otherwise the raw file contents are
582+ returned.
583+ """
445584 def read (test ):
446585 if property not in test .data :
447586 return None
@@ -454,6 +593,15 @@ def read(test):
454593
455594
456595def create_test_options (opts = None ):
596+ """
597+ Factory returning a function that builds options for a pyld API call.
598+
599+ The returned callable accepts a `Test` instance and produces the
600+ options dictionary consumed by functions such as `expand`/`compact`.
601+ It merges explicit test `option` values with any additional `opts`
602+ passed to the factory, wires in the test-specific `documentLoader`,
603+ and resolves `expandContext` files when present.
604+ """
457605 def create (test ):
458606 http_options = ['contentType' , 'httpLink' , 'httpStatus' , 'redirectTo' ]
459607 test_options = test .data .get ('option' , {})
@@ -471,8 +619,18 @@ def create(test):
471619
472620
473621def create_document_loader (test ):
622+ """
623+ create_document_loader returns a callable compatible with the JSON-LD
624+ API's document loader interface. The returned `local_loader` will
625+ decide whether to load a URL from the local test-tree (mapping
626+ `LOCAL_BASES` to local files) or delegate to the normal network
627+ loader. This enables deterministic tests without requiring network
628+ access for suite-hosted resources.
629+ """
474630 loader = jsonld .get_document_loader ()
475631
632+
633+
476634 def is_test_suite_url (url ):
477635 return any (url .startswith (base ) for base in LOCAL_BASES )
478636
@@ -559,6 +717,14 @@ def local_loader(url, headers):
559717
560718
561719class EarlTestResult (TextTestResult ):
720+ """
721+ A `TextTestResult` subclass that records EARL assertions as tests run.
722+
723+ This result object forwards normal test outcome bookkeeping to the
724+ base `TextTestResult` and additionally records each assertion in an
725+ `EarlReport` instance so a machine-readable report can be emitted at
726+ the end of a test run.
727+ """
562728 def __init__ (self , stream , descriptions , verbosity ):
563729 TextTestResult .__init__ (self , stream , descriptions , verbosity )
564730 self .report = EarlReport ()
@@ -585,11 +751,16 @@ class EarlReport():
585751 """
586752
587753 def __init__ (self ):
754+ # Load package metadata (version) from the library's __about__.py
588755 about = {}
589756 with open (os .path .join (
590757 os .path .dirname (__file__ ), '..' , 'lib' , 'pyld' , '__about__.py' )) as fp :
591758 exec (fp .read (), about )
759+ # Timestamp used for test results
592760 self .now = datetime .datetime .utcnow ().replace (microsecond = 0 )
761+ # Build the base EARL report structure. The report is a JSON-LD
762+ # document describing the project and the assertions made about
763+ # test outcomes.
593764 self .report = {
594765 '@context' : {
595766 'doap' : 'http://usefulinc.com/ns/doap#' ,
@@ -640,6 +811,8 @@ def __init__(self):
640811 }
641812
642813 def add_assertion (self , test , success ):
814+ # Append an EARL assertion describing a single test outcome. The
815+ # `earl:outcome` is either `earl:passed` or `earl:failed`.
643816 self .report ['subjectOf' ].append ({
644817 '@type' : 'earl:Assertion' ,
645818 'earl:assertedBy' : self .report ['doap:developer' ]['@id' ],
@@ -654,6 +827,7 @@ def add_assertion(self, test, success):
654827 return self
655828
656829 def write (self , filename ):
830+ # Serialize the EARL report as pretty-printed JSON-LD.
657831 with open (filename , 'w' ) as f :
658832 f .write (json .dumps (self .report , indent = 2 ))
659833 f .close ()
0 commit comments