pychrony 2.0.2__tar.gz → 2.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. {pychrony-2.0.2 → pychrony-2.0.3}/.github/workflows/ci.yml +9 -7
  2. {pychrony-2.0.2 → pychrony-2.0.3}/.github/workflows/docs.yml +1 -1
  3. {pychrony-2.0.2 → pychrony-2.0.3}/.github/workflows/release.yml +6 -6
  4. {pychrony-2.0.2/src/pychrony.egg-info → pychrony-2.0.3}/PKG-INFO +1 -1
  5. {pychrony-2.0.2 → pychrony-2.0.3}/TESTING.md +26 -0
  6. {pychrony-2.0.2 → pychrony-2.0.3}/docker/Dockerfile.test +6 -1
  7. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/_bindings.py +80 -25
  8. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/_build_bindings.py +1 -0
  9. {pychrony-2.0.2 → pychrony-2.0.3/src/pychrony.egg-info}/PKG-INFO +1 -1
  10. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/SOURCES.txt +1 -0
  11. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/scm_file_list.json +1 -0
  12. pychrony-2.0.3/src/pychrony.egg-info/scm_version.json +8 -0
  13. {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/config.py +4 -0
  14. {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/session.py +64 -24
  15. pychrony-2.0.3/tests/unit/test_source_list_changes.py +143 -0
  16. {pychrony-2.0.2 → pychrony-2.0.3}/uv.lock +41 -41
  17. pychrony-2.0.2/src/pychrony.egg-info/scm_version.json +0 -8
  18. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.analyze.md +0 -0
  19. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.checklist.md +0 -0
  20. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.clarify.md +0 -0
  21. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.constitution.md +0 -0
  22. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.implement.md +0 -0
  23. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.plan.md +0 -0
  24. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.specify.md +0 -0
  25. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.tasks.md +0 -0
  26. {pychrony-2.0.2 → pychrony-2.0.3}/.claude/commands/speckit.taskstoissues.md +0 -0
  27. {pychrony-2.0.2 → pychrony-2.0.3}/.dockerignore +0 -0
  28. {pychrony-2.0.2 → pychrony-2.0.3}/.github/dependabot.yml +0 -0
  29. {pychrony-2.0.2 → pychrony-2.0.3}/.gitignore +0 -0
  30. {pychrony-2.0.2 → pychrony-2.0.3}/.gitmodules +0 -0
  31. {pychrony-2.0.2 → pychrony-2.0.3}/.python-version +0 -0
  32. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/memory/constitution.md +0 -0
  33. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/check-prerequisites.sh +0 -0
  34. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/common.sh +0 -0
  35. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/create-new-feature.sh +0 -0
  36. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/execute-task-generation.sh +0 -0
  37. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/setup-plan.sh +0 -0
  38. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/scripts/bash/update-agent-context.sh +0 -0
  39. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/agent-file-template.md +0 -0
  40. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/checklist-template.md +0 -0
  41. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/plan-template.md +0 -0
  42. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/spec-template.md +0 -0
  43. {pychrony-2.0.2 → pychrony-2.0.3}/.specify/templates/tasks-template.md +0 -0
  44. {pychrony-2.0.2 → pychrony-2.0.3}/CLAUDE.md +0 -0
  45. {pychrony-2.0.2 → pychrony-2.0.3}/LICENSE +0 -0
  46. {pychrony-2.0.2 → pychrony-2.0.3}/LICENSES/LGPL-2.1-or-later.txt +0 -0
  47. {pychrony-2.0.2 → pychrony-2.0.3}/README.md +0 -0
  48. {pychrony-2.0.2 → pychrony-2.0.3}/docker/docker-compose.test.yml +0 -0
  49. {pychrony-2.0.2 → pychrony-2.0.3}/docs/api/index.md +0 -0
  50. {pychrony-2.0.2 → pychrony-2.0.3}/docs/index.md +0 -0
  51. {pychrony-2.0.2 → pychrony-2.0.3}/mkdocs.yml +0 -0
  52. {pychrony-2.0.2 → pychrony-2.0.3}/pyproject.toml +0 -0
  53. {pychrony-2.0.2 → pychrony-2.0.3}/setup.cfg +0 -0
  54. {pychrony-2.0.2 → pychrony-2.0.3}/setup.py +0 -0
  55. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/checklists/requirements.md +0 -0
  56. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/contracts/package.md +0 -0
  57. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/data-model.md +0 -0
  58. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/plan.md +0 -0
  59. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/quickstart.md +0 -0
  60. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/research.md +0 -0
  61. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/spec.md +0 -0
  62. {pychrony-2.0.2 → pychrony-2.0.3}/specs/001-python-scaffold/tasks.md +0 -0
  63. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/checklists/requirements.md +0 -0
  64. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/contracts/python-api.md +0 -0
  65. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/data-model.md +0 -0
  66. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/plan.md +0 -0
  67. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/quickstart.md +0 -0
  68. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/research.md +0 -0
  69. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/spec.md +0 -0
  70. {pychrony-2.0.2 → pychrony-2.0.3}/specs/002-tracking-binding/tasks.md +0 -0
  71. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/checklists/requirements.md +0 -0
  72. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/contracts/api.md +0 -0
  73. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/contracts/python-api.md +0 -0
  74. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/data-model.md +0 -0
  75. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/plan.md +0 -0
  76. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/quickstart.md +0 -0
  77. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/research.md +0 -0
  78. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/spec.md +0 -0
  79. {pychrony-2.0.2 → pychrony-2.0.3}/specs/003-multiple-reports-bindings/tasks.md +0 -0
  80. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/checklists/requirements.md +0 -0
  81. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/data-model.md +0 -0
  82. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/plan.md +0 -0
  83. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/quickstart.md +0 -0
  84. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/research.md +0 -0
  85. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/spec.md +0 -0
  86. {pychrony-2.0.2 → pychrony-2.0.3}/specs/004-categorical-enums/tasks.md +0 -0
  87. {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/checklists/requirements.md +0 -0
  88. {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/plan.md +0 -0
  89. {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/quickstart.md +0 -0
  90. {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/research.md +0 -0
  91. {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/spec.md +0 -0
  92. {pychrony-2.0.2 → pychrony-2.0.3}/specs/005-pypi-packaging/tasks.md +0 -0
  93. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/analysis-report.md +0 -0
  94. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/checklists/requirements.md +0 -0
  95. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/contracts/mock-api.md +0 -0
  96. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/data-model.md +0 -0
  97. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/plan.md +0 -0
  98. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/quickstart.md +0 -0
  99. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/research.md +0 -0
  100. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/spec.md +0 -0
  101. {pychrony-2.0.2 → pychrony-2.0.3}/specs/006-protocol-test-mocks/tasks.md +0 -0
  102. {pychrony-2.0.2 → pychrony-2.0.3}/specs/007-api-docs-github-pages/tasks.md +0 -0
  103. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/__init__.py +0 -0
  104. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/__init__.py +0 -0
  105. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/_core/_fields.py +0 -0
  106. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/exceptions.py +0 -0
  107. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/models.py +0 -0
  108. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/py.typed +0 -0
  109. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony/testing.py +0 -0
  110. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/dependency_links.txt +0 -0
  111. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/entry_points.txt +0 -0
  112. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/requires.txt +0 -0
  113. {pychrony-2.0.2 → pychrony-2.0.3}/src/pychrony.egg-info/top_level.txt +0 -0
  114. {pychrony-2.0.2 → pychrony-2.0.3}/tests/__init__.py +0 -0
  115. {pychrony-2.0.2 → pychrony-2.0.3}/tests/conftest.py +0 -0
  116. {pychrony-2.0.2 → pychrony-2.0.3}/tests/contract/__init__.py +0 -0
  117. {pychrony-2.0.2 → pychrony-2.0.3}/tests/contract/test_api.py +0 -0
  118. {pychrony-2.0.2 → pychrony-2.0.3}/tests/contract/test_api_stability.py +0 -0
  119. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/__init__.py +0 -0
  120. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/conftest.py +0 -0
  121. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_connection.py +0 -0
  122. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_enums.py +0 -0
  123. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_rtcdata.py +0 -0
  124. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_sources.py +0 -0
  125. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_sourcestats.py +0 -0
  126. {pychrony-2.0.2 → pychrony-2.0.3}/tests/integration/test_tracking.py +0 -0
  127. {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/__init__.py +0 -0
  128. {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/context.py +0 -0
  129. {pychrony-2.0.2 → pychrony-2.0.3}/tests/mocks/scenarios.py +0 -0
  130. {pychrony-2.0.2 → pychrony-2.0.3}/tests/test_import.py +0 -0
  131. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/__init__.py +0 -0
  132. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_connection.py +0 -0
  133. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_enums.py +0 -0
  134. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_exceptions.py +0 -0
  135. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_leap_scenarios.py +0 -0
  136. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_mock_config.py +0 -0
  137. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_mock_scenarios.py +0 -0
  138. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_models.py +0 -0
  139. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_rtc_scenarios.py +0 -0
  140. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_source_scenarios.py +0 -0
  141. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_testing.py +0 -0
  142. {pychrony-2.0.2 → pychrony-2.0.3}/tests/unit/test_validation.py +0 -0
@@ -36,7 +36,7 @@ jobs:
36
36
  persist-credentials: false
37
37
 
38
38
  - name: Install uv
39
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
39
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
40
40
  with:
41
41
  enable-cache: true
42
42
  python-version: ${{ matrix.python-version }}
@@ -59,7 +59,7 @@ jobs:
59
59
  persist-credentials: false
60
60
 
61
61
  - name: Install uv
62
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
62
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
63
63
  with:
64
64
  enable-cache: true
65
65
  python-version: "3.12"
@@ -87,7 +87,7 @@ jobs:
87
87
  persist-credentials: false
88
88
 
89
89
  - name: Install uv
90
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
90
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
91
91
  with:
92
92
  enable-cache: true
93
93
  python-version: "3.12"
@@ -127,10 +127,12 @@ jobs:
127
127
  cache-from: type=gha
128
128
  cache-to: type=gha,mode=max
129
129
 
130
- # chronyd answers commands before it has resolved its pool and server
131
- # names, and the source list grows as each name resolves. Tests that
130
+ # chronyd answers commands before it has resolved its server name, and
131
+ # the source list changes as the name resolves. Tests that
132
132
  # walk the source list race that growth, so wait until no source has an
133
133
  # unresolved address. chronyc also fails until the command socket is up.
134
+ # The 40s limit covers one failed DNS lookup: chronyd retries unresolved
135
+ # names 28s after its first attempt.
134
136
  - name: Run tests
135
137
  run: |
136
138
  docker run --rm --cap-add=SYS_TIME pychrony-test:latest sh -c '
@@ -138,8 +140,8 @@ jobs:
138
140
  i=0
139
141
  until chronyc -n activity 2>/dev/null | grep -q "^0 sources with unknown address"; do
140
142
  i=$((i + 1))
141
- if [ "$i" -ge 40 ]; then
142
- echo "chronyd sources not resolved after 20s"
143
+ if [ "$i" -ge 80 ]; then
144
+ echo "chronyd sources not resolved after 40s"
143
145
  chronyc -n activity
144
146
  exit 1
145
147
  fi
@@ -29,7 +29,7 @@ jobs:
29
29
  persist-credentials: true
30
30
 
31
31
  - name: Install UV
32
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
32
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
33
33
  with:
34
34
  # A deploy job must not restore a cache that a PR run could have written.
35
35
  enable-cache: false
@@ -35,14 +35,14 @@ jobs:
35
35
  persist-credentials: false
36
36
 
37
37
  - name: Build wheels with cibuildwheel
38
- uses: pypa/cibuildwheel@e090b81e30c4d855ea63bf4b6e59204c09a101ae # v4.2.1
38
+ uses: pypa/cibuildwheel@adba99c781d3746d38b3a09b1cf797965bcb7fe6 # v4.3.0
39
39
  with:
40
40
  extras: uv
41
41
  env:
42
42
  CIBW_ARCHS_LINUX: ${{ matrix.arch }}
43
43
 
44
44
  - name: Upload wheel artifacts
45
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
45
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
46
46
  with:
47
47
  name: wheels-${{ matrix.arch }}
48
48
  path: wheelhouse/*.whl
@@ -59,7 +59,7 @@ jobs:
59
59
  persist-credentials: false
60
60
 
61
61
  - name: Install UV
62
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
62
+ uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
63
63
  with:
64
64
  # The sdist ships to PyPI, so it must not build from a cache that a PR
65
65
  # run could have written.
@@ -69,7 +69,7 @@ jobs:
69
69
  run: uv build --sdist
70
70
 
71
71
  - name: Upload sdist artifact
72
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
72
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
73
73
  with:
74
74
  name: sdist
75
75
  path: dist/*.tar.gz
@@ -86,7 +86,7 @@ jobs:
86
86
 
87
87
  steps:
88
88
  - name: Download all artifacts
89
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
89
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
90
90
  with:
91
91
  path: dist/
92
92
  merge-multiple: true
@@ -169,7 +169,7 @@ jobs:
169
169
 
170
170
  steps:
171
171
  - name: Download all artifacts
172
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
172
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
173
173
  with:
174
174
  path: dist/
175
175
  merge-multiple: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pychrony
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Python bindings for chrony NTP client
5
5
  Author-email: arunderwood <arunderwood@users.noreply.github.com>
6
6
  License-Expression: MIT AND LGPL-2.1-or-later
@@ -238,6 +238,7 @@ Key fields:
238
238
  - `reference_id`: Reference identifier (uint32)
239
239
  - `leap_status`: `LeapStatus` enum value
240
240
  - `sources`: List of `SourceConfig` instances
241
+ - `source_list_changes`: Source lists chronyd moves to, one per source count it answers (see [Source List Changes](#source-list-changes))
241
242
  - `rtc`: Optional `RTCConfig` for RTC data
242
243
  - `error_injection`: Dict for injecting errors
243
244
 
@@ -341,6 +342,31 @@ def test_permission_error():
341
342
  pass
342
343
  ```
343
344
 
345
+ ### Source List Changes
346
+
347
+ chronyd adds and removes sources while it resolves `pool` and `server` names,
348
+ and when it replaces unreachable pool servers. A record count can then be
349
+ stale by the time a record is requested. The mock models this the way libchrony
350
+ behaves: the count is fixed when its response arrives, and a record request past
351
+ the end of the current list fails with `CHRONY_UNEXPECTED_STATUS`.
352
+
353
+ `source_list_changes` holds the lists chronyd moves to, in order. The mock takes
354
+ the next one right after it answers each `sources` or `sourcestats` count:
355
+
356
+ ```python
357
+ config = ChronyStateConfig(
358
+ sources=[SourceConfig(address=a) for a in ("10.0.0.1", "10.0.0.2", "10.0.0.3")],
359
+ # After the first count (3), one source is gone: record 2 no longer exists.
360
+ source_list_changes=[[SourceConfig(address=a) for a in ("10.0.0.1", "10.0.0.2")]],
361
+ )
362
+
363
+ with patched_chrony_connection(config) as conn:
364
+ assert len(conn.get_sources()) == 2
365
+ ```
366
+
367
+ The mock copies `sources` and `source_list_changes` per connection, so shared
368
+ scenarios are never modified.
369
+
344
370
  ### Custom Scenarios
345
371
 
346
372
  Create custom scenarios for specific test needs:
@@ -46,7 +46,12 @@ RUN mkdir -p /run/chrony && chown chrony:chrony /run/chrony && chmod 750 /run/ch
46
46
  # The command port stays at its default (323 on localhost) so the auto-detect
47
47
  # fallback from the Unix socket can be exercised; "cmdport 0" would make that
48
48
  # path untestable.
49
- RUN echo "local stratum 10" >> /etc/chrony.conf && \
49
+ # Fedora's stock "pool" line is removed so the only NTP source is the single
50
+ # "server" below. A pool starts as 8 placeholders that need several DNS
51
+ # answers to fill, and chronyd waits 28s before retrying a partial fill,
52
+ # which outlasts the CI readiness gate.
53
+ RUN sed -i '/^pool /d' /etc/chrony.conf && \
54
+ echo "local stratum 10" >> /etc/chrony.conf && \
50
55
  echo "allow 127.0.0.1" >> /etc/chrony.conf && \
51
56
  echo "cmdallow 127.0.0.1" >> /etc/chrony.conf && \
52
57
  echo "bindcmdaddress /run/chrony/chronyd.sock" >> /etc/chrony.conf && \
@@ -8,8 +8,9 @@ Internal implementation - use pychrony.ChronyConnection instead.
8
8
 
9
9
  import math
10
10
  import os
11
+ from collections.abc import Callable
11
12
  from types import TracebackType
12
- from typing import Any, NoReturn
13
+ from typing import Any, NoReturn, TypeVar
13
14
 
14
15
  from ..exceptions import (
15
16
  ChronyConnectionError,
@@ -29,6 +30,8 @@ from ..models import (
29
30
  _ref_id_to_name,
30
31
  )
31
32
 
33
+ _T = TypeVar("_T")
34
+
32
35
  # Unix socket paths tried during auto-detect, in order. chrony documents its
33
36
  # compiled-in default as /run/... or /var/run/... depending on release; the two
34
37
  # are the same file wherever /var/run symlinks to /run.
@@ -74,15 +77,27 @@ except ImportError:
74
77
  # rather than its data being bad. Taken from the compiled bindings so that an
75
78
  # enumerator added upstream cannot silently shift them into misclassifying
76
79
  # errors. The literals apply only with no bindings, where nothing is classified.
80
+ #
81
+ # CHRONY_UNEXPECTED_STATUS is how a request for a source index past the end of
82
+ # chronyd's list comes back: chronyd answers STT_NOSUCHSOURCE, which libchrony
83
+ # has no case for.
77
84
  if _LIBRARY_AVAILABLE:
78
85
  CHRONY_SEND_FAILED = _lib.CHRONY_SEND_FAILED
79
86
  CHRONY_RECV_FAILED = _lib.CHRONY_RECV_FAILED
87
+ CHRONY_UNEXPECTED_STATUS = _lib.CHRONY_UNEXPECTED_STATUS
80
88
  else:
81
89
  CHRONY_SEND_FAILED = 5
82
90
  CHRONY_RECV_FAILED = 6
91
+ CHRONY_UNEXPECTED_STATUS = 11
83
92
 
84
93
  TRANSPORT_ERRORS = frozenset({CHRONY_SEND_FAILED, CHRONY_RECV_FAILED})
85
94
 
95
+ # Reads of a per-source report before the last failure is raised. chronyd adds
96
+ # and removes sources while it resolves pool and server names, and when it
97
+ # replaces unreachable pool servers, so the record count can be stale by the
98
+ # time a record is requested. Each read starts over from a fresh count.
99
+ SOURCE_LIST_READ_ATTEMPTS = 3
100
+
86
101
 
87
102
  def _is_unix_socket_address(address: str) -> bool:
88
103
  """Return True if an address names a Unix socket path.
@@ -539,6 +554,58 @@ class ChronyConnection:
539
554
  f"Failed to process {report_name.decode()} record {index}", err
540
555
  )
541
556
 
557
+ def _read_records(
558
+ self, report_name: bytes, read_record: Callable[[], _T]
559
+ ) -> list[_T]:
560
+ """Read every record of a report, one request per record.
561
+
562
+ Args:
563
+ report_name: Report name (e.g., b"sources")
564
+ read_record: Extracts and validates the record the session holds
565
+
566
+ Returns:
567
+ One read_record result per record, in index order
568
+
569
+ Raises:
570
+ ChronyConnectionError: If chronyd could not be reached
571
+ ChronyDataError: If a record could not be retrieved or is invalid
572
+ """
573
+ records = []
574
+ for index in range(self._request_report(report_name)):
575
+ self._request_record(report_name, index)
576
+ records.append(read_record())
577
+ return records
578
+
579
+ def _read_source_list(
580
+ self, report_name: bytes, read_record: Callable[[], _T]
581
+ ) -> list[_T]:
582
+ """Read a per-source report, starting over if the source list changes.
583
+
584
+ A shrinking list shows up as CHRONY_UNEXPECTED_STATUS on a record
585
+ request. libchrony uses that code for every chronyd status it does not
586
+ know, so a failure that is not a list change also gets retried, and
587
+ then raised once the attempts run out.
588
+
589
+ Args:
590
+ report_name: Report name (b"sources" or b"sourcestats")
591
+ read_record: Extracts and validates the record the session holds
592
+
593
+ Returns:
594
+ One read_record result per source, in index order
595
+
596
+ Raises:
597
+ ChronyConnectionError: If chronyd could not be reached
598
+ ChronyDataError: If a record is invalid, or could not be retrieved
599
+ on any of SOURCE_LIST_READ_ATTEMPTS reads
600
+ """
601
+ for _ in range(SOURCE_LIST_READ_ATTEMPTS - 1):
602
+ try:
603
+ return self._read_records(report_name, read_record)
604
+ except ChronyDataError as e:
605
+ if e.error_code != CHRONY_UNEXPECTED_STATUS:
606
+ raise
607
+ return self._read_records(report_name, read_record)
608
+
542
609
  def get_tracking(self) -> TrackingStatus:
543
610
  """Get current tracking status from chronyd.
544
611
 
@@ -650,19 +717,13 @@ class ChronyConnection:
650
717
  ... print(f"{src.address}: stratum {src.stratum}")
651
718
  """
652
719
  self._ensure_context()
720
+ return self._read_source_list(b"sources", self._read_source)
653
721
 
654
- num_records = self._request_report(b"sources")
655
- if num_records < 1:
656
- return []
657
-
658
- sources = []
659
- for i in range(num_records):
660
- self._request_record(b"sources", i)
661
- data = self._extract_source()
662
- self._validate_source(data)
663
- sources.append(Source(**data))
664
-
665
- return sources
722
+ def _read_source(self) -> Source:
723
+ """Build a Source from the current session record."""
724
+ data = self._extract_source()
725
+ self._validate_source(data)
726
+ return Source(**data)
666
727
 
667
728
  def _extract_source(self) -> dict:
668
729
  """Extract source fields from the current session record."""
@@ -745,19 +806,13 @@ class ChronyConnection:
745
806
  ... print(f"{s.address}: {s.samples} samples")
746
807
  """
747
808
  self._ensure_context()
809
+ return self._read_source_list(b"sourcestats", self._read_sourcestats)
748
810
 
749
- num_records = self._request_report(b"sourcestats")
750
- if num_records < 1:
751
- return []
752
-
753
- stats = []
754
- for i in range(num_records):
755
- self._request_record(b"sourcestats", i)
756
- data = self._extract_sourcestats()
757
- self._validate_sourcestats(data)
758
- stats.append(SourceStats(**data))
759
-
760
- return stats
811
+ def _read_sourcestats(self) -> SourceStats:
812
+ """Build a SourceStats from the current session record."""
813
+ data = self._extract_sourcestats()
814
+ self._validate_sourcestats(data)
815
+ return SourceStats(**data)
761
816
 
762
817
  def _extract_sourcestats(self) -> dict:
763
818
  """Extract sourcestats fields from the current session record."""
@@ -23,6 +23,7 @@ typedef enum {
23
23
  CHRONY_OK,
24
24
  CHRONY_SEND_FAILED,
25
25
  CHRONY_RECV_FAILED,
26
+ CHRONY_UNEXPECTED_STATUS,
26
27
  ...
27
28
  } chrony_err;
28
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pychrony
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Python bindings for chrony NTP client
5
5
  Author-email: arunderwood <arunderwood@users.noreply.github.com>
6
6
  License-Expression: MIT AND LGPL-2.1-or-later
@@ -133,6 +133,7 @@ tests/unit/test_mock_config.py
133
133
  tests/unit/test_mock_scenarios.py
134
134
  tests/unit/test_models.py
135
135
  tests/unit/test_rtc_scenarios.py
136
+ tests/unit/test_source_list_changes.py
136
137
  tests/unit/test_source_scenarios.py
137
138
  tests/unit/test_testing.py
138
139
  tests/unit/test_validation.py
@@ -126,6 +126,7 @@
126
126
  "tests/unit/test_mock_scenarios.py",
127
127
  "tests/unit/test_models.py",
128
128
  "tests/unit/test_rtc_scenarios.py",
129
+ "tests/unit/test_source_list_changes.py",
129
130
  "tests/unit/test_source_scenarios.py",
130
131
  "tests/unit/test_testing.py",
131
132
  "tests/unit/test_validation.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.0.3",
3
+ "distance": 0,
4
+ "node": "g272927cd1d5b1021a26967247d4def5b2a8335d3",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-21"
8
+ }
@@ -212,6 +212,9 @@ class ChronyStateConfig:
212
212
  root_dispersion: Root dispersion in seconds
213
213
  update_interval: Update interval in seconds
214
214
  sources: List of source configurations
215
+ source_list_changes: Source lists chronyd moves to, in order, one
216
+ each time it answers a sources or sourcestats count. Simulates
217
+ sources added or removed between the count and the record requests.
215
218
  rtc: RTC configuration (None if not available)
216
219
  error_injection: Map of operation names to error codes
217
220
  """
@@ -233,6 +236,7 @@ class ChronyStateConfig:
233
236
 
234
237
  # Sub-configurations
235
238
  sources: list[SourceConfig] = field(default_factory=list)
239
+ source_list_changes: list[list[SourceConfig]] = field(default_factory=list)
236
240
  rtc: RTCConfig | None = None
237
241
 
238
242
  # Error injection
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from typing import TYPE_CHECKING, Any
11
11
 
12
+ from pychrony._core._bindings import CHRONY_UNEXPECTED_STATUS
12
13
  from pychrony._core._fields import (
13
14
  RTC_FIELDS,
14
15
  SOURCE_FIELDS,
@@ -18,9 +19,12 @@ from pychrony._core._fields import (
18
19
  )
19
20
 
20
21
  if TYPE_CHECKING:
21
- from tests.mocks.config import ChronyStateConfig
22
+ from tests.mocks.config import ChronyStateConfig, SourceConfig
22
23
 
23
24
 
25
+ # Reports whose records are chronyd's sources, one record per source.
26
+ SOURCE_REPORTS = frozenset({"sources", "sourcestats"})
27
+
24
28
  __all__ = [
25
29
  "MockTimespec",
26
30
  "MockFFI",
@@ -153,6 +157,7 @@ class MockLib:
153
157
  return error
154
158
 
155
159
  self._session.current_report = report_name.decode("utf-8")
160
+ self._session.count_requested = True
156
161
  self._session.pending_responses = 1
157
162
  return 0
158
163
 
@@ -161,29 +166,35 @@ class MockLib:
161
166
  return self._session.pending_responses > 0
162
167
 
163
168
  def chrony_process_response(self, session: Any) -> int:
164
- """Mock chrony_process_response."""
169
+ """Mock chrony_process_response.
170
+
171
+ Like libchrony, the record count is fixed when the count response
172
+ arrives, and a record request past the end of the source list fails
173
+ here with CHRONY_UNEXPECTED_STATUS.
174
+ """
165
175
  error = self._session.config.error_injection.get("chrony_process_response")
166
176
  if error is not None:
167
177
  return error
168
178
 
169
- if self._session.pending_responses > 0:
170
- self._session.pending_responses -= 1
179
+ session_state = self._session
180
+ if session_state.pending_responses == 0:
181
+ return 0
182
+ session_state.pending_responses -= 1
183
+
184
+ if session_state.count_requested:
185
+ session_state.num_records = session_state.count_records()
186
+ if session_state.current_report in SOURCE_REPORTS:
187
+ session_state.advance_source_list()
188
+ elif (
189
+ session_state.current_report in SOURCE_REPORTS
190
+ and session_state.current_record_index >= len(session_state.sources)
191
+ ):
192
+ return CHRONY_UNEXPECTED_STATUS
171
193
  return 0
172
194
 
173
195
  def chrony_get_report_number_records(self, session: Any) -> int:
174
196
  """Mock chrony_get_report_number_records."""
175
- report = self._session.current_report
176
- config = self._session.config
177
-
178
- if report == "tracking":
179
- return 1
180
- elif report == "sources" or report == "sourcestats":
181
- return len(config.sources)
182
- elif report == "rtcdata":
183
- if config.rtc is not None and config.rtc.available:
184
- return 1
185
- return 0
186
- return 0
197
+ return self._session.num_records
187
198
 
188
199
  def chrony_request_record(
189
200
  self, session: Any, report_name: bytes, index: int
@@ -194,6 +205,7 @@ class MockLib:
194
205
  return error
195
206
 
196
207
  self._session.current_record_index = index
208
+ self._session.count_requested = False
197
209
  self._session.pending_responses = 1
198
210
  return 0
199
211
 
@@ -216,8 +228,8 @@ class MockLib:
216
228
  # which dynamically resolves to "address" (mode != 2) or "reference ID" (mode == 2)
217
229
  if name == "address":
218
230
  record_idx = self._session.current_record_index
219
- if record_idx < len(self._session.config.sources):
220
- source = self._session.config.sources[record_idx]
231
+ if record_idx < len(self._session.sources):
232
+ source = self._session.sources[record_idx]
221
233
  # Import SourceMode here to avoid circular import
222
234
  from pychrony.models import SourceMode
223
235
 
@@ -266,8 +278,12 @@ class MockChronySession:
266
278
 
267
279
  Attributes:
268
280
  config: Configuration driving mock behavior
281
+ sources: chronyd's current source list. Starts as config.sources and
282
+ steps through config.source_list_changes.
269
283
  current_report: Currently active report name
270
284
  current_record_index: Current record being accessed
285
+ count_requested: Whether the outstanding request is a record count
286
+ num_records: Record count from the last count response
271
287
  pending_responses: Number of responses pending
272
288
  lib: Mock _lib object
273
289
  ffi: Mock _ffi object
@@ -275,12 +291,38 @@ class MockChronySession:
275
291
 
276
292
  def __init__(self, config: ChronyStateConfig) -> None:
277
293
  self.config = config
294
+ # Copies, so that stepping through source list changes leaves the
295
+ # config untouched: scenarios are shared module-level instances.
296
+ self.sources: list[SourceConfig] = list(config.sources)
297
+ self._source_list_changes = list(config.source_list_changes)
278
298
  self.current_report: str | None = None
279
299
  self.current_record_index: int = 0
300
+ self.count_requested: bool = False
301
+ self.num_records: int = 0
280
302
  self.pending_responses: int = 0
281
303
  self.lib = MockLib(self)
282
304
  self.ffi = MockFFI()
283
305
 
306
+ def count_records(self) -> int:
307
+ """Return the record count chronyd reports for the current report."""
308
+ report = self.current_report
309
+
310
+ if report == "tracking":
311
+ return 1
312
+ elif report in SOURCE_REPORTS:
313
+ return len(self.sources)
314
+ elif report == "rtcdata":
315
+ rtc = self.config.rtc
316
+ if rtc is not None and rtc.available:
317
+ return 1
318
+ return 0
319
+ return 0
320
+
321
+ def advance_source_list(self) -> None:
322
+ """Move chronyd's source list to the next configured change, if any."""
323
+ if self._source_list_changes:
324
+ self.sources = list(self._source_list_changes.pop(0))
325
+
284
326
  def get_field_value(self, index: int, field_type: FieldType) -> Any:
285
327
  """Get field value for current report and record.
286
328
 
@@ -335,13 +377,12 @@ class MockChronySession:
335
377
 
336
378
  def _get_source_field(self, index: int) -> Any:
337
379
  """Get sources report field by index."""
338
- config = self.config
339
380
  record_idx = self.current_record_index
340
381
 
341
- if record_idx >= len(config.sources):
382
+ if record_idx >= len(self.sources):
342
383
  return 0
343
384
 
344
- source = config.sources[record_idx]
385
+ source = self.sources[record_idx]
345
386
  fields = list(SOURCE_FIELDS.keys())
346
387
 
347
388
  if index >= len(fields):
@@ -373,13 +414,12 @@ class MockChronySession:
373
414
 
374
415
  def _get_sourcestats_field(self, index: int) -> Any:
375
416
  """Get sourcestats report field by index."""
376
- config = self.config
377
417
  record_idx = self.current_record_index
378
418
 
379
- if record_idx >= len(config.sources):
419
+ if record_idx >= len(self.sources):
380
420
  return 0
381
421
 
382
- source = config.sources[record_idx]
422
+ source = self.sources[record_idx]
383
423
  fields = list(SOURCESTATS_FIELDS.keys())
384
424
 
385
425
  if index >= len(fields):