├── .github └── workflows │ └── pre-commit.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── examples └── test_examples.py ├── flake.lock ├── flake.nix ├── pyproject.toml ├── pytest.ini ├── shell.nix ├── src └── pytest_patterns │ ├── __about__.py │ ├── __init__.py │ └── plugin.py └── tests ├── fixtures └── ical-ordering.ical ├── test_basics.py └── test_edge_cases.py /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master, main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | - uses: pre-commit/action@v3.0.0 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | .coverage 4 | report.xml 5 | *.sublime* 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^secrets/|^appenv$ 2 | repos: 3 | - hooks: 4 | - id: detect-private-key 5 | - id: check-added-large-files 6 | - exclude: "(?x)^(\n secrets/|environments/.*/secret.*|\n .*\\.patch\n)$\n" 7 | id: trailing-whitespace 8 | - exclude: "(?x)^(\n environments/.*/secret.*|\n .*\\.patch\n)$\n" 9 | id: end-of-file-fixer 10 | - exclude: "(?x)^(\n environments/.*/secret.*|\n .*\\.patch\n)$\n" 11 | id: check-yaml 12 | - exclude: "(?x)^(\n environments/.*/secret.*|\n .*\\.patch\n)$\n" 13 | id: check-json 14 | - exclude: "(?x)^(\n environments/.*/secret.*|\n .*\\.patch\n)$\n" 15 | id: check-xml 16 | - exclude: "(?x)^(\n environments/.*/secret.*|\n .*\\.patch\n)$\n" 17 | id: check-toml 18 | repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v4.5.0 20 | - hooks: 21 | - args: 22 | - --profile 23 | - black 24 | - --filter-files 25 | id: isort 26 | name: isort (python) 27 | repo: https://github.com/pycqa/isort 28 | rev: 5.12.0 29 | - hooks: 30 | - id: black 31 | repo: https://github.com/psf/black 32 | rev: 23.11.0 33 | - repo: https://github.com/astral-sh/ruff-pre-commit 34 | rev: v0.1.6 35 | hooks: 36 | - id: ruff 37 | args: [--fix, --exit-non-zero-on-fix] 38 | - repo: https://github.com/tox-dev/pyproject-fmt 39 | rev: "1.5.2" 40 | hooks: 41 | - id: pyproject-fmt 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `pytest-patterns` is a plugin for pytest that provides a pattern matching 2 | engine optimized for testing. 3 | 4 | Benefits: 5 | 6 | * provides easy to read reporting for complex patterns in long strings (1000+ lines) 7 | * assists in reasoning which patterns have matched or not matched – and why 8 | * can deal with ambiguity, optional and repetitive matches, intermingled 9 | output from non-deterministic concurrent processes 10 | * helps writing patterns that are easy to read, easy to maintain and 11 | easy to adjust in the face of unstable outputs 12 | * helps reusing patterns using pytest fixtures 13 | 14 | Long term goals: 15 | 16 | Support testing of CLI output, as well as HTML and potentially typical text/* 17 | types like JSON, YAML, and others. 18 | 19 | # Examples 20 | 21 | Try and play around using the examples in the source repository. The 22 | examples fail on purpose, because the failure reporting is the most important 23 | and useful part – aside from making it easier to write the assertions. 24 | 25 | ```shell 26 | $ nix develop 27 | $ hatch run test examples -vv 28 | ``` 29 | 30 | # Basic API 31 | 32 | ## `optional` matches and variability with the ellipsis `...` 33 | 34 | If you want to test a complex string, start by pulling in the `patterns` fixture 35 | and create a named pattern that accepts any number of lines that contain 36 | the word "better": 37 | 38 | ```python 39 | import this 40 | 41 | zen = "".join([this.d.get(c, c) for c in this.s]) 42 | 43 | 44 | def test_zen(patterns): 45 | p = patterns.better_things 46 | p.optional("...better...") 47 | assert p == zen 48 | ``` 49 | 50 | This will not give us a green bar, as the pattern does match some lines, but 51 | some lines were not matched and thus considered **unexpected**: 52 | 53 | ``` 54 | 🟢=EXPECTED | ⚪️=OPTIONAL | 🟡=UNEXPECTED | 🔴=REFUSED/UNMATCHED 55 | 56 | Here is the string that was tested: 57 | 58 | 🟡 | The Zen of Python, by Tim Peters 59 | 🟡 | 60 | ⚪️ better_things | Beautiful is better than ugly. 61 | ⚪️ better_things | Explicit is better than implicit. 62 | ⚪️ better_things | Simple is better than complex. 63 | ⚪️ better_things | Complex is better than complicated. 64 | ⚪️ better_things | Flat is better than nested. 65 | ⚪️ better_things | Sparse is better than dense. 66 | 🟡 | Readability counts. 67 | 🟡 | Special cases aren't special enough to break the rules. 68 | 🟡 | Although practicality beats purity. 69 | 🟡 | Errors should never pass silently. 70 | 🟡 | Unless explicitly silenced. 71 | 🟡 | In the face of ambiguity, refuse the temptation to guess. 72 | 🟡 | There should be one-- and preferably only one --obvious way to do it. 73 | 🟡 | Although that way may not be obvious at first unless you're Dutch. 74 | ⚪️ better_things | Now is better than never. 75 | ⚪️ better_things | Although never is often better than *right* now. 76 | 🟡 | If the implementation is hard to explain, it's a bad idea. 77 | 🟡 | If the implementation is easy to explain, it may be a good idea. 78 | 🟡 | Namespaces are one honking great idea -- let's do more of those! 79 | ``` 80 | 81 | The report highlights which lines were matched (and which pattern caused the 82 | match) and are fine the way they are. Optional matches are displayed with a 83 | white circle (⚪️). The report also highlights those lines that weren't matched 84 | and marked with a yellow circle (🟡). 85 | 86 | We know the Zen is correct the way it is, so lets use more of the API to continue 87 | completing the pattern. 88 | 89 | ## `continous` matches 90 | 91 | Lets use the `continuous` match which requires lines to come both in a specific order 92 | and must not be interrupted by other lines. We create a new named pattern and 93 | `merge` it with the previous pattern: 94 | 95 | ```python 96 | def test_zen(patterns): 97 | ... 98 | 99 | p = patterns.conclusio 100 | p.continous(""" 101 | If the implementation is hard to explain, it's a bad idea. 102 | If the implementation is easy to explain, it may be a good idea. 103 | Namespaces are one honking great idea -- let's do more of those! 104 | """) 105 | full_pattern = patterns.full 106 | full_pattern.merge("better_things", "conclusio") 107 | 108 | assert full_pattern == zen 109 | ``` 110 | 111 | This gets us a bit further: 112 | 113 | ``` 114 | 🟢=EXPECTED | ⚪️=OPTIONAL | 🟡=UNEXPECTED | 🔴=REFUSED/UNMATCHED 115 | 116 | Here is the string that was tested: 117 | 118 | 🟡 | The Zen of Python, by Tim Peters 119 | 🟡 | 120 | ⚪️ better_things | Beautiful is better than ugly. 121 | ⚪️ better_things | Explicit is better than implicit. 122 | ⚪️ better_things | Simple is better than complex. 123 | ⚪️ better_things | Complex is better than complicated. 124 | ⚪️ better_things | Flat is better than nested. 125 | ⚪️ better_things | Sparse is better than dense. 126 | 🟡 | Readability counts. 127 | 🟡 | Special cases aren't special enough to break the rules. 128 | 🟡 | Although practicality beats purity. 129 | 🟡 | Errors should never pass silently. 130 | 🟡 | Unless explicitly silenced. 131 | 🟡 | In the face of ambiguity, refuse the temptation to guess. 132 | 🟡 | There should be one-- and preferably only one --obvious way to do it. 133 | 🟡 | Although that way may not be obvious at first unless you're Dutch. 134 | ⚪️ better_things | Now is better than never. 135 | ⚪️ better_things | Although never is often better than *right* now. 136 | 🟢 conclusio | If the implementation is hard to explain, it's a bad idea. 137 | 🟢 conclusio | If the implementation is easy to explain, it may be a good idea. 138 | 🟢 conclusio | Namespaces are one honking great idea -- let's do more of those! 139 | ``` 140 | 141 | Note, that lines matched by `continuous` are highlighted in green as they are 142 | considered a stronger match than the `optional` ones. 143 | 144 | ## `in_order` matches 145 | 146 | There is still stuff missing. Lets make the test green by creating a match for 147 | all other lines using `in_order`, which expects the lines to come in the order 148 | given, but might be mixed in with other lines. 149 | 150 | ```python 151 | def test_zen(patterns): 152 | ... 153 | p = patterns.top_and_middle 154 | p.in_order( 155 | """ 156 | The Zen of Python, by Tim Peters 157 | 158 | Readability counts. 159 | Special cases aren't special enough to break the rules. 160 | Although practicality beats purity. 161 | Errors should never pass silently. 162 | Unless explicitly silenced. 163 | In the face of ambiguity, refuse the temptation to guess. 164 | There should be one-- and preferably only one --obvious way to do it. 165 | Although that way may not be obvious at first unless you're Dutch. 166 | """ 167 | ) 168 | full_pattern = patterns.full 169 | full_pattern.merge("top_and_middle", "better_things", "conclusio") 170 | 171 | assert full_pattern == zen 172 | ``` 173 | 174 | Shouldn't that have given us a green bar? I can still see a yellow circle there! 175 | 176 | ``` 177 | 🟢=EXPECTED | ⚪️=OPTIONAL | 🟡=UNEXPECTED | 🔴=REFUSED/UNMATCHED 178 | 179 | Here is the string that was tested: 180 | 181 | 🟢 top_and_middle | The Zen of Python, by Tim Peters 182 | 🟡 | 183 | ⚪️ better_things | Beautiful is better than ugly. 184 | ⚪️ better_things | Explicit is better than implicit. 185 | ⚪️ better_things | Simple is better than complex. 186 | ⚪️ better_things | Complex is better than complicated. 187 | ⚪️ better_things | Flat is better than nested. 188 | ⚪️ better_things | Sparse is better than dense. 189 | 🟢 top_and_middle | Readability counts. 190 | 🟢 top_and_middle | Special cases aren't special enough to break the rules. 191 | 🟢 top_and_middle | Although practicality beats purity. 192 | 🟢 top_and_middle | Errors should never pass silently. 193 | 🟢 top_and_middle | Unless explicitly silenced. 194 | 🟢 top_and_middle | In the face of ambiguity, refuse the temptation to guess. 195 | 🟢 top_and_middle | There should be one-- and preferably only one --obvious way to do it. 196 | 🟢 top_and_middle | Although that way may not be obvious at first unless you're Dutch. 197 | ⚪️ better_things | Now is better than never. 198 | ⚪️ better_things | Although never is often better than *right* now. 199 | 🟢 conclusio | If the implementation is hard to explain, it's a bad idea. 200 | 🟢 conclusio | If the implementation is easy to explain, it may be a good idea. 201 | 🟢 conclusio | Namespaces are one honking great idea -- let's do more of those! 202 | ``` 203 | 204 | ## Handling empty lines with the `` marker 205 | 206 | The previous pattern is not quite perfect because `pytest-patterns` has a 207 | special way to handle newlines both in patterns and in content. 208 | 209 | 1. In content that is tested we never implicitly accept any lines that were not 210 | specified, including empty lines. 211 | 212 | 2. However, in patterns empty lines are not significant to allow you to use them 213 | to make your patterns more readable by grouping lines visually. 214 | 215 | We get out of this by using the special marker `` in our patterns 216 | which will match both literally for lines containing `` and which 217 | are empty lines: 218 | 219 | ```python 220 | def test_zen(patterns): 221 | ... 222 | p = patterns.top_and_middle 223 | p.optional("") 224 | p.in_order( 225 | """ 226 | The Zen of Python, by Tim Peters 227 | 228 | Readability counts. 229 | Special cases aren't special enough to break the rules. 230 | Although practicality beats purity. 231 | Errors should never pass silently. 232 | Unless explicitly silenced. 233 | In the face of ambiguity, refuse the temptation to guess. 234 | There should be one-- and preferably only one --obvious way to do it. 235 | Although that way may not be obvious at first unless you're Dutch. 236 | """ 237 | ) 238 | ... 239 | assert full_pattern == zen 240 | ``` 241 | 242 | And we finally get a green bar: 243 | 244 | ``` 245 | examples/test_examples.py::test_zen_5_1 PASSED 246 | ``` 247 | 248 | ## `refused` matches 249 | 250 | Up until now we only created matches that allowed us to write down 251 | things that we expect. However, we can also explicitly refuse lines - for example any line 252 | containing the word "should": 253 | 254 | ```python 255 | def test_zen(patterns): 256 | ... 257 | p = patterns.no_should 258 | p.refused("...should...") 259 | 260 | full_pattern = patterns.full 261 | full_pattern.merge( 262 | "no_should", "top_and_middle", "better_things", "conclusio" 263 | ) 264 | 265 | assert full_pattern == zen 266 | ``` 267 | 268 | This is were `pytest-patterns` really shines. We now can quickly see which parts 269 | of our output is OK and which isn't and why: 270 | 271 | ``` 272 | 🟢=EXPECTED | ⚪️=OPTIONAL | 🟡=UNEXPECTED | 🔴=REFUSED/UNMATCHED 273 | 274 | Here is the string that was tested: 275 | 276 | 🟢 top_and_middle | The Zen of Python, by Tim Peters 277 | 🟡 | 278 | ⚪️ better_things | Beautiful is better than ugly. 279 | ⚪️ better_things | Explicit is better than implicit. 280 | ⚪️ better_things | Simple is better than complex. 281 | ⚪️ better_things | Complex is better than complicated. 282 | ⚪️ better_things | Flat is better than nested. 283 | ⚪️ better_things | Sparse is better than dense. 284 | 🟢 top_and_middle | Readability counts. 285 | 🟢 top_and_middle | Special cases aren't special enough to break the rules. 286 | 🟢 top_and_middle | Although practicality beats purity. 287 | 🔴 no_should | Errors should never pass silently. 288 | 🟢 top_and_middle | Unless explicitly silenced. 289 | 🟢 top_and_middle | In the face of ambiguity, refuse the temptation to guess. 290 | 🔴 no_should | There should be one-- and preferably only one --obvious way to do it. 291 | 🟢 top_and_middle | Although that way may not be obvious at first unless you're Dutch. 292 | ⚪️ better_things | Now is better than never. 293 | ⚪️ better_things | Although never is often better than *right* now. 294 | 🟢 conclusio | If the implementation is hard to explain, it's a bad idea. 295 | 🟢 conclusio | If the implementation is easy to explain, it may be a good idea. 296 | 🟢 conclusio | Namespaces are one honking great idea -- let's do more of those!` 297 | 298 | These are the matched refused lines: 299 | 300 | 🔴 no_should | ...should... 301 | 🔴 no_should | ...should... 302 | 303 | ``` 304 | 305 | ## Re-using patterns with fixtures 306 | 307 | Lastly, we'd like to re-use patterns in multiple tests. Let's refactor the 308 | current patterns into a separate fixture that can be activated as needed: 309 | 310 | ```python 311 | import pytest 312 | import this 313 | 314 | zen = "".join([this.d.get(c, c) for c in this.s]) 315 | 316 | 317 | @pytest.fixture 318 | def zen_patterns(patterns): 319 | p = patterns.better_things 320 | p.optional("...better...") 321 | 322 | p = patterns.conclusio 323 | p.continuous( 324 | """\ 325 | If the implementation is hard to explain, it's a bad idea. 326 | If the implementation is easy to explain, it may be a good idea. 327 | Namespaces are one honking great idea -- let's do more of those! 328 | """ 329 | ) 330 | 331 | p = patterns.top_and_middle 332 | p.in_order( 333 | """ 334 | The Zen of Python, by Tim Peters 335 | 336 | Readability counts. 337 | Special cases aren't special enough to break the rules. 338 | Although practicality beats purity. 339 | Errors should never pass silently. 340 | Unless explicitly silenced. 341 | In the face of ambiguity, refuse the temptation to guess. 342 | There should be one-- and preferably only one --obvious way to do it. 343 | Although that way may not be obvious at first unless you're Dutch. 344 | """ 345 | ) 346 | 347 | 348 | def test_zen(patterns, zen_patterns): 349 | full_pattern = patterns.full 350 | full_pattern.merge("better_things", "conclusio", "top_and_middle") 351 | 352 | assert full_pattern == zen 353 | ``` 354 | 355 | ## Handling tabs and whitespace 356 | 357 | When copying and pasting output from commands its easy to turn tabs from an 358 | original source into spaces and then accidentally not aligning things right. 359 | 360 | As its more typical to not insert tabs in your code pytest-patterns converts 361 | tabs to spaces (properly aligned to 8 character stops as terminals render them): 362 | 363 | ```python 364 | def test_tabs_and_spaces(patterns): 365 | data = """ 366 | pre>\taligned text 367 | prefix>\tmore aligned text 368 | """ 369 | tabs = patterns.tabs 370 | tabs.in_order(""" 371 | pre> aligned text 372 | prefix> aligned text 373 | """) 374 | assert tabs == data 375 | ``` 376 | 377 | # Development 378 | 379 | 380 | ```shell 381 | $ pre-commit install 382 | $ nix develop 383 | $ hatch run test 384 | ``` 385 | 386 | 387 | # TODO 388 | 389 | * [ ] normalization feature 390 | 391 | -> json (+whitespace) 392 | -> python object causes serialization 393 | -> json object causes deserialization + reserialization (mit readable oder so) 394 | -> whitespace normalization 395 | 396 | -> html (+whitespace) 397 | -> parse html, then serialize in a normalized way 398 | -> whitespace normalization for both pattern and tested content 399 | 400 | -> whitespace (pattern and tested content) 401 | -> strip whitespace at beginning and end 402 | -> fold multiple spaces into single spaces (makes it harder to diagnose things) 403 | 404 | -> make tab replacement and format with control pictures optional. 405 | -> also, tab replacement only happens on the input line, not the test line because due to `...` we can't know where the tab will land 406 | 407 | * [ ] proper release process with tagging, version updates, etc. 408 | 409 | * [ ] Get coverage working correctly (https://pytest-cov.readthedocs.io/en/latest/plugins.html doesnt seem to help ...) 410 | 411 | * [ ] Get the project fully set up to make sense for interested parties and 412 | potential contributors. 413 | 414 | * [ ] optional reporting without colors 415 | 416 | * [ ] matrix builds for multiple python versions / use tox locally and in github action? 417 | 418 | * [ ] highlight whitespace (e.g. ) when reporting unmatched expected lines. this can be confusing if you see an "empty" line because you typoed e.g.: 419 | 420 | ``` 421 | outmigrate.optional( 422 | """ 423 | simplevm waiting interval=3 remaining=... 424 | simplevm check-staging-config result='none' 425 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 426 | simplevm migration-status mbps=... remaining='...' status='active' 427 | simplevm vm-destroy-kill-vm attempt=... subsystem='qemu' 428 | """ 429 | ) 430 | ``` 431 | 432 | Do you see it? There are four spaces on the last line which is now an expected line with four spaces ... 433 | 434 | This could also be improved by ignoring whitespace only lines (optionally?) 435 | 436 | 437 | # DONE 438 | 439 | 440 | * [x] coding style template 441 | 442 | * [x] Actual documentation 443 | 444 | * [x] differentiate between tolerated and expected in status reporting 445 | 446 | * [x] add avoided lines that must not appear 447 | 448 | * [x] allow patterns expectations/tolerations/... to have names and use those to mark up the report why things matched? 449 | 450 | * [T] DEBUG | .... 451 | * [X] MIGRATION | 452 | 453 | * [x] how to deal with HTML boilerplate -> use `optional("...")` 454 | 455 | * [x] add lines that must appear in order without being interrupted 456 | 457 | # Later 458 | 459 | * [ ] html normalization might want to include a feature to suppress reporting 460 | of certain lines (and just add `...` in the reporting output, e.g. if something 461 | fails do not report the owrap lines 462 | 463 | * [ ] add line numbers 464 | 465 | * [ ] report line numbers on matched avoidances 466 | 467 | * [ ] structlog integration 468 | 469 | * [ ] more comprehensive docs 470 | 471 | # Wording 472 | 473 | 474 | Matches on patterns are adjectives: 475 | 476 | * These lines must appear and they must be **continuous**. 477 | * These lines must appear and they must be **in order**. 478 | * These lines are **optional**. 479 | * If those lines appear they are **refused**. 480 | 481 | Modifiers to the pattern itself are verbs: 482 | 483 | * *Merge* the rules from those patterns into this one. 484 | * *Normalize* the input (and the rules) in this way.s 485 | -------------------------------------------------------------------------------- /examples/test_examples.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import this 3 | 4 | zen = "".join([this.d.get(c, c) for c in this.s]) 5 | 6 | 7 | def test_comprehensive_example(patterns): 8 | patterns.ordered_sample.in_order( 9 | """ 10 | This comes early on 11 | This comes later 12 | 13 | This comes even later 14 | And this should come last - but it doesn't 15 | """ 16 | ) 17 | 18 | patterns.optional_sample.optional( 19 | """ 20 | This is a heartbeat that can appear almost anywhere... 21 | """ 22 | ) 23 | patterns.continous_sample.continuous( 24 | """ 25 | This comes first (...) 26 | This comes second (...) 27 | This comes third (...) 28 | """ 29 | ) 30 | patterns.refused_sample.refused( 31 | """ 32 | ...error... 33 | """ 34 | ) 35 | 36 | full_pattern = patterns.full 37 | full_pattern.merge( 38 | "ordered_sample", 39 | "optional_sample", 40 | "continuous_sample", 41 | "refused_sample", 42 | ) 43 | assert ( 44 | full_pattern 45 | == """\ 46 | This comes early on 47 | This is a heartbeat that can appear almost anywhere 48 | This comes first (with variability) 49 | This comes second (also with variability) 50 | This comes third (more variability!) 51 | This line is an error :( 52 | This is a heartbeat that can appear almost anywhere (outside focus ranges) 53 | This comes later 54 | This comes even later 55 | """ 56 | ) 57 | 58 | 59 | def test_zen_1(patterns): 60 | p = patterns.better_things 61 | p.optional("...better...") 62 | assert p == zen 63 | 64 | 65 | def test_zen_2(patterns): 66 | p = patterns.better_things 67 | p.optional("...better...") 68 | 69 | p = patterns.conclusio 70 | p.continuous( 71 | """\ 72 | If the implementation is hard to explain, it's a bad idea. 73 | If the implementation is easy to explain, it may be a good idea. 74 | Namespaces are one honking great idea -- let's do more of those! 75 | """ 76 | ) 77 | full_pattern = patterns.full 78 | full_pattern.merge("better_things", "conclusio") 79 | 80 | assert full_pattern == zen 81 | 82 | 83 | def test_zen_3(patterns): 84 | p = patterns.better_things 85 | p.optional("...better...") 86 | 87 | p = patterns.conclusio 88 | p.continuous( 89 | """\ 90 | If the implementation is hard to explain, it's a bad idea. 91 | If the implementation is easy to explain, it may be a good idea. 92 | Namespaces are one honking great idea -- let's do more of those! 93 | """ 94 | ) 95 | 96 | p = patterns.top_and_middle 97 | p.in_order( 98 | """ 99 | The Zen of Python, by Tim Peters 100 | 101 | Readability counts. 102 | Special cases aren't special enough to break the rules. 103 | Although practicality beats purity. 104 | Errors should never pass silently. 105 | Unless explicitly silenced. 106 | In the face of ambiguity, refuse the temptation to guess. 107 | There should be one-- and preferably only one --obvious way to do it. 108 | Although that way may not be obvious at first unless you're Dutch. 109 | """ 110 | ) 111 | full_pattern = patterns.full 112 | full_pattern.merge("top_and_middle", "better_things", "conclusio") 113 | 114 | assert full_pattern == zen 115 | 116 | 117 | def test_zen_4(patterns): 118 | p = patterns.better_things 119 | p.optional("...better...") 120 | 121 | p = patterns.conclusio 122 | p.continuous( 123 | """\ 124 | If the implementation is hard to explain, it's a bad idea. 125 | If the implementation is easy to explain, it may be a good idea. 126 | Namespaces are one honking great idea -- let's do more of those! 127 | """ 128 | ) 129 | 130 | p = patterns.top_and_middle 131 | p.in_order( 132 | """ 133 | The Zen of Python, by Tim Peters 134 | 135 | Readability counts. 136 | Special cases aren't special enough to break the rules. 137 | Although practicality beats purity. 138 | Errors should never pass silently. 139 | Unless explicitly silenced. 140 | In the face of ambiguity, refuse the temptation to guess. 141 | There should be one-- and preferably only one --obvious way to do it. 142 | Although that way may not be obvious at first unless you're Dutch. 143 | """ 144 | ) 145 | full_pattern = patterns.full 146 | full_pattern.merge("top_and_middle", "better_things", "conclusio") 147 | 148 | assert full_pattern == zen 149 | 150 | 151 | def test_zen_5(patterns): 152 | p = patterns.better_things 153 | p.optional("...better...") 154 | 155 | p = patterns.conclusio 156 | p.continuous( 157 | """\ 158 | If the implementation is hard to explain, it's a bad idea. 159 | If the implementation is easy to explain, it may be a good idea. 160 | Namespaces are one honking great idea -- let's do more of those! 161 | """ 162 | ) 163 | 164 | p = patterns.top_and_middle 165 | p.in_order( 166 | """ 167 | The Zen of Python, by Tim Peters 168 | 169 | Readability counts. 170 | Special cases aren't special enough to break the rules. 171 | Although practicality beats purity. 172 | Errors should never pass silently. 173 | Unless explicitly silenced. 174 | In the face of ambiguity, refuse the temptation to guess. 175 | There should be one-- and preferably only one --obvious way to do it. 176 | Although that way may not be obvious at first unless you're Dutch. 177 | """ 178 | ) 179 | full_pattern = patterns.full 180 | full_pattern.merge("top_and_middle", "better_things", "conclusio") 181 | 182 | assert full_pattern == zen 183 | 184 | 185 | def test_zen_5_1(patterns): 186 | p = patterns.better_things 187 | p.optional("...better...") 188 | 189 | p = patterns.conclusio 190 | p.continuous( 191 | """\ 192 | If the implementation is hard to explain, it's a bad idea. 193 | If the implementation is easy to explain, it may be a good idea. 194 | Namespaces are one honking great idea -- let's do more of those! 195 | """ 196 | ) 197 | 198 | p = patterns.top_and_middle 199 | p.optional("") 200 | p.in_order( 201 | """ 202 | The Zen of Python, by Tim Peters 203 | 204 | Readability counts. 205 | Special cases aren't special enough to break the rules. 206 | Although practicality beats purity. 207 | Errors should never pass silently. 208 | Unless explicitly silenced. 209 | In the face of ambiguity, refuse the temptation to guess. 210 | There should be one-- and preferably only one --obvious way to do it. 211 | Although that way may not be obvious at first unless you're Dutch. 212 | """ 213 | ) 214 | full_pattern = patterns.full 215 | full_pattern.merge("top_and_middle", "better_things", "conclusio") 216 | 217 | assert full_pattern == zen 218 | 219 | 220 | def test_zen_6(patterns): 221 | p = patterns.better_things 222 | p.optional("...better...") 223 | 224 | p = patterns.conclusio 225 | p.continuous( 226 | """\ 227 | If the implementation is hard to explain, it's a bad idea. 228 | If the implementation is easy to explain, it may be a good idea. 229 | Namespaces are one honking great idea -- let's do more of those! 230 | """ 231 | ) 232 | 233 | p = patterns.top_and_middle 234 | p.optional("") 235 | p.in_order( 236 | """ 237 | The Zen of Python, by Tim Peters 238 | 239 | Readability counts. 240 | Special cases aren't special enough to break the rules. 241 | Although practicality beats purity. 242 | Errors should never pass silently. 243 | Unless explicitly silenced. 244 | In the face of ambiguity, refuse the temptation to guess. 245 | There should be one-- and preferably only one --obvious way to do it. 246 | Although that way may not be obvious at first unless you're Dutch. 247 | """ 248 | ) 249 | 250 | p = patterns.no_should 251 | p.refused("...should...") 252 | 253 | full_pattern = patterns.full 254 | full_pattern.merge( 255 | "no_should", "top_and_middle", "better_things", "conclusio" 256 | ) 257 | 258 | assert full_pattern == zen 259 | 260 | 261 | @pytest.fixture 262 | def zen_patterns(patterns): 263 | p = patterns.better_things 264 | p.optional("...better...") 265 | 266 | p = patterns.conclusio 267 | p.continuous( 268 | """\ 269 | If the implementation is hard to explain, it's a bad idea. 270 | If the implementation is easy to explain, it may be a good idea. 271 | Namespaces are one honking great idea -- let's do more of those! 272 | """ 273 | ) 274 | 275 | p = patterns.top_and_middle 276 | p.optional("") 277 | p.in_order( 278 | """ 279 | The Zen of Python, by Tim Peters 280 | 281 | Readability counts. 282 | Special cases aren't special enough to break the rules. 283 | Although practicality beats purity. 284 | Errors should never pass silently. 285 | Unless explicitly silenced. 286 | In the face of ambiguity, refuse the temptation to guess. 287 | There should be one-- and preferably only one --obvious way to do it. 288 | Although that way may not be obvious at first unless you're Dutch. 289 | """ 290 | ) 291 | full_pattern = patterns.full 292 | full_pattern.merge("top_and_middle", "better_things", "conclusio") 293 | 294 | 295 | def test_zen_7(patterns, zen_patterns): 296 | full_pattern = patterns.full 297 | full_pattern.merge("better_things", "conclusio", "top_and_middle") 298 | 299 | assert full_pattern == zen 300 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1700076579, 6 | "narHash": "sha256-iMsZCHbMArLfg9pP5xzSSQf0/IvQ9kAAQ4w0a3sQtn8=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "61202fc8677a6e9e0a82eb6610eeef28852fc790", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "nixpkgs", 14 | "type": "indirect" 15 | } 16 | }, 17 | "root": { 18 | "inputs": { 19 | "nixpkgs": "nixpkgs" 20 | } 21 | } 22 | }, 23 | "root": "root", 24 | "version": 7 25 | } 26 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | 3 | outputs = { self, nixpkgs }: { 4 | 5 | devShells.aarch64-darwin.default = import ./shell.nix { pkgs = nixpkgs.legacyPackages.aarch64-darwin; }; 6 | 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatchling", 5 | ] 6 | 7 | [project] 8 | name = "pytest-patterns" 9 | description = "pytest plugin to make testing complicated long string output easy to write and easy to debug" 10 | readme = "README.md" 11 | keywords = [ 12 | ] 13 | license = "MIT" 14 | authors = [ 15 | { name = "Christian Theune", email = "ct@flyingcircus.io" }, 16 | ] 17 | requires-python = ">=3.7" 18 | classifiers = [ 19 | "Development Status :: 4 - Beta", 20 | "Framework :: Pytest", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: Implementation :: CPython", 30 | "Programming Language :: Python :: Implementation :: PyPy", 31 | ] 32 | dynamic = [ 33 | "version", 34 | ] 35 | dependencies = [ 36 | "pytest>=6", 37 | ] 38 | [project.urls] 39 | Documentation = "https://github.com/unknown/pytest-patterns#readme" 40 | Issues = "https://github.com/unknown/pytest-patterns/issues" 41 | Source = "https://github.com/unknown/pytest-patterns" 42 | [project.entry-points.pytest11] 43 | myproject = "pytest_patterns.plugin" 44 | 45 | [tool.hatch.version] 46 | path = "src/pytest_patterns/__about__.py" 47 | 48 | [tool.hatch.envs.default] 49 | dependencies = [ 50 | "coverage[toml]>=6.5", 51 | "pytest-cov", 52 | ] 53 | 54 | [tool.hatch.envs.default.env-vars] 55 | COV_CORE_SOURCE="src" 56 | COV_CORE_CONFIG=".coveragerc" 57 | COV_CORE_DATAFILE=".coverage.eager" 58 | 59 | [tool.hatch.envs.default.scripts] 60 | test = "pytest {args:tests}" 61 | 62 | [[tool.hatch.envs.all.matrix]] 63 | python = ["3.7", "3.8", "3.9", "3.10", "3.11"] 64 | 65 | [tool.hatch.envs.lint] 66 | detached = true 67 | dependencies = [ 68 | "pytest>=6", 69 | "black>=23.1.0", 70 | "mypy>=1.0.0", 71 | "ruff>=0.0.243", 72 | ] 73 | [tool.hatch.envs.lint.scripts] 74 | typing = "mypy {args:src/pytest_patterns tests}" 75 | style = [ 76 | "ruff {args:.}", 77 | "black --check --diff {args:.}", 78 | ] 79 | fmt = [ 80 | "black {args:.}", 81 | "ruff --fix {args:.}", 82 | "style", 83 | ] 84 | all = [ 85 | "style", 86 | "typing", 87 | ] 88 | 89 | [tool.black] 90 | target-version = ["py38"] 91 | line-length = 80 92 | skip-string-normalization = true 93 | 94 | [tool.ruff] 95 | target-version = "py38" 96 | line-length = 80 97 | select = [ 98 | "A", 99 | "ARG", 100 | "B", 101 | "C", 102 | "DTZ", 103 | "E", 104 | "EM", 105 | "F", 106 | "FBT", 107 | "I", 108 | "ICN", 109 | "ISC", 110 | "N", 111 | "PLC", 112 | "PLE", 113 | "PLR", 114 | "PLW", 115 | "Q", 116 | "RUF", 117 | "S", 118 | "T", 119 | "TID", 120 | "UP", 121 | "W", 122 | "YTT", 123 | ] 124 | ignore = [ 125 | # pytest 126 | "ARG001", "S101", 127 | # Allow non-abstract empty methods in abstract base classes 128 | "B027", 129 | # Allow boolean positional values in function calls, like `dict.get(... True)` 130 | "FBT003", 131 | # Ignore checks for possible passwords 132 | "S105", "S106", "S107", 133 | # Ignore complexity 134 | "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", 135 | ] 136 | unfixable = [ 137 | # Don't touch unused imports 138 | "F401", 139 | ] 140 | 141 | [tool.ruff.isort] 142 | known-first-party = ["pytest_patterns"] 143 | 144 | [tool.ruff.flake8-tidy-imports] 145 | ban-relative-imports = "all" 146 | 147 | [tool.ruff.per-file-ignores] 148 | # Tests can use magic values, assertions, and relative imports 149 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 150 | 151 | [tool.isort] 152 | profile = "black" 153 | line_length = 80 154 | 155 | [tool.coverage.run] 156 | source_pkgs = ["pytest_patterns", "tests"] 157 | branch = true 158 | parallel = true 159 | omit = [ 160 | "src/pytest_patterns/__about__.py", 161 | ] 162 | 163 | [tool.coverage.paths] 164 | pytest_patterns = ["src/pytest_patterns", "*/pytest-patterns/src/pytest_patterns"] 165 | tests = ["tests", "*/pytest-patterns/tests"] 166 | 167 | [tool.coverage.report] 168 | exclude_lines = [ 169 | "no cov", 170 | "if __name__ == .__main__.:", 171 | "if TYPE_CHECKING:", 172 | ] 173 | 174 | [tool.mypy] 175 | strict=true 176 | python_version = "3.8" 177 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -W error --tb=native --cov=src -cov-append --cov-report=html --junitxml=report.xml 3 | testpaths = tests 4 | junit_family=xunit2 5 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: 2 | 3 | pkgs.mkShell { 4 | name = "python"; 5 | packages = [ pkgs.hatch ]; 6 | } 7 | -------------------------------------------------------------------------------- /src/pytest_patterns/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.0" 2 | -------------------------------------------------------------------------------- /src/pytest_patterns/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flyingcircusio/pytest-patterns/960300ff62cc64fd7a13a924b5cd979dcfaa2311/src/pytest_patterns/__init__.py -------------------------------------------------------------------------------- /src/pytest_patterns/plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | import re 5 | from typing import Any, Iterator 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture 11 | def patterns() -> PatternsLib: 12 | return PatternsLib() 13 | 14 | 15 | def pytest_assertrepr_compare( 16 | op: str, left: Any, right: Any 17 | ) -> list[str] | None: 18 | if op != "==": 19 | return None 20 | if isinstance(left, Pattern): 21 | return list(left._audit(right).report()) 22 | elif isinstance(right, Pattern): 23 | return list(right._audit(left).report()) 24 | else: 25 | return None 26 | 27 | 28 | class Status(enum.Enum): 29 | UNEXPECTED = 1 30 | OPTIONAL = 2 31 | EXPECTED = 3 32 | REFUSED = 4 33 | 34 | @property 35 | def symbol(self) -> str: 36 | return STATUS_SYMBOLS[self] 37 | 38 | 39 | STATUS_SYMBOLS = { 40 | Status.UNEXPECTED: "🟡", 41 | Status.EXPECTED: "🟢", 42 | Status.OPTIONAL: "⚪️", 43 | Status.REFUSED: "🔴", 44 | } 45 | 46 | EMPTY_LINE_PATTERN = "" 47 | 48 | 49 | def tab_replace(line: str) -> str: 50 | while (position := line.find("\t")) != -1: 51 | fill = " " * (8 - (position % 8)) 52 | line = line.replace("\t", fill) 53 | return line 54 | 55 | 56 | ascii_to_control_pictures = { 57 | 0x00: "\u2400", # NUL -> ␀ 58 | 0x01: "\u2401", # SOH -> ␁ 59 | 0x02: "\u2402", # STX -> ␂ 60 | 0x03: "\u2403", # ETX -> ␃ 61 | 0x04: "\u2404", # EOT -> ␄ 62 | 0x05: "\u2405", # ENQ -> ␅ 63 | 0x06: "\u2406", # ACK -> ␆ 64 | 0x07: "\u2407", # BEL -> ␇ 65 | 0x08: "\u2408", # BS -> ␈ 66 | 0x09: "\u2409", # HT -> ␉ 67 | 0x0A: "\u240a", # LF -> ␊ 68 | 0x0B: "\u240b", # VT -> ␋ 69 | 0x0C: "\u240c", # FF -> ␌ 70 | 0x0D: "\u240d", # CR -> ␍ 71 | 0x0E: "\u240e", # SO -> ␎ 72 | 0x0F: "\u240f", # SI -> ␏ 73 | 0x10: "\u2410", # DLE -> ␐ 74 | 0x11: "\u2411", # DC1 -> ␑ 75 | 0x12: "\u2412", # DC2 -> ␒ 76 | 0x13: "\u2413", # DC3 -> ␓ 77 | 0x14: "\u2414", # DC4 -> ␔ 78 | 0x15: "\u2415", # NAK -> ␕ 79 | 0x16: "\u2416", # SYN -> ␖ 80 | 0x17: "\u2417", # ETB -> ␗ 81 | 0x18: "\u2418", # CAN -> ␘ 82 | 0x19: "\u2419", # EM -> ␙ 83 | 0x1A: "\u241a", # SUB -> ␚ 84 | 0x1B: "\u241b", # ESC -> ␛ 85 | 0x1C: "\u241c", # FS -> ␜ 86 | 0x1D: "\u241d", # GS -> ␝ 87 | 0x1E: "\u241e", # RS -> ␞ 88 | 0x1F: "\u241f", # US -> ␟ 89 | 0x20: "\u2420", # SPACE -> ␠ 90 | 0x7F: "\u2421", # DEL -> ␡ 91 | } 92 | 93 | 94 | def to_control_picture(char: str) -> str: 95 | return ascii_to_control_pictures.get(ord(char), char) 96 | 97 | 98 | def line_to_control_pictures(line: str) -> str: 99 | return "".join(to_control_picture(char) for char in line) 100 | 101 | 102 | def match(pattern: str, line: str) -> bool | re.Match[str] | None: 103 | if pattern == EMPTY_LINE_PATTERN: 104 | if not line: 105 | return True 106 | 107 | line = tab_replace(line) 108 | pattern = re.escape(pattern) 109 | pattern = pattern.replace(r"\.\.\.", ".*?") 110 | re_pattern = re.compile("^" + pattern + "$") 111 | return re_pattern.match(line) 112 | 113 | 114 | class Line: 115 | status: Status = Status.UNEXPECTED 116 | status_cause: str = "" 117 | 118 | def __init__(self, data: str): 119 | self.data = data 120 | 121 | def matches(self, expectation: str) -> bool: 122 | return bool(match(expectation, self.data)) 123 | 124 | def mark(self, status: Status, cause: str) -> None: 125 | if status.value <= self.status.value: 126 | # Stay in the current status 127 | return 128 | self.status = status 129 | self.status_cause = cause 130 | 131 | 132 | class Audit: 133 | content: list[Line] 134 | unmatched_expectations: list[tuple[str, str]] 135 | matched_refused: set[tuple[str, str]] 136 | 137 | def __init__(self, content: str): 138 | self.unmatched_expectations = [] 139 | self.matched_refused = set() 140 | 141 | self.content = [] 142 | for line in content.splitlines(): 143 | self.content.append(Line(line)) 144 | 145 | def cursor(self) -> Iterator[Line]: 146 | return iter(self.content) 147 | 148 | def in_order(self, name: str, expected_lines: list[str]) -> None: 149 | """Expect all lines exist and come in order, but they 150 | may be interleaved with other lines.""" 151 | cursor = self.cursor() 152 | have_some_match = False 153 | for expected_line in expected_lines: 154 | for line in cursor: 155 | if line.matches(expected_line): 156 | line.mark(Status.EXPECTED, name) 157 | have_some_match = True 158 | break 159 | else: 160 | self.unmatched_expectations.append((name, expected_line)) 161 | if not have_some_match: 162 | # Reset the scan, if we didn't have any previous 163 | # match - maybe a later line will produce a partial match. 164 | # But do not reset if we already have something matching, 165 | # because that would defeat the "in order" assumption. 166 | cursor = self.cursor() 167 | 168 | def optional(self, name: str, tolerated_lines: list[str]) -> None: 169 | """Those lines may exist and then they may appear anywhere 170 | a number of times, or they may not exist. 171 | """ 172 | for tolerated_line in tolerated_lines: 173 | for line in self.cursor(): 174 | if line.matches(tolerated_line): 175 | line.mark(Status.OPTIONAL, name) 176 | 177 | def refused(self, name: str, refused_lines: list[str]) -> None: 178 | for refused_line in refused_lines: 179 | for line in self.cursor(): 180 | if line.matches(refused_line): 181 | line.mark(Status.REFUSED, name) 182 | self.matched_refused.add((name, refused_line)) 183 | 184 | def continuous(self, name: str, continuous_lines: list[str]) -> None: 185 | continuous_cursor = enumerate(continuous_lines) 186 | continuous_index, continuous_line = next(continuous_cursor) 187 | for line in self.cursor(): 188 | if continuous_index and not line.data: 189 | # Continuity still allows empty lines (after the first line) in 190 | # between as we filter them out from the pattern to make those 191 | # more readable. 192 | line.mark(Status.OPTIONAL, name) 193 | continue 194 | if line.matches(continuous_line): 195 | line.mark(Status.EXPECTED, name) 196 | try: 197 | continuous_index, continuous_line = next(continuous_cursor) 198 | except StopIteration: 199 | # We exhausted the pattern and are happy. 200 | break 201 | elif continuous_index: 202 | # This is not the first focus line any more, it's not valid to 203 | # not match 204 | line.mark(Status.REFUSED, name) 205 | self.unmatched_expectations.append((name, continuous_line)) 206 | self.unmatched_expectations.extend( 207 | [(name, line) for i, line in continuous_cursor] 208 | ) 209 | break 210 | else: 211 | self.unmatched_expectations.append((name, continuous_line)) 212 | self.unmatched_expectations.extend( 213 | [(name, line) for i, line in continuous_cursor] 214 | ) 215 | 216 | def report(self) -> Iterator[str]: 217 | yield "String did not meet the expectations." 218 | yield "" 219 | yield " | ".join( 220 | [ 221 | Status.EXPECTED.symbol + "=EXPECTED", 222 | Status.OPTIONAL.symbol + "=OPTIONAL", 223 | Status.UNEXPECTED.symbol + "=UNEXPECTED", 224 | Status.REFUSED.symbol + "=REFUSED/UNMATCHED", 225 | ] 226 | ) 227 | yield "" 228 | yield "Here is the string that was tested: " 229 | yield "" 230 | for line in self.content: 231 | yield format_line_report( 232 | line.status, 233 | line.status.symbol, 234 | line.status_cause, 235 | tab_replace(line.data), 236 | ) 237 | if self.unmatched_expectations: 238 | yield "" 239 | yield "These are the unmatched expected lines: " 240 | yield "" 241 | for name, line_str in self.unmatched_expectations: 242 | yield format_line_report( 243 | Status.REFUSED, Status.REFUSED.symbol, name, line_str 244 | ) 245 | if self.matched_refused: 246 | yield "" 247 | yield "These are the matched refused lines: " 248 | yield "" 249 | for name, line_str in self.matched_refused: 250 | yield format_line_report( 251 | Status.REFUSED, Status.REFUSED.symbol, name, line_str 252 | ) 253 | 254 | def is_ok(self) -> bool: 255 | if self.unmatched_expectations: 256 | return False 257 | for line in self.content: 258 | if line.status not in [Status.EXPECTED, Status.OPTIONAL]: 259 | return False 260 | return True 261 | 262 | 263 | def format_line_report( 264 | status: Status, 265 | symbol: str, 266 | cause: str, 267 | line: str, 268 | ) -> str: 269 | if status not in [Status.EXPECTED, Status.OPTIONAL]: 270 | line = line_to_control_pictures(line) 271 | return symbol + " " + cause.ljust(15)[:15] + " | " + line 272 | 273 | 274 | def pattern_lines(lines: str) -> list[str]: 275 | # Remove leading whitespace, ignore empty lines. 276 | return list(filter(None, lines.splitlines())) 277 | 278 | 279 | class Pattern: 280 | name: str 281 | library: PatternsLib 282 | ops: list[tuple[str, str, Any]] 283 | inherited: set[str] 284 | 285 | def __init__(self, library: PatternsLib, name: str): 286 | self.name = name 287 | self.library = library 288 | self.ops = [] 289 | self.inherited = set() 290 | 291 | # Modifiers (Verbs) 292 | 293 | def merge(self, *base_patterns: str) -> None: 294 | """Merge rules from base_patterns (recursively) into this pattern.""" 295 | self.inherited.update(base_patterns) 296 | 297 | def normalize(self, mode: str) -> None: 298 | pass 299 | 300 | # Matches (Adjectives) 301 | 302 | def continuous(self, lines: str) -> None: 303 | """These lines must appear once and they must be continuous.""" 304 | self.ops.append(("continuous", self.name, pattern_lines(lines))) 305 | 306 | def in_order(self, lines: str) -> None: 307 | """These lines must appear once and they must be in order.""" 308 | self.ops.append(("in_order", self.name, pattern_lines(lines))) 309 | 310 | def optional(self, lines: str) -> None: 311 | """These lines are optional.""" 312 | self.ops.append(("optional", self.name, pattern_lines(lines))) 313 | 314 | def refused(self, lines: str) -> None: 315 | """If those lines appear they are refused.""" 316 | self.ops.append(("refused", self.name, pattern_lines(lines))) 317 | 318 | # Internal API 319 | 320 | def flat_ops(self) -> Iterator[tuple[str, str, Any]]: 321 | for inherited_pattern in self.inherited: 322 | yield from getattr(self.library, inherited_pattern).flat_ops() 323 | yield from self.ops 324 | 325 | def _audit(self, content: str) -> Audit: 326 | audit = Audit(content) 327 | for op, *args in self.flat_ops(): 328 | getattr(audit, op)(*args) 329 | return audit 330 | 331 | def __eq__(self, other: object) -> bool: 332 | assert isinstance(other, str) 333 | audit = self._audit(other) 334 | return audit.is_ok() 335 | 336 | 337 | class PatternsLib: 338 | def __getattr__(self, name: str) -> Pattern: 339 | res = self.__dict__[name] = Pattern(self, name) 340 | return res 341 | -------------------------------------------------------------------------------- /tests/fixtures/ical-ordering.ical: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//fc.support//fcio// 4 | BEGIN:VEVENT 5 | SUMMARY:alice (1\, appops) 6 | DTSTART;VALUE=DATE:20110201 7 | DTEND;VALUE=DATE:20110201 8 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 9 | UID:36a6983685c626a8255f480ec59930ea6f38a257ba79460982149461c733506a 10 | END:VEVENT 11 | BEGIN:VEVENT 12 | SUMMARY:bob (2\, appops) 13 | DTSTART;VALUE=DATE:20110201 14 | DTEND;VALUE=DATE:20110201 15 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 16 | UID:42d5f543bdbb6f589bd19a893fd209fe89f4558c7be5ebcb93bcd12ba9ae9161 17 | END:VEVENT 18 | BEGIN:VEVENT 19 | SUMMARY:alice (1\, appops) 20 | DTSTART;VALUE=DATE:20110202 21 | DTEND;VALUE=DATE:20110202 22 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 23 | UID:6db22f58ee442b51955546b6b617c2e39c07acc0a77b1dcc4230a04d63dc08a4 24 | END:VEVENT 25 | BEGIN:VEVENT 26 | SUMMARY:bob (2\, appops) 27 | DTSTART;VALUE=DATE:20110202 28 | DTEND;VALUE=DATE:20110202 29 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 30 | UID:92100821b84973eccd2fb036c068bd405698af95e16f4420341940e5cc5ac148 31 | END:VEVENT 32 | BEGIN:VEVENT 33 | SUMMARY:alice (1\, appops) 34 | DTSTART;VALUE=DATE:20110203 35 | DTEND;VALUE=DATE:20110203 36 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 37 | UID:bd1b1d019cfdff07456a7be437ecc6c7027f8c4ec6904e65c6f297a52f3eee14 38 | END:VEVENT 39 | BEGIN:VEVENT 40 | SUMMARY:bob (2\, appops) 41 | DTSTART;VALUE=DATE:20110203 42 | DTEND;VALUE=DATE:20110203 43 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 44 | UID:becfafc6c131961b1d8913f3109aef3af1b6142bdbbc4e4642503fcd1cce05a6 45 | END:VEVENT 46 | BEGIN:VEVENT 47 | SUMMARY:alice (1\, appops) 48 | DTSTART;VALUE=DATE:20110204 49 | DTEND;VALUE=DATE:20110204 50 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 51 | UID:834e1ddc937baae355d08f8960967466baab83f172d5f967a49083550cbd9e06 52 | END:VEVENT 53 | BEGIN:VEVENT 54 | SUMMARY:bob (2\, appops) 55 | DTSTART;VALUE=DATE:20110204 56 | DTEND;VALUE=DATE:20110204 57 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 58 | UID:08e07e2b92b3fbb3264abd48e1aa1983962626902630beb6a5b4d5fece22a7da 59 | END:VEVENT 60 | BEGIN:VEVENT 61 | SUMMARY:bob (1\, appops) 62 | DTSTART;VALUE=DATE:20110205 63 | DTEND;VALUE=DATE:20110205 64 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 65 | UID:16f138f3f88c54fe5a6e248de42f6a8e3a9b0c3f941c9f9c760b9aa639c1d457 66 | END:VEVENT 67 | BEGIN:VEVENT 68 | SUMMARY:bob (1\, appops) 69 | DTSTART;VALUE=DATE:20110206 70 | DTEND;VALUE=DATE:20110206 71 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 72 | UID:0afbce38bf534b30031618c9a7c0f884b8ae69ccfe99d32753790edfab033405 73 | END:VEVENT 74 | BEGIN:VEVENT 75 | SUMMARY:cedric (1\, platform) 76 | DTSTART;VALUE=DATE:20110201 77 | DTEND;VALUE=DATE:20110201 78 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 79 | UID:46f2574f64d22dc70db2379b08d83c0f51305c0729cb0d0e8852208b0deb4d87 80 | END:VEVENT 81 | BEGIN:VEVENT 82 | SUMMARY:cedric (1\, platform) 83 | DTSTART;VALUE=DATE:20110202 84 | DTEND;VALUE=DATE:20110202 85 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 86 | UID:0b3f15fedfdf1c627afd2efd990b8c52e7f8822eebf62767e38b33cd5763d5c9 87 | END:VEVENT 88 | BEGIN:VEVENT 89 | SUMMARY:cedric (1\, platform) 90 | DTSTART;VALUE=DATE:20110203 91 | DTEND;VALUE=DATE:20110203 92 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 93 | UID:a1d00464af0a25c9292aa72d0bf5236b5749d5e42c13e484080d2abbfb6ad89e 94 | END:VEVENT 95 | BEGIN:VEVENT 96 | SUMMARY:cedric (1\, platform) 97 | DTSTART;VALUE=DATE:20110204 98 | DTEND;VALUE=DATE:20110204 99 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 100 | UID:4d6e48685c893ddf8856c8439f4d6bde05fbe603118fa68ebd7d3f37b4e3e6ed 101 | END:VEVENT 102 | BEGIN:VEVENT 103 | SUMMARY:alice (1\, platform) 104 | DTSTART;VALUE=DATE:20110205 105 | DTEND;VALUE=DATE:20110205 106 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 107 | UID:31638940439cb48412420108cbb265a34baf2714075df401723bf82da0586743 108 | END:VEVENT 109 | BEGIN:VEVENT 110 | SUMMARY:alice (1\, platform) 111 | DTSTART;VALUE=DATE:20110206 112 | DTEND;VALUE=DATE:20110206 113 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 114 | UID:8288d3266bf9cef59ddeafd8ec03d58bc0314dfe9e825617c9de90f856361eff 115 | END:VEVENT 116 | END:VCALENDAR 117 | -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytest_patterns.plugin import PatternsLib 4 | 5 | GENERIC_HEADER = [ 6 | "String did not meet the expectations.", 7 | "", 8 | "🟢=EXPECTED | ⚪️=OPTIONAL | 🟡=UNEXPECTED | 🔴=REFUSED/UNMATCHED", 9 | "", 10 | "Here is the string that was tested: ", 11 | "", 12 | ] 13 | 14 | 15 | def test_tab_replace() -> None: 16 | from pytest_patterns.plugin import tab_replace 17 | 18 | assert tab_replace("\t") == " " * 8 19 | assert tab_replace("1\t9") == "1 9" 20 | assert tab_replace("12\t9") == "12 9" 21 | assert tab_replace("123\t9") == "123 9" 22 | assert tab_replace("1234\t9") == "1234 9" 23 | assert tab_replace("12345\t9") == "12345 9" 24 | assert tab_replace("123456\t9") == "123456 9" 25 | assert tab_replace("1234567\t9") == "1234567 9" 26 | assert tab_replace("12345678\t9") == "12345678 9" 27 | assert tab_replace("123456789\t0") == "123456789 0" 28 | 29 | 30 | def test_patternslib_multiple_accesses(patterns: PatternsLib) -> None: 31 | assert patterns.foo is patterns.foo 32 | 33 | 34 | def test_empty_pattern_empty_string_is_ok(patterns: PatternsLib) -> None: 35 | # This is fine IMHO. The whole general assumption is that we only reject 36 | # unexpected content and fail if required content is missing. If there is 37 | # no content, then there is no unexpected content and if you didn't expect 38 | # any content then there is none missing, so we fall through. 39 | audit = patterns.nothing._audit("") 40 | assert list(audit.report()) == GENERIC_HEADER 41 | assert audit.is_ok() 42 | 43 | 44 | def test_unexpected_lines_fail(patterns: PatternsLib) -> None: 45 | audit = patterns.nothing._audit("This is an unexpected line") 46 | assert list(audit.report()) == [ 47 | *GENERIC_HEADER, 48 | "🟡 | This␠is␠an␠unexpected␠line", 49 | ] 50 | assert not audit.is_ok() 51 | 52 | 53 | def test_empty_lines_do_not_match(patterns: PatternsLib) -> None: 54 | patterns.nothing.optional("") 55 | audit = patterns.nothing._audit( 56 | """ 57 | """ 58 | ) 59 | assert list(audit.report()) == [ 60 | *GENERIC_HEADER, 61 | "🟡 | ", 62 | ] 63 | assert not audit.is_ok() 64 | 65 | 66 | def test_empty_lines_match_special_marker(patterns: PatternsLib) -> None: 67 | patterns.empty.optional("") 68 | audit = patterns.empty._audit( 69 | """ 70 | 71 | 72 | """ 73 | ) 74 | assert list(audit.report()) == [ 75 | *GENERIC_HEADER, 76 | "⚪️ empty | ", 77 | "⚪️ empty | ", 78 | "⚪️ empty | ", 79 | ] 80 | assert audit.is_ok() 81 | 82 | 83 | def test_comprehensive(patterns: PatternsLib) -> None: 84 | sample = patterns.sample 85 | 86 | sample.in_order( 87 | """ 88 | This comes early on 89 | This comes later 90 | 91 | This comes even later 92 | """ 93 | ) 94 | sample.optional( 95 | """ 96 | This is a heartbeat that can appear almost anywhere... 97 | """ 98 | ) 99 | sample.continuous( 100 | """ 101 | This comes first (...) 102 | This comes second (...) 103 | This comes third (...) 104 | """ 105 | ) 106 | sample.refused( 107 | """ 108 | ...error... 109 | """ 110 | ) 111 | assert ( 112 | sample 113 | == """\ 114 | This comes early on 115 | This is a heartbeat that can appear almost anywhere 116 | This comes first (with variability) 117 | This comes second (also with variability) 118 | This comes third (more variability!) 119 | This is a heartbeat that can appear almost anywhere (outside focus ranges) 120 | This comes later 121 | This comes even later 122 | """ 123 | ) 124 | 125 | 126 | def test_in_order_lines_clear_with_intermittent_input( 127 | patterns: PatternsLib, 128 | ) -> None: 129 | pattern = patterns.in_order 130 | pattern.in_order( 131 | """ 132 | This is a first expected line 133 | This is a second expected line""" 134 | ) 135 | pattern.optional("This is from another match") 136 | 137 | audit = pattern._audit( 138 | """\ 139 | This is a first expected line 140 | This is from another match 141 | This is a second expected line""" 142 | ) 143 | 144 | assert list(audit.report()) == [ 145 | *GENERIC_HEADER, 146 | "🟢 in_order | This is a first expected line", 147 | "⚪️ in_order | This is from another match", 148 | "🟢 in_order | This is a second expected line", 149 | ] 150 | assert audit.is_ok() 151 | 152 | 153 | def test_missing_ordered_lines_fail(patterns: PatternsLib) -> None: 154 | pattern = patterns.in_order 155 | pattern.in_order( 156 | """ 157 | This is an expected line 158 | This is also an expected line 159 | """ 160 | ) 161 | 162 | audit = pattern._audit( 163 | """\ 164 | This is an expected line 165 | """ 166 | ) 167 | assert list(audit.report()) == [ 168 | *GENERIC_HEADER, 169 | "🟢 in_order | This is an expected line", 170 | "", 171 | "These are the unmatched expected lines: ", 172 | "", 173 | "🔴 in_order | This␠is␠also␠an␠expected␠line", 174 | ] 175 | assert not audit.is_ok() 176 | 177 | 178 | def test_incorrectly_ordered_lines_fail(patterns: PatternsLib) -> None: 179 | pattern = patterns.in_order 180 | pattern.in_order( 181 | """ 182 | Line 1 183 | Line 2 184 | Line 3 185 | Line 4 186 | Line 5 187 | """ 188 | ) 189 | 190 | audit = pattern._audit( 191 | """\ 192 | Line 5 193 | Line 4 194 | Line 3 195 | Line 2 196 | Line 1 197 | """ 198 | ) 199 | assert list(audit.report()) == [ 200 | *GENERIC_HEADER, 201 | "🟡 | Line␠5", 202 | "🟡 | Line␠4", 203 | "🟡 | Line␠3", 204 | "🟡 | Line␠2", 205 | "🟢 in_order | Line 1", 206 | "", 207 | "These are the unmatched expected lines: ", 208 | "", 209 | "🔴 in_order | Line␠2", 210 | "🔴 in_order | Line␠3", 211 | "🔴 in_order | Line␠4", 212 | "🔴 in_order | Line␠5", 213 | ] 214 | assert not audit.is_ok() 215 | 216 | 217 | def test_refused_lines_fail(patterns: PatternsLib) -> None: 218 | pattern = patterns.refused 219 | pattern.refused("This is a refused line") 220 | 221 | audit = pattern._audit("This is a refused line") 222 | assert list(audit.report()) == [ 223 | *GENERIC_HEADER, 224 | "🔴 refused | This␠is␠a␠refused␠line", 225 | "", 226 | "These are the matched refused lines: ", 227 | "", 228 | "🔴 refused | This␠is␠a␠refused␠line", 229 | ] 230 | assert not audit.is_ok() 231 | 232 | 233 | def test_continuous_lines_only_clear_if_not_interrupted( 234 | patterns: PatternsLib, 235 | ) -> None: 236 | pattern = patterns.focus 237 | pattern.optional("asdf") 238 | pattern.continuous( 239 | """ 240 | These lines 241 | need to match 242 | without being 243 | interrupted 244 | """ 245 | ) 246 | 247 | audit = pattern._audit( 248 | """\ 249 | asdf 250 | These lines 251 | need to match 252 | without being 253 | interrupted 254 | asdf 255 | """ 256 | ) 257 | assert list(audit.report()) == [ 258 | *GENERIC_HEADER, 259 | "⚪️ focus | asdf", 260 | "🟢 focus | These lines", 261 | "🟢 focus | need to match", 262 | "🟢 focus | without being", 263 | "🟢 focus | interrupted", 264 | "⚪️ focus | asdf", 265 | ] 266 | assert audit.is_ok() 267 | 268 | audit = pattern._audit( 269 | """\ 270 | asdf 271 | These lines 272 | are broken 273 | need to match 274 | asdf 275 | without being 276 | because there is stuff in between 277 | interrupted 278 | asdf 279 | """ 280 | ) 281 | assert list(audit.report()) == [ 282 | *GENERIC_HEADER, 283 | "⚪️ focus | asdf", 284 | "🟢 focus | These lines", 285 | "🔴 focus | are␠broken", 286 | "🟡 | need␠to␠match", 287 | "⚪️ focus | asdf", 288 | "🟡 | without␠being", 289 | "🟡 | because␠there␠is␠stuff␠in␠between", 290 | "🟡 | interrupted", 291 | "⚪️ focus | asdf", 292 | "", 293 | "These are the unmatched expected lines: ", 294 | "", 295 | "🔴 focus | need␠to␠match", 296 | "🔴 focus | without␠being", 297 | "🔴 focus | interrupted", 298 | ] 299 | assert not audit.is_ok() 300 | 301 | 302 | def test_continuous_lines_fail_and_report_if_first_line_isnt_matching( 303 | patterns: PatternsLib, 304 | ) -> None: 305 | pattern = patterns.focus 306 | pattern.continuous( 307 | """ 308 | First line 309 | Second line 310 | """ 311 | ) 312 | 313 | audit = pattern._audit( 314 | """\ 315 | Not the first line 316 | There is no first line 317 | """ 318 | ) 319 | assert list(audit.report()) == [ 320 | *GENERIC_HEADER, 321 | "🟡 | Not␠the␠first␠line", 322 | "🟡 | There␠is␠no␠first␠line", 323 | "", 324 | "These are the unmatched expected lines: ", 325 | "", 326 | "🔴 focus | First␠line", 327 | "🔴 focus | Second␠line", 328 | ] 329 | assert not audit.is_ok() 330 | 331 | 332 | def test_optional(patterns: PatternsLib) -> None: 333 | pattern = patterns.optional 334 | pattern.optional("pong") 335 | pattern.optional("ping") 336 | 337 | audit = pattern._audit( 338 | """\ 339 | ping 340 | """ 341 | ) 342 | assert list(audit.report()) == [ 343 | *GENERIC_HEADER, 344 | "⚪️ optional | ping", 345 | ] 346 | assert audit.is_ok() 347 | 348 | 349 | @pytest.fixture() 350 | def fcqemu_patterns(patterns: PatternsLib) -> None: 351 | patterns.debug.optional("simplevm> ...") 352 | 353 | # This part of the heartbeats must show up 354 | patterns.heartbeat.in_order( 355 | """ 356 | simplevm heartbeat-initialized 357 | simplevm started-heartbeat-ping 358 | simplevm heartbeat-ping 359 | """ 360 | ) 361 | # The pings may happen more times and sometimes the stopping part 362 | # isn't visible because we terminate too fast. 363 | patterns.heartbeat.optional( 364 | """ 365 | simplevm heartbeat-ping 366 | simplevm stopped-heartbeat-ping 367 | """ 368 | ) 369 | 370 | patterns.failure.refused("...fail...") 371 | 372 | 373 | def test_complex_example(patterns: PatternsLib, fcqemu_patterns: None) -> None: 374 | outmigration = patterns.outmigration 375 | outmigration.merge("debug", "heartbeat", "failure") 376 | 377 | outmigration.in_order( 378 | """ 379 | /nix/store/.../bin/fc-qemu -v outmigrate simplevm 380 | load-system-config 381 | simplevm connect-rados subsystem='ceph' 382 | simplevm acquire-lock target='/run/qemu.simplevm.lock' 383 | simplevm acquire-lock count=1 result='locked' target='/run/qemu.simplevm.lock' 384 | simplevm qmp_capabilities arguments={} id=None subsystem='qemu/qmp' 385 | simplevm query-status arguments={} id=None subsystem='qemu/qmp' 386 | 387 | simplevm outmigrate 388 | simplevm consul-register 389 | simplevm locate-inmigration-service 390 | simplevm check-staging-config result='none' 391 | simplevm located-inmigration-service url='http://host2.mgm.test.gocept.net:...' 392 | 393 | simplevm acquire-migration-locks 394 | simplevm check-staging-config result='none' 395 | simplevm acquire-migration-lock result='success' subsystem='qemu' 396 | simplevm acquire-local-migration-lock result='success' 397 | simplevm acquire-remote-migration-lock 398 | simplevm acquire-remote-migration-lock result='success' 399 | 400 | simplevm unlock subsystem='ceph' volume='rbd.ssd/simplevm.root' 401 | simplevm unlock subsystem='ceph' volume='rbd.ssd/simplevm.swap' 402 | simplevm unlock subsystem='ceph' volume='rbd.ssd/simplevm.tmp' 403 | 404 | simplevm prepare-remote-environment 405 | simplevm start-migration target='tcp:192.168.4.7:...' 406 | simplevm migrate subsystem='qemu' 407 | simplevm migrate-set-capabilities arguments={'capabilities': [{'capability': 'xbzrle', 'state': False}, {'capability': 'auto-converge', 'state': True}]} id=None subsystem='qemu/qmp' 408 | simplevm migrate-set-parameters arguments={'compress-level': 0, 'downtime-limit': 4000, 'max-bandwidth': 22500} id=None subsystem='qemu/qmp' 409 | simplevm migrate arguments={'uri': 'tcp:192.168.4.7:...'} id=None subsystem='qemu/qmp' 410 | 411 | simplevm query-migrate-parameters arguments={} id=None subsystem='qemu/qmp' 412 | simplevm migrate-parameters announce-initial=50 announce-max=550 announce-rounds=5 announce-step=100 block-incremental=False compress-level=0 compress-threads=8 compress-wait-thread=True cpu-throttle-increment=10 cpu-throttle-initial=20 cpu-throttle-tailslow=False decompress-threads=2 downtime-limit=4000 max-bandwidth=22500 max-cpu-throttle=99 max-postcopy-bandwidth=0 multifd-channels=2 multifd-compression='none' multifd-zlib-level=1 multifd-zstd-level=1 subsystem='qemu' throttle-trigger-threshold=50 tls-authz='' tls-creds='' tls-hostname='' x-checkpoint-delay=20000 xbzrle-cache-size=67108864 413 | 414 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 415 | simplevm migration-status mbps='-' remaining='0' status='setup' 416 | 417 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 418 | simplevm migration-status mbps=... remaining='...' status='active' 419 | 420 | simplevm migration-status mbps=... remaining='...' status='completed' 421 | 422 | simplevm query-status arguments={} id=None subsystem='qemu/qmp' 423 | simplevm finish-migration 424 | 425 | simplevm vm-destroy-kill-supervisor attempt=1 subsystem='qemu' 426 | simplevm vm-destroy-kill-supervisor attempt=2 subsystem='qemu' 427 | simplevm vm-destroy-kill-vm attempt=1 subsystem='qemu' 428 | simplevm vm-destroy-kill-vm attempt=2 subsystem='qemu' 429 | simplevm clean-run-files subsystem='qemu' 430 | simplevm finish-remote 431 | simplevm consul-deregister 432 | simplevm outmigrate-finished exitcode=0 433 | simplevm release-lock count=0 target='/run/qemu.simplevm.lock' 434 | simplevm release-lock result='unlocked' target='/run/qemu.simplevm.lock' 435 | """ # noqa: E501 436 | ) 437 | # The migration process may take a couple of rounds to complete, 438 | # so this might appear more often: 439 | outmigration.optional( 440 | """ 441 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 442 | simplevm migration-status mbps=... remaining='...' status='active' 443 | """ # noqa: E501 444 | ) 445 | 446 | assert ( 447 | outmigration 448 | == """\ 449 | /nix/store/99xm8d2fjwlj6fvglrwpi0pz5zz8jsl1-python3.8-fc.qemu-dev/bin/fc-qemu -v outmigrate simplevm 450 | load-system-config 451 | simplevm connect-rados subsystem='ceph' 452 | simplevm acquire-lock target='/run/qemu.simplevm.lock' 453 | simplevm acquire-lock count=1 result='locked' target='/run/qemu.simplevm.lock' 454 | simplevm qmp_capabilities arguments={} id=None subsystem='qemu/qmp' 455 | simplevm query-status arguments={} id=None subsystem='qemu/qmp' 456 | simplevm outmigrate 457 | simplevm consul-register 458 | simplevm heartbeat-initialized 459 | simplevm locate-inmigration-service 460 | simplevm check-staging-config result='none' 461 | simplevm located-inmigration-service url='http://host2.mgm.test.gocept.net:43303' 462 | simplevm started-heartbeat-ping 463 | simplevm acquire-migration-locks 464 | simplevm heartbeat-ping 465 | simplevm check-staging-config result='none' 466 | simplevm acquire-migration-lock result='success' subsystem='qemu' 467 | simplevm acquire-local-migration-lock result='success' 468 | simplevm acquire-remote-migration-lock 469 | simplevm acquire-remote-migration-lock result='success' 470 | simplevm unlock subsystem='ceph' volume='rbd.ssd/simplevm.root' 471 | simplevm unlock subsystem='ceph' volume='rbd.ssd/simplevm.swap' 472 | simplevm unlock subsystem='ceph' volume='rbd.ssd/simplevm.tmp' 473 | simplevm prepare-remote-environment 474 | simplevm start-migration target='tcp:192.168.4.7:2345' 475 | simplevm migrate subsystem='qemu' 476 | simplevm migrate-set-capabilities arguments={'capabilities': [{'capability': 'xbzrle', 'state': False}, {'capability': 'auto-converge', 'state': True}]} id=None subsystem='qemu/qmp' 477 | simplevm migrate-set-parameters arguments={'compress-level': 0, 'downtime-limit': 4000, 'max-bandwidth': 22500} id=None subsystem='qemu/qmp' 478 | simplevm migrate arguments={'uri': 'tcp:192.168.4.7:2345'} id=None subsystem='qemu/qmp' 479 | simplevm query-migrate-parameters arguments={} id=None subsystem='qemu/qmp' 480 | simplevm migrate-parameters announce-initial=50 announce-max=550 announce-rounds=5 announce-step=100 block-incremental=False compress-level=0 compress-threads=8 compress-wait-thread=True cpu-throttle-increment=10 cpu-throttle-initial=20 cpu-throttle-tailslow=False decompress-threads=2 downtime-limit=4000 max-bandwidth=22500 max-cpu-throttle=99 max-postcopy-bandwidth=0 multifd-channels=2 multifd-compression='none' multifd-zlib-level=1 multifd-zstd-level=1 subsystem='qemu' throttle-trigger-threshold=50 tls-authz='' tls-creds='' tls-hostname='' x-checkpoint-delay=20000 xbzrle-cache-size=67108864 481 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 482 | simplevm migration-status mbps='-' remaining='0' status='setup' 483 | simplevm> {'blocked': False, 'status': 'setup'} 484 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 485 | simplevm migration-status mbps=0.32976 remaining='285,528,064' status='active' 486 | simplevm> {'blocked': False, 487 | simplevm> 'expected-downtime': 4000, 488 | simplevm> 'ram': {'dirty-pages-rate': 0, 489 | simplevm> 'dirty-sync-count': 1, 490 | simplevm> 'duplicate': 182, 491 | simplevm> 'mbps': 0.32976, 492 | simplevm> 'multifd-bytes': 0, 493 | simplevm> 'normal': 15, 494 | simplevm> 'normal-bytes': 61440, 495 | simplevm> 'page-size': 4096, 496 | simplevm> 'pages-per-second': 10, 497 | simplevm> 'postcopy-requests': 0, 498 | simplevm> 'remaining': 285528064, 499 | simplevm> 'skipped': 0, 500 | simplevm> 'total': 286334976, 501 | simplevm> 'transferred': 63317}, 502 | simplevm> 'setup-time': 1, 503 | simplevm> 'status': 'active', 504 | simplevm> 'total-time': 1418} 505 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 506 | simplevm migration-status mbps=0.32976 remaining='285,331,456' status='active' 507 | simplevm> {'blocked': False, 508 | simplevm> 'expected-downtime': 4000, 509 | simplevm> 'ram': {'dirty-pages-rate': 0, 510 | simplevm> 'dirty-sync-count': 1, 511 | simplevm> 'duplicate': 210, 512 | simplevm> 'mbps': 0.32976, 513 | simplevm> 'multifd-bytes': 0, 514 | simplevm> 'normal': 35, 515 | simplevm> 'normal-bytes': 143360, 516 | simplevm> 'page-size': 4096, 517 | simplevm> 'pages-per-second': 10, 518 | simplevm> 'postcopy-requests': 0, 519 | simplevm> 'remaining': 285331456, 520 | simplevm> 'skipped': 0, 521 | simplevm> 'total': 286334976, 522 | simplevm> 'transferred': 145809}, 523 | simplevm> 'setup-time': 1, 524 | simplevm> 'status': 'active', 525 | simplevm> 'total-time': 3421} 526 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 527 | simplevm migration-status mbps=0.18144 remaining='267,878,400' status='active' 528 | simplevm> {'blocked': False, 529 | simplevm> 'expected-downtime': 4000, 530 | simplevm> 'ram': {'dirty-pages-rate': 0, 531 | simplevm> 'dirty-sync-count': 1, 532 | simplevm> 'duplicate': 4460, 533 | simplevm> 'mbps': 0.18144, 534 | simplevm> 'multifd-bytes': 0, 535 | simplevm> 'normal': 46, 536 | simplevm> 'normal-bytes': 188416, 537 | simplevm> 'page-size': 4096, 538 | simplevm> 'pages-per-second': 2500, 539 | simplevm> 'postcopy-requests': 0, 540 | simplevm> 'remaining': 267878400, 541 | simplevm> 'skipped': 0, 542 | simplevm> 'total': 286334976, 543 | simplevm> 'transferred': 229427}, 544 | simplevm> 'setup-time': 1, 545 | simplevm> 'status': 'active', 546 | simplevm> 'total-time': 6253} 547 | simplevm heartbeat-ping 548 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 549 | simplevm migration-status mbps=0.18144 remaining='226,918,400' status='active' 550 | simplevm> {'blocked': False, 551 | simplevm> 'expected-downtime': 4000, 552 | simplevm> 'ram': {'dirty-pages-rate': 0, 553 | simplevm> 'dirty-sync-count': 1, 554 | simplevm> 'duplicate': 14460, 555 | simplevm> 'mbps': 0.18144, 556 | simplevm> 'multifd-bytes': 0, 557 | simplevm> 'normal': 46, 558 | simplevm> 'normal-bytes': 188416, 559 | simplevm> 'page-size': 4096, 560 | simplevm> 'pages-per-second': 2500, 561 | simplevm> 'postcopy-requests': 0, 562 | simplevm> 'remaining': 226918400, 563 | simplevm> 'skipped': 0, 564 | simplevm> 'total': 286334976, 565 | simplevm> 'transferred': 319747}, 566 | simplevm> 'setup-time': 1, 567 | simplevm> 'status': 'active', 568 | simplevm> 'total-time': 10258} 569 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 570 | simplevm migration-status mbps=0.18144 remaining='169,574,400' status='active' 571 | simplevm> {'blocked': False, 572 | simplevm> 'expected-downtime': 4000, 573 | simplevm> 'ram': {'dirty-pages-rate': 0, 574 | simplevm> 'dirty-sync-count': 1, 575 | simplevm> 'duplicate': 28460, 576 | simplevm> 'mbps': 0.18144, 577 | simplevm> 'multifd-bytes': 0, 578 | simplevm> 'normal': 46, 579 | simplevm> 'normal-bytes': 188416, 580 | simplevm> 'page-size': 4096, 581 | simplevm> 'pages-per-second': 2500, 582 | simplevm> 'postcopy-requests': 0, 583 | simplevm> 'remaining': 169574400, 584 | simplevm> 'skipped': 0, 585 | simplevm> 'total': 286334976, 586 | simplevm> 'transferred': 446195}, 587 | simplevm> 'setup-time': 1, 588 | simplevm> 'status': 'active', 589 | simplevm> 'total-time': 15917} 590 | simplevm heartbeat-ping 591 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 592 | simplevm migration-status mbps=0.18144 remaining='87,654,400' status='active' 593 | simplevm> {'blocked': False, 594 | simplevm> 'expected-downtime': 4000, 595 | simplevm> 'ram': {'dirty-pages-rate': 0, 596 | simplevm> 'dirty-sync-count': 1, 597 | simplevm> 'duplicate': 48460, 598 | simplevm> 'mbps': 0.18144, 599 | simplevm> 'multifd-bytes': 0, 600 | simplevm> 'normal': 46, 601 | simplevm> 'normal-bytes': 188416, 602 | simplevm> 'page-size': 4096, 603 | simplevm> 'pages-per-second': 2500, 604 | simplevm> 'postcopy-requests': 0, 605 | simplevm> 'remaining': 87654400, 606 | simplevm> 'skipped': 0, 607 | simplevm> 'total': 286334976, 608 | simplevm> 'transferred': 626835}, 609 | simplevm> 'setup-time': 1, 610 | simplevm> 'status': 'active', 611 | simplevm> 'total-time': 23926} 612 | simplevm heartbeat-ping 613 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 614 | simplevm migration-status mbps=0.32976 remaining='18,821,120' status='active' 615 | simplevm> {'blocked': False, 616 | simplevm> 'expected-downtime': 4000, 617 | simplevm> 'ram': {'dirty-pages-rate': 0, 618 | simplevm> 'dirty-sync-count': 1, 619 | simplevm> 'duplicate': 65218, 620 | simplevm> 'mbps': 0.32976, 621 | simplevm> 'multifd-bytes': 0, 622 | simplevm> 'normal': 93, 623 | simplevm> 'normal-bytes': 380928, 624 | simplevm> 'page-size': 4096, 625 | simplevm> 'pages-per-second': 10, 626 | simplevm> 'postcopy-requests': 0, 627 | simplevm> 'remaining': 18821120, 628 | simplevm> 'skipped': 0, 629 | simplevm> 'total': 286334976, 630 | simplevm> 'transferred': 971457}, 631 | simplevm> 'setup-time': 1, 632 | simplevm> 'status': 'active', 633 | simplevm> 'total-time': 35251} 634 | simplevm heartbeat-ping 635 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 636 | simplevm migration-status mbps=0.32976 remaining='827,392' status='active' 637 | simplevm> {'blocked': False, 638 | simplevm> 'expected-downtime': 4000, 639 | simplevm> 'ram': {'dirty-pages-rate': 0, 640 | simplevm> 'dirty-sync-count': 1, 641 | simplevm> 'duplicate': 69514, 642 | simplevm> 'mbps': 0.32976, 643 | simplevm> 'multifd-bytes': 0, 644 | simplevm> 'normal': 190, 645 | simplevm> 'normal-bytes': 778240, 646 | simplevm> 'page-size': 4096, 647 | simplevm> 'pages-per-second': 10, 648 | simplevm> 'postcopy-requests': 0, 649 | simplevm> 'remaining': 827392, 650 | simplevm> 'skipped': 0, 651 | simplevm> 'total': 286334976, 652 | simplevm> 'transferred': 1409164}, 653 | simplevm> 'setup-time': 1, 654 | simplevm> 'status': 'active', 655 | simplevm> 'total-time': 46571} 656 | simplevm heartbeat-ping 657 | simplevm heartbeat-ping 658 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 659 | simplevm migration-status mbps=0.32976 remaining='1,175,552' status='active' 660 | simplevm> {'blocked': False, 661 | simplevm> 'expected-downtime': 4000, 662 | simplevm> 'ram': {'dirty-pages-rate': 4, 663 | simplevm> 'dirty-sync-count': 2, 664 | simplevm> 'duplicate': 69594, 665 | simplevm> 'mbps': 0.32976, 666 | simplevm> 'multifd-bytes': 0, 667 | simplevm> 'normal': 303, 668 | simplevm> 'normal-bytes': 1241088, 669 | simplevm> 'page-size': 4096, 670 | simplevm> 'pages-per-second': 10, 671 | simplevm> 'postcopy-requests': 0, 672 | simplevm> 'remaining': 1175552, 673 | simplevm> 'skipped': 0, 674 | simplevm> 'total': 286334976, 675 | simplevm> 'transferred': 1874632}, 676 | simplevm> 'setup-time': 1, 677 | simplevm> 'status': 'active', 678 | simplevm> 'total-time': 57893} 679 | simplevm heartbeat-ping 680 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 681 | simplevm migration-status mbps=0.32976 remaining='172,032' status='active' 682 | simplevm> {'blocked': False, 683 | simplevm> 'expected-downtime': 4000, 684 | simplevm> 'ram': {'dirty-pages-rate': 0, 685 | simplevm> 'dirty-sync-count': 3, 686 | simplevm> 'duplicate': 69730, 687 | simplevm> 'mbps': 0.32976, 688 | simplevm> 'multifd-bytes': 0, 689 | simplevm> 'normal': 416, 690 | simplevm> 'normal-bytes': 1703936, 691 | simplevm> 'page-size': 4096, 692 | simplevm> 'pages-per-second': 10, 693 | simplevm> 'postcopy-requests': 0, 694 | simplevm> 'remaining': 172032, 695 | simplevm> 'skipped': 0, 696 | simplevm> 'total': 286334976, 697 | simplevm> 'transferred': 2340590}, 698 | simplevm> 'setup-time': 1, 699 | simplevm> 'status': 'active', 700 | simplevm> 'total-time': 69217} 701 | simplevm heartbeat-ping 702 | simplevm query-migrate arguments={} id=None subsystem='qemu/qmp' 703 | simplevm migration-status mbps=0.34051462695157925 remaining='0' status='completed' 704 | simplevm> {'blocked': False, 705 | simplevm> 'downtime': 7, 706 | simplevm> 'ram': {'dirty-pages-rate': 0, 707 | simplevm> 'dirty-sync-count': 5, 708 | simplevm> 'duplicate': 69730, 709 | simplevm> 'mbps': 0.34051462695157925, 710 | simplevm> 'multifd-bytes': 0, 711 | simplevm> 'normal': 458, 712 | simplevm> 'normal-bytes': 1875968, 713 | simplevm> 'page-size': 4096, 714 | simplevm> 'pages-per-second': 10, 715 | simplevm> 'postcopy-requests': 0, 716 | simplevm> 'remaining': 0, 717 | simplevm> 'skipped': 0, 718 | simplevm> 'total': 286334976, 719 | simplevm> 'transferred': 2512989}, 720 | simplevm> 'setup-time': 1, 721 | simplevm> 'status': 'completed', 722 | simplevm> 'total-time': 69496} 723 | simplevm query-status arguments={} id=None subsystem='qemu/qmp' 724 | simplevm finish-migration 725 | simplevm vm-destroy-kill-supervisor attempt=1 subsystem='qemu' 726 | simplevm vm-destroy-kill-supervisor attempt=2 subsystem='qemu' 727 | simplevm vm-destroy-kill-vm attempt=1 subsystem='qemu' 728 | simplevm vm-destroy-kill-vm attempt=2 subsystem='qemu' 729 | simplevm clean-run-files subsystem='qemu' 730 | simplevm finish-remote 731 | simplevm consul-deregister 732 | simplevm outmigrate-finished exitcode=0 733 | simplevm release-lock count=0 target='/run/qemu.simplevm.lock' 734 | simplevm release-lock result='unlocked' target='/run/qemu.simplevm.lock' 735 | """ # noqa: E501 736 | ) 737 | 738 | 739 | def test_html(patterns: PatternsLib) -> None: 740 | patterns.owrap.in_order( 741 | """ 742 | 743 | 744 | 745 | 746 | 747 | """ 748 | ) 749 | patterns.owrap.optional("...") 750 | # patterns.owrap.normalize("html") 751 | 752 | invoice_list = patterns.invoice_list 753 | invoice_list.merge("owrap") 754 | invoice_list.continuous( 755 | """ 756 | 757 | 758 | 2023-10-01 — 2023-10-31 759 | 760 | 761 | 10466 762 | Theune, Christian 763 | 764 | 765 | 766 | 767 | 0.00 € 768 | 769 | 770 | pending 771 | View 772 | 773 | """ 774 | ) 775 | 776 | assert ( 777 | invoice_list 778 | == """\ 779 | 780 | 781 | 783 | 784 | 785 | 786 | 790 | Generate 791 | 792 | 793 | 794 |
795 |
796 |
806 |
807 | 811 |
812 | 813 |
814 | 826 |
827 | 828 |
829 |

Status

830 | 831 |
832 | 835 |
836 |
837 | 840 |
841 |
842 | 845 |
846 |
847 | 850 |
851 |
852 | 855 |
856 |
857 | 860 |
861 | 862 |
863 | 864 |
865 |

Options

866 | 867 |
868 | 871 |
872 | 873 |
874 | 875 |
876 |
877 |
878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | 891 | 892 | 893 | 894 | 895 | 896 | 901 | 902 | 903 | 906 | 907 | 908 | 909 | 910 |
Consumption PeriodCustomerSumStatus 
1 matching invoices.
2023-10-01 — 2023-10-31 897 | 898 | 10466 899 | Theune, Christian 900 | 904 | 0.00 € 905 | pendingView
911 |
912 | 913 | 914 | """ # noqa: E501 915 | ) 916 | 917 | 918 | # def test_ring0_json_api(patterns): 919 | # ring0 = patterns.ring0 920 | 921 | # ring0.normalize("json") 922 | # ring0.optional("...") 923 | # ring0.in_order( 924 | # """ 925 | # { 926 | # "directory_password": "gfhdjk", 927 | # } 928 | # """ 929 | # ) 930 | 931 | # ring0 == { 932 | # "aliases_fe": [], 933 | # "aliases_srv": [], 934 | # "directory_password": "gfhdjk", 935 | # "profile": "generic", 936 | # "creation_date": "2014-01-02T03:04:05+00:00", 937 | # "directory_ring": 0, 938 | # "environment": "testing", 939 | # "environment_class": "Puppet", 940 | # "environment_url": "", 941 | # "kvm_net_memory": 61440, 942 | # "machine": "physical", 943 | # "servicing": True, 944 | # "location": "ny", 945 | # "frontend_ips_v4": 1, 946 | # "frontend_ips_v6": 1, 947 | # "production": True, 948 | # "service_description": "backup server", 949 | # "secrets": {}, 950 | # "secret_salt": "secret/salt", 951 | # "reverses": {"172.21.2.2": "test.gocept.net."}, 952 | # "in_transit": False, 953 | # "interfaces": { 954 | # "fe": { 955 | # "mac": "00:15:17:91:d2:f0", 956 | # "bridged": False, 957 | # "policy": "puppet", 958 | # "gateways": { 959 | # "172.21.2.0/24": "172.21.2.1", 960 | # "2002:470:9aaf:42::/64": "2002:470:9aaf:42::1", 961 | # }, 962 | # "networks": { 963 | # "2002:470:9aaf:42::/64": ["2002:470:9aaf:42::2"], 964 | # "172.21.2.0/24": ["172.21.2.2"], 965 | # }, 966 | # }, 967 | # "ipmi": { 968 | # "mac": "", 969 | # "bridged": False, 970 | # "policy": "puppet", 971 | # "gateways": {"172.21.1.0/24": "172.21.1.1"}, 972 | # "networks": {"172.21.1.0/24": ["172.21.1.2"]}, 973 | # }, 974 | # "mgm": { 975 | # "mac": "00:1e:c9:ad:4a:a6", 976 | # "bridged": False, 977 | # "policy": "puppet", 978 | # "gateways": { 979 | # "172.21.1.0/24": "172.21.1.1", 980 | # "2002:470:9aaf:41::/64": "2002:470:9aaf:41::1", 981 | # }, 982 | # "networks": { 983 | # "2002:470:9aaf:41::/64": ["2002:470:9aaf:41::2"], 984 | # "172.21.1.0/24": ["172.21.1.3"], 985 | # }, 986 | # }, 987 | # "srv": { 988 | # "mac": "00:1e:c9:ad:4a:a0", 989 | # "bridged": False, 990 | # "policy": "puppet", 991 | # "gateways": { 992 | # "172.21.3.0/24": "172.21.3.1", 993 | # "2002:470:9aaf:43::/64": "2002:470:9aaf:43::1", 994 | # }, 995 | # "networks": { 996 | # "2002:470:9aaf:43::/64": ["2002:470:9aaf:43::2"], 997 | # "172.21.3.0/24": ["172.21.3.2"], 998 | # }, 999 | # }, 1000 | # "sto": { 1001 | # "mac": "00:1e:c9:ad:4a:a2", 1002 | # "bridged": False, 1003 | # "policy": "puppet", 1004 | # "gateways": { 1005 | # "172.21.4.0/24": "172.21.4.1", 1006 | # "2002:470:9aaf:44::/64": "2002:470:9aaf:44::1", 1007 | # }, 1008 | # "networks": { 1009 | # "2002:470:9aaf:44::/64": ["2002:470:9aaf:44::2"], 1010 | # "172.21.4.0/24": ["172.21.4.2"], 1011 | # }, 1012 | # }, 1013 | # }, 1014 | # "rack": "OB 4 DA", 1015 | # "resource_group": "services", 1016 | # "resource_group_parent": "", 1017 | # "timezone": "UTC", 1018 | # "id": 4100, 1019 | # "nixos_configs": {}, 1020 | # } 1021 | -------------------------------------------------------------------------------- /tests/test_edge_cases.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pytest_patterns.plugin import PatternsLib 4 | 5 | GENERIC_HEADER = [ 6 | "String did not meet the expectations.", 7 | "", 8 | "🟢=EXPECTED | ⚪️=OPTIONAL | 🟡=UNEXPECTED | 🔴=REFUSED/UNMATCHED", 9 | "", 10 | "Here is the string that was tested: ", 11 | "", 12 | ] 13 | 14 | 15 | def test_ical_ordering_produces_reasonable_reports( 16 | patterns: PatternsLib, 17 | ) -> None: 18 | with (Path(__file__).parent / "fixtures" / "ical-ordering.ical").open( 19 | "r" 20 | ) as f: 21 | test_data = f.read() 22 | 23 | # We used this pattern and got a weird match originally. Here's what 24 | # we expect it to look like: 25 | p = patterns.schedule 26 | p.in_order( 27 | """ 28 | BEGIN:VCALENDAR\r 29 | VERSION:2.0\r 30 | PRODID:-//fc.support//fcio//\r 31 | 32 | BEGIN:VEVENT 33 | SUMMARY:cedric (1\\, platform) 34 | DTSTART;VALUE=DATE:20110201 35 | DTEND;VALUE=DATE:20110201 36 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 37 | UID:46f2574f64d22dc70db2379b08d83c0f51305c0729cb0d0e8852208b0deb4d87 38 | END:VEVENT 39 | 40 | BEGIN:VEVENT 41 | SUMMARY:cedric (1\\, platform) 42 | DTSTART;VALUE=DATE:20110202 43 | DTEND;VALUE=DATE:20110202 44 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 45 | UID:0b3f15fedfdf1c627afd2efd990b8c52e7f8822eebf62767e38b33cd5763d5c9 46 | END:VEVENT 47 | 48 | BEGIN:VEVENT 49 | SUMMARY:cedric (1\\, platform) 50 | DTSTART;VALUE=DATE:20110203 51 | DTEND;VALUE=DATE:20110203 52 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 53 | UID:a1d00464af0a25c9292aa72d0bf5236b5749d5e42c13e484080d2abbfb6ad89e 54 | END:VEVENT 55 | 56 | BEGIN:VEVENT 57 | SUMMARY:cedric (1\\, platform) 58 | DTSTART;VALUE=DATE:20110204 59 | DTEND;VALUE=DATE:20110204 60 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 61 | UID:4d6e48685c893ddf8856c8439f4d6bde05fbe603118fa68ebd7d3f37b4e3e6ed 62 | END:VEVENT 63 | 64 | BEGIN:VEVENT 65 | SUMMARY:alice (1\\, platform) 66 | DTSTART;VALUE=DATE:20110205 67 | DTEND;VALUE=DATE:20110205 68 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 69 | UID:31638940439cb48412420108cbb265a34baf2714075df401723bf82da0586743 70 | END:VEVENT 71 | 72 | BEGIN:VEVENT 73 | SUMMARY:alice (1\\, platform) 74 | DTSTART;VALUE=DATE:20110206 75 | DTEND;VALUE=DATE:20110206 76 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 77 | UID:8288d3266bf9cef59ddeafd8ec03d58bc0314dfe9e825617c9de90f856361eff 78 | END:VEVENT 79 | 80 | BEGIN:VEVENT 81 | SUMMARY:alice (1\\, appops) 82 | DTSTART;VALUE=DATE:20110201 83 | DTEND;VALUE=DATE:20110201 84 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 85 | UID:36a6983685c626a8255f480ec59930ea6f38a257ba79460982149461c733506a 86 | END:VEVENT 87 | 88 | BEGIN:VEVENT 89 | SUMMARY:bob (2\\, appops) 90 | DTSTART;VALUE=DATE:20110201 91 | DTEND;VALUE=DATE:20110201 92 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 93 | UID:42d5f543bdbb6f589bd19a893fd209fe89f4558c7be5ebcb93bcd12ba9ae9161 94 | END:VEVENT 95 | 96 | BEGIN:VEVENT 97 | SUMMARY:alice (1\\, appops) 98 | DTSTART;VALUE=DATE:20110202 99 | DTEND;VALUE=DATE:20110202 100 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 101 | UID:6db22f58ee442b51955546b6b617c2e39c07acc0a77b1dcc4230a04d63dc08a4 102 | END:VEVENT 103 | 104 | BEGIN:VEVENT 105 | SUMMARY:bob (2\\, appops) 106 | DTSTART;VALUE=DATE:20110202 107 | DTEND;VALUE=DATE:20110202 108 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 109 | UID:92100821b84973eccd2fb036c068bd405698af95e16f4420341940e5cc5ac148 110 | END:VEVENT 111 | 112 | BEGIN:VEVENT 113 | SUMMARY:alice (1\\, appops) 114 | DTSTART;VALUE=DATE:20110203 115 | DTEND;VALUE=DATE:20110203 116 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 117 | UID:bd1b1d019cfdff07456a7be437ecc6c7027f8c4ec6904e65c6f297a52f3eee14 118 | END:VEVENT 119 | 120 | BEGIN:VEVENT 121 | SUMMARY:bob (2\\, appops) 122 | DTSTART;VALUE=DATE:20110203 123 | DTEND;VALUE=DATE:20110203 124 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 125 | UID:becfafc6c131961b1d8913f3109aef3af1b6142bdbbc4e4642503fcd1cce05a6 126 | END:VEVENT 127 | 128 | BEGIN:VEVENT 129 | SUMMARY:alice (1\\, appops) 130 | DTSTART;VALUE=DATE:20110204 131 | DTEND;VALUE=DATE:20110204 132 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 133 | UID:834e1ddc937baae355d08f8960967466baab83f172d5f967a49083550cbd9e06 134 | END:VEVENT 135 | 136 | BEGIN:VEVENT 137 | SUMMARY:bob (2\\, appops) 138 | DTSTART;VALUE=DATE:20110204 139 | DTEND;VALUE=DATE:20110204 140 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 141 | UID:08e07e2b92b3fbb3264abd48e1aa1983962626902630beb6a5b4d5fece22a7da 142 | END:VEVENT 143 | 144 | BEGIN:VEVENT 145 | SUMMARY:bob (1\\, appops) 146 | DTSTART;VALUE=DATE:20110205 147 | DTEND;VALUE=DATE:20110205 148 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 149 | UID:16f138f3f88c54fe5a6e248de42f6a8e3a9b0c3f941c9f9c760b9aa639c1d457 150 | END:VEVENT 151 | 152 | BEGIN:VEVENT 153 | SUMMARY:bob (1\\, appops) 154 | DTSTART;VALUE=DATE:20110206 155 | DTEND;VALUE=DATE:20110206 156 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z 157 | UID:0afbce38bf534b30031618c9a7c0f884b8ae69ccfe99d32753790edfab033405 158 | END:VEVENT 159 | 160 | END:VCALENDAR\r 161 | """ 162 | ) 163 | 164 | audit = p._audit(test_data) 165 | assert list(audit.report()) == [ 166 | *GENERIC_HEADER, 167 | "🟢 schedule | BEGIN:VCALENDAR", 168 | "🟢 schedule | VERSION:2.0", 169 | "🟢 schedule | PRODID:-//fc.support//fcio//", 170 | "🟢 schedule | BEGIN:VEVENT", 171 | "🟡 | SUMMARY:alice␠(1\\,␠appops)", 172 | "🟡 | DTSTART;VALUE=DATE:20110201", 173 | "🟡 | DTEND;VALUE=DATE:20110201", 174 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 175 | "🟡 | " 176 | "UID:36a6983685c626a8255f480ec59930ea6f38a257ba79460982149461c733506a", 177 | "🟡 | END:VEVENT", 178 | "🟡 | BEGIN:VEVENT", 179 | "🟡 | SUMMARY:bob␠(2\\,␠appops)", 180 | "🟡 | DTSTART;VALUE=DATE:20110201", 181 | "🟡 | DTEND;VALUE=DATE:20110201", 182 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 183 | "🟡 | " 184 | "UID:42d5f543bdbb6f589bd19a893fd209fe89f4558c7be5ebcb93bcd12ba9ae9161", 185 | "🟡 | END:VEVENT", 186 | "🟡 | BEGIN:VEVENT", 187 | "🟡 | SUMMARY:alice␠(1\\,␠appops)", 188 | "🟡 | DTSTART;VALUE=DATE:20110202", 189 | "🟡 | DTEND;VALUE=DATE:20110202", 190 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 191 | "🟡 | " 192 | "UID:6db22f58ee442b51955546b6b617c2e39c07acc0a77b1dcc4230a04d63dc08a4", 193 | "🟡 | END:VEVENT", 194 | "🟡 | BEGIN:VEVENT", 195 | "🟡 | SUMMARY:bob␠(2\\,␠appops)", 196 | "🟡 | DTSTART;VALUE=DATE:20110202", 197 | "🟡 | DTEND;VALUE=DATE:20110202", 198 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 199 | "🟡 | " 200 | "UID:92100821b84973eccd2fb036c068bd405698af95e16f4420341940e5cc5ac148", 201 | "🟡 | END:VEVENT", 202 | "🟡 | BEGIN:VEVENT", 203 | "🟡 | SUMMARY:alice␠(1\\,␠appops)", 204 | "🟡 | DTSTART;VALUE=DATE:20110203", 205 | "🟡 | DTEND;VALUE=DATE:20110203", 206 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 207 | "🟡 | " 208 | "UID:bd1b1d019cfdff07456a7be437ecc6c7027f8c4ec6904e65c6f297a52f3eee14", 209 | "🟡 | END:VEVENT", 210 | "🟡 | BEGIN:VEVENT", 211 | "🟡 | SUMMARY:bob␠(2\\,␠appops)", 212 | "🟡 | DTSTART;VALUE=DATE:20110203", 213 | "🟡 | DTEND;VALUE=DATE:20110203", 214 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 215 | "🟡 | " 216 | "UID:becfafc6c131961b1d8913f3109aef3af1b6142bdbbc4e4642503fcd1cce05a6", 217 | "🟡 | END:VEVENT", 218 | "🟡 | BEGIN:VEVENT", 219 | "🟡 | SUMMARY:alice␠(1\\,␠appops)", 220 | "🟡 | DTSTART;VALUE=DATE:20110204", 221 | "🟡 | DTEND;VALUE=DATE:20110204", 222 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 223 | "🟡 | " 224 | "UID:834e1ddc937baae355d08f8960967466baab83f172d5f967a49083550cbd9e06", 225 | "🟡 | END:VEVENT", 226 | "🟡 | BEGIN:VEVENT", 227 | "🟡 | SUMMARY:bob␠(2\\,␠appops)", 228 | "🟡 | DTSTART;VALUE=DATE:20110204", 229 | "🟡 | DTEND;VALUE=DATE:20110204", 230 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 231 | "🟡 | " 232 | "UID:08e07e2b92b3fbb3264abd48e1aa1983962626902630beb6a5b4d5fece22a7da", 233 | "🟡 | END:VEVENT", 234 | "🟡 | BEGIN:VEVENT", 235 | "🟡 | SUMMARY:bob␠(1\\,␠appops)", 236 | "🟡 | DTSTART;VALUE=DATE:20110205", 237 | "🟡 | DTEND;VALUE=DATE:20110205", 238 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 239 | "🟡 | " 240 | "UID:16f138f3f88c54fe5a6e248de42f6a8e3a9b0c3f941c9f9c760b9aa639c1d457", 241 | "🟡 | END:VEVENT", 242 | "🟡 | BEGIN:VEVENT", 243 | "🟡 | SUMMARY:bob␠(1\\,␠appops)", 244 | "🟡 | DTSTART;VALUE=DATE:20110206", 245 | "🟡 | DTEND;VALUE=DATE:20110206", 246 | "🟡 | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 247 | "🟡 | " 248 | "UID:0afbce38bf534b30031618c9a7c0f884b8ae69ccfe99d32753790edfab033405", 249 | "🟡 | END:VEVENT", 250 | "🟡 | BEGIN:VEVENT", 251 | "🟢 schedule | SUMMARY:cedric (1\\, platform)", 252 | "🟢 schedule | DTSTART;VALUE=DATE:20110201", 253 | "🟢 schedule | DTEND;VALUE=DATE:20110201", 254 | "🟢 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 255 | "🟢 schedule | " 256 | "UID:46f2574f64d22dc70db2379b08d83c0f51305c0729cb0d0e8852208b0deb4d87", 257 | "🟢 schedule | END:VEVENT", 258 | "🟢 schedule | BEGIN:VEVENT", 259 | "🟢 schedule | SUMMARY:cedric (1\\, platform)", 260 | "🟢 schedule | DTSTART;VALUE=DATE:20110202", 261 | "🟢 schedule | DTEND;VALUE=DATE:20110202", 262 | "🟢 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 263 | "🟢 schedule | " 264 | "UID:0b3f15fedfdf1c627afd2efd990b8c52e7f8822eebf62767e38b33cd5763d5c9", 265 | "🟢 schedule | END:VEVENT", 266 | "🟢 schedule | BEGIN:VEVENT", 267 | "🟢 schedule | SUMMARY:cedric (1\\, platform)", 268 | "🟢 schedule | DTSTART;VALUE=DATE:20110203", 269 | "🟢 schedule | DTEND;VALUE=DATE:20110203", 270 | "🟢 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 271 | "🟢 schedule | " 272 | "UID:a1d00464af0a25c9292aa72d0bf5236b5749d5e42c13e484080d2abbfb6ad89e", 273 | "🟢 schedule | END:VEVENT", 274 | "🟢 schedule | BEGIN:VEVENT", 275 | "🟢 schedule | SUMMARY:cedric (1\\, platform)", 276 | "🟢 schedule | DTSTART;VALUE=DATE:20110204", 277 | "🟢 schedule | DTEND;VALUE=DATE:20110204", 278 | "🟢 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 279 | "🟢 schedule | " 280 | "UID:4d6e48685c893ddf8856c8439f4d6bde05fbe603118fa68ebd7d3f37b4e3e6ed", 281 | "🟢 schedule | END:VEVENT", 282 | "🟢 schedule | BEGIN:VEVENT", 283 | "🟢 schedule | SUMMARY:alice (1\\, platform)", 284 | "🟢 schedule | DTSTART;VALUE=DATE:20110205", 285 | "🟢 schedule | DTEND;VALUE=DATE:20110205", 286 | "🟢 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 287 | "🟢 schedule | " 288 | "UID:31638940439cb48412420108cbb265a34baf2714075df401723bf82da0586743", 289 | "🟢 schedule | END:VEVENT", 290 | "🟢 schedule | BEGIN:VEVENT", 291 | "🟢 schedule | SUMMARY:alice (1\\, platform)", 292 | "🟢 schedule | DTSTART;VALUE=DATE:20110206", 293 | "🟢 schedule | DTEND;VALUE=DATE:20110206", 294 | "🟢 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 295 | "🟢 schedule | " 296 | "UID:8288d3266bf9cef59ddeafd8ec03d58bc0314dfe9e825617c9de90f856361eff", 297 | "🟢 schedule | END:VEVENT", 298 | "🟡 | END:VCALENDAR", 299 | "", 300 | "These are the unmatched expected lines: ", 301 | "", 302 | "🔴 schedule | BEGIN:VEVENT", 303 | "🔴 schedule | SUMMARY:alice␠(1\\,␠appops)", 304 | "🔴 schedule | DTSTART;VALUE=DATE:20110201", 305 | "🔴 schedule | DTEND;VALUE=DATE:20110201", 306 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 307 | "🔴 schedule | " 308 | "UID:36a6983685c626a8255f480ec59930ea6f38a257ba79460982149461c733506a", 309 | "🔴 schedule | END:VEVENT", 310 | "🔴 schedule | BEGIN:VEVENT", 311 | "🔴 schedule | SUMMARY:bob␠(2\\,␠appops)", 312 | "🔴 schedule | DTSTART;VALUE=DATE:20110201", 313 | "🔴 schedule | DTEND;VALUE=DATE:20110201", 314 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 315 | "🔴 schedule | " 316 | "UID:42d5f543bdbb6f589bd19a893fd209fe89f4558c7be5ebcb93bcd12ba9ae9161", 317 | "🔴 schedule | END:VEVENT", 318 | "🔴 schedule | BEGIN:VEVENT", 319 | "🔴 schedule | SUMMARY:alice␠(1\\,␠appops)", 320 | "🔴 schedule | DTSTART;VALUE=DATE:20110202", 321 | "🔴 schedule | DTEND;VALUE=DATE:20110202", 322 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 323 | "🔴 schedule | " 324 | "UID:6db22f58ee442b51955546b6b617c2e39c07acc0a77b1dcc4230a04d63dc08a4", 325 | "🔴 schedule | END:VEVENT", 326 | "🔴 schedule | BEGIN:VEVENT", 327 | "🔴 schedule | SUMMARY:bob␠(2\\,␠appops)", 328 | "🔴 schedule | DTSTART;VALUE=DATE:20110202", 329 | "🔴 schedule | DTEND;VALUE=DATE:20110202", 330 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 331 | "🔴 schedule | " 332 | "UID:92100821b84973eccd2fb036c068bd405698af95e16f4420341940e5cc5ac148", 333 | "🔴 schedule | END:VEVENT", 334 | "🔴 schedule | BEGIN:VEVENT", 335 | "🔴 schedule | SUMMARY:alice␠(1\\,␠appops)", 336 | "🔴 schedule | DTSTART;VALUE=DATE:20110203", 337 | "🔴 schedule | DTEND;VALUE=DATE:20110203", 338 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 339 | "🔴 schedule | " 340 | "UID:bd1b1d019cfdff07456a7be437ecc6c7027f8c4ec6904e65c6f297a52f3eee14", 341 | "🔴 schedule | END:VEVENT", 342 | "🔴 schedule | BEGIN:VEVENT", 343 | "🔴 schedule | SUMMARY:bob␠(2\\,␠appops)", 344 | "🔴 schedule | DTSTART;VALUE=DATE:20110203", 345 | "🔴 schedule | DTEND;VALUE=DATE:20110203", 346 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 347 | "🔴 schedule | " 348 | "UID:becfafc6c131961b1d8913f3109aef3af1b6142bdbbc4e4642503fcd1cce05a6", 349 | "🔴 schedule | END:VEVENT", 350 | "🔴 schedule | BEGIN:VEVENT", 351 | "🔴 schedule | SUMMARY:alice␠(1\\,␠appops)", 352 | "🔴 schedule | DTSTART;VALUE=DATE:20110204", 353 | "🔴 schedule | DTEND;VALUE=DATE:20110204", 354 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 355 | "🔴 schedule | " 356 | "UID:834e1ddc937baae355d08f8960967466baab83f172d5f967a49083550cbd9e06", 357 | "🔴 schedule | END:VEVENT", 358 | "🔴 schedule | BEGIN:VEVENT", 359 | "🔴 schedule | SUMMARY:bob␠(2\\,␠appops)", 360 | "🔴 schedule | DTSTART;VALUE=DATE:20110204", 361 | "🔴 schedule | DTEND;VALUE=DATE:20110204", 362 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 363 | "🔴 schedule | " 364 | "UID:08e07e2b92b3fbb3264abd48e1aa1983962626902630beb6a5b4d5fece22a7da", 365 | "🔴 schedule | END:VEVENT", 366 | "🔴 schedule | BEGIN:VEVENT", 367 | "🔴 schedule | SUMMARY:bob␠(1\\,␠appops)", 368 | "🔴 schedule | DTSTART;VALUE=DATE:20110205", 369 | "🔴 schedule | DTEND;VALUE=DATE:20110205", 370 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 371 | "🔴 schedule | " 372 | "UID:16f138f3f88c54fe5a6e248de42f6a8e3a9b0c3f941c9f9c760b9aa639c1d457", 373 | "🔴 schedule | END:VEVENT", 374 | "🔴 schedule | BEGIN:VEVENT", 375 | "🔴 schedule | SUMMARY:bob␠(1\\,␠appops)", 376 | "🔴 schedule | DTSTART;VALUE=DATE:20110206", 377 | "🔴 schedule | DTEND;VALUE=DATE:20110206", 378 | "🔴 schedule | DTSTAMP;VALUE=DATE-TIME:19700101T000140Z", 379 | "🔴 schedule | " 380 | "UID:0afbce38bf534b30031618c9a7c0f884b8ae69ccfe99d32753790edfab033405", 381 | "🔴 schedule | END:VEVENT", 382 | "🔴 schedule | END:VCALENDAR", 383 | ] 384 | assert not audit.is_ok() 385 | --------------------------------------------------------------------------------