sdmxlib 0.70.0__tar.gz → 0.71.0__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 (159) hide show
  1. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/PKG-INFO +1 -1
  2. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/pyproject.toml +1 -1
  3. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/pyproject.toml.orig +1 -1
  4. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/__init__.py +2 -0
  5. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/client.py +40 -3
  6. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/federated.py +3 -1
  7. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/providers.py +13 -1
  8. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/registry.py +24 -8
  9. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/local/registry.py +57 -12
  10. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_label_match.py +1 -1
  11. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_common.py +1 -1
  12. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/observations.py +6 -0
  13. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/raw.py +1 -1
  14. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/sql.py +7 -7
  15. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/schema.py +35 -6
  16. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/README.md +0 -0
  17. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/_duckdb.py +0 -0
  18. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/_singleflight.py +0 -0
  19. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/_time.py +0 -0
  20. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/__init__.py +0 -0
  21. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/_freshness.py +0 -0
  22. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/policy.py +0 -0
  23. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/api/session.py +0 -0
  24. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/builders/__init__.py +0 -0
  25. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/builders/contentconstraint.py +0 -0
  26. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/builders/metadata_annotation.py +0 -0
  27. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/data_store.py +0 -0
  28. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/__init__.py +0 -0
  29. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/_arrow.py +0 -0
  30. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/_attachment.py +0 -0
  31. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/_coerce.py +0 -0
  32. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/_index.py +0 -0
  33. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/_positions.py +0 -0
  34. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/_refuse.py +0 -0
  35. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/data_format.py +0 -0
  36. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/gaps.py +0 -0
  37. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  38. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  39. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  40. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  41. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_data_common.py +0 -0
  42. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_dto.py +0 -0
  43. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_header.py +0 -0
  44. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_losses.py +0 -0
  45. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_periods.py +0 -0
  46. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_resolve.py +0 -0
  47. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_structure_dto.py +0 -0
  48. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/_structure_ingest.py +0 -0
  49. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/data.py +0 -0
  50. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/data_v1.py +0 -0
  51. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/data_v2.py +0 -0
  52. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  53. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  54. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/structure_values.py +0 -0
  55. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  56. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json_1_0/__init__.py +0 -0
  57. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_json_1_0/writer.py +0 -0
  58. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/__init__.py +0 -0
  59. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_allowed_children.py +0 -0
  60. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_closed_world.py +0 -0
  61. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_common.py +0 -0
  62. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_header.py +0 -0
  63. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_losses.py +0 -0
  64. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_v3_reader.py +0 -0
  65. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml/_v3_writer.py +0 -0
  66. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  67. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml21/_providers.py +0 -0
  68. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml21/_synthetic.py +0 -0
  69. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  70. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  71. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  72. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  73. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  74. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  75. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  76. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  77. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml31/__init__.py +0 -0
  78. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml31/namespaces.py +0 -0
  79. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml31/reader.py +0 -0
  80. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/formats/sdmx_ml31/writer.py +0 -0
  81. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/local/__init__.py +0 -0
  82. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/local/_scan.py +0 -0
  83. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/local/data_store.py +0 -0
  84. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/local/sql.py +0 -0
  85. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/__init__.py +0 -0
  86. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_binding.py +0 -0
  87. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_container.py +0 -0
  88. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_invariants.py +0 -0
  89. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_items.py +0 -0
  90. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_rebind.py +0 -0
  91. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_referenceable.py +0 -0
  92. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_refshape.py +0 -0
  93. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_resolvers.py +0 -0
  94. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_rules.py +0 -0
  95. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/_tree.py +0 -0
  96. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/annotations.py +0 -0
  97. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/base.py +0 -0
  98. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/binding.py +0 -0
  99. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/category.py +0 -0
  100. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/codelist.py +0 -0
  101. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/collections.py +0 -0
  102. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/concept.py +0 -0
  103. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/constraint.py +0 -0
  104. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/convert.py +0 -0
  105. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/dataflow.py +0 -0
  106. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/datastructure.py +0 -0
  107. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/errors.py +0 -0
  108. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/expr.py +0 -0
  109. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/header.py +0 -0
  110. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/hierarchy.py +0 -0
  111. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/in_memory_registry.py +0 -0
  112. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/istring.py +0 -0
  113. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/link.py +0 -0
  114. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/mapping.py +0 -0
  115. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/message.py +0 -0
  116. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/metadata_provision.py +0 -0
  117. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/metadataflow.py +0 -0
  118. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/metadataset.py +0 -0
  119. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/metadatastructure.py +0 -0
  120. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/organisation.py +0 -0
  121. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/provision.py +0 -0
  122. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/ref.py +0 -0
  123. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/registry.py +0 -0
  124. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/representation.py +0 -0
  125. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/rest_api.py +0 -0
  126. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/rollup.py +0 -0
  127. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/urn.py +0 -0
  128. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/validation.py +0 -0
  129. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/valuelist.py +0 -0
  130. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/version.py +0 -0
  131. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/model/version_rewrite.py +0 -0
  132. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/polars.py +0 -0
  133. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/py.typed +0 -0
  134. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/__init__.py +0 -0
  135. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_closure.py +0 -0
  136. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_compile.py +0 -0
  137. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_edges.py +0 -0
  138. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_execute.py +0 -0
  139. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_expr.py +0 -0
  140. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_joins.py +0 -0
  141. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_plan.py +0 -0
  142. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_pushdown.py +0 -0
  143. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_rebase.py +0 -0
  144. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/_rest.py +0 -0
  145. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/accessors.py +0 -0
  146. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/query.py +0 -0
  147. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/query/schema.py +0 -0
  148. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/rest.py +0 -0
  149. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/session.py +0 -0
  150. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/__init__.py +0 -0
  151. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/_checked.py +0 -0
  152. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/_kinds.py +0 -0
  153. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/_parsed.py +0 -0
  154. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/_projections.py +0 -0
  155. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/lazy.py +0 -0
  156. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/readers.py +0 -0
  157. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/resolve.py +0 -0
  158. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/storage/writers.py +0 -0
  159. {sdmxlib-0.70.0 → sdmxlib-0.71.0}/src/sdmxlib/versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.70.0
3
+ Version: 0.71.0
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.70.0"
3
+ version = "0.71.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.70.0"
3
+ version = "0.71.0"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -49,6 +49,7 @@ from sdmxlib.rest import ComponentFilter, parse_components, widen_key
49
49
  from sdmxlib.query._common import Descending
50
50
  from sdmxlib.model.convert import from_dict, to_dict
51
51
  from sdmxlib.model.errors import ImRuleWarning, ReadAssumption, ReadGap, SdmxWarning
52
+ from sdmxlib.storage.schema import StoreSchemaError
52
53
  from sdmxlib.model.validation import (
53
54
  LossyReadError,
54
55
  ReferenceIssue,
@@ -395,6 +396,7 @@ __all__ = [
395
396
  "SliceError",
396
397
  "SourceValue",
397
398
  "SqlRelation",
399
+ "StoreSchemaError",
398
400
  "Str",
399
401
  "StructureMap",
400
402
  "StructureMessage",
@@ -216,6 +216,33 @@ def _check_content_type(response: httpx.Response, fmt: DataFormat, *, url: str)
216
216
  raise ValueError(msg)
217
217
 
218
218
 
219
+ def _require_csv(response: httpx.Response, *, accept: str, url: str) -> None:
220
+ """Refuse an observation fetch's response that is not CSV (GH-50).
221
+
222
+ The data plane hands the body to DuckDB's CSV reader, which reads an
223
+ SDMX-ML document as a CSV of one malformed column. BIS answers
224
+ ``text/csv`` with SDMX-ML and a 200, and ECB and ESTAT answer an
225
+ ``Accept`` listing two types the same way. Either SDMX-CSV version, or
226
+ plain ``text/csv``, is fine: the reader takes both envelopes. As in
227
+ `_check_content_type`, a response with no ``Content-Type`` passes.
228
+
229
+ Raises:
230
+ ValueError: If the response says it is something other than CSV.
231
+ """
232
+ header = response.headers.get("content-type")
233
+ if not header:
234
+ return
235
+ media_type = header.partition(";")[0].strip().lower()
236
+ if media_type == "text/csv" or media_type.endswith("+csv"):
237
+ return
238
+ msg = (
239
+ f"Asked for {accept!r} but the response is {header!r}, not CSV, so it was not read. "
240
+ "Pass the media type this provider serves CSV under as `csv_media_type=` "
241
+ f"(see `sl.Provider` for the ones checked). URL: {url}"
242
+ )
243
+ raise ValueError(msg)
244
+
245
+
219
246
  def _raise_for_status(response: httpx.Response, *, url: str) -> None:
220
247
  """`raise_for_status`, with the two size refusals named.
221
248
 
@@ -277,6 +304,8 @@ class SdmxClient:
277
304
  obey the provider exactly.
278
305
  freshness_size: How many URLs to remember freshness for. Bounded,
279
306
  per `docs/design/caching.md`; eviction only costs a request.
307
+ csv_media_type: The ``Accept`` an observation fetch sends. Defaults to
308
+ the SDMX-CSV the API version defines: 1.0 for 2.1, 2.0 for 3.0.
280
309
  """
281
310
 
282
311
  def __init__(
@@ -288,6 +317,7 @@ class SdmxClient:
288
317
  headers: dict[str, str] | None = None,
289
318
  latest_ttl: int = 0,
290
319
  freshness_size: int = 256,
320
+ csv_media_type: str | None = None,
291
321
  **httpx_kwargs: Unpack[HttpxOptions],
292
322
  ) -> None:
293
323
  self._endpoint = endpoint.rstrip("/")
@@ -300,6 +330,11 @@ class SdmxClient:
300
330
  self._freshness_lock = threading.Lock()
301
331
  self._api = api
302
332
  self._v3 = api.startswith(("3", "2.0"))
333
+ #: What an observation fetch asks for (GH-50): the provider's, else
334
+ #: the SDMX-CSV the API version defines.
335
+ self._csv_media_type = (
336
+ csv_media_type or (DataFormat.SDMX_CSV_2_0_0 if self._v3 else DataFormat.SDMX_CSV_1_0_0).media_type
337
+ )
303
338
  default_headers: dict[str, str]
304
339
  if self._v3:
305
340
  default_headers = {"Accept": "application/vnd.sdmx.structure+xml;version=3.0"}
@@ -566,8 +601,9 @@ class SdmxClient:
566
601
  """
567
602
  url = f"{self._endpoint}/{path}"
568
603
  with self._reaching(url):
569
- response = self._client.get(url, params=dict(params), headers={"Accept": "text/csv"})
604
+ response = self._client.get(url, params=dict(params), headers={"Accept": self._csv_media_type})
570
605
  _raise_for_status(response, url=url)
606
+ _require_csv(response, accept=self._csv_media_type, url=url)
571
607
  return response.content
572
608
 
573
609
  def stream_data_at(self, path: str, params: Mapping[str, str], *, to: Path) -> None:
@@ -587,9 +623,10 @@ class SdmxClient:
587
623
  url = f"{self._endpoint}/{path}"
588
624
  with (
589
625
  self._reaching(url),
590
- self._client.stream("GET", url, params=dict(params), headers={"Accept": "text/csv"}) as response,
626
+ self._client.stream("GET", url, params=dict(params), headers={"Accept": self._csv_media_type}) as response,
591
627
  ):
592
628
  _raise_for_status(response, url=url)
629
+ _require_csv(response, accept=self._csv_media_type, url=url)
593
630
  with to.open("wb") as handle:
594
631
  for chunk in response.iter_bytes():
595
632
  handle.write(chunk)
@@ -609,7 +646,7 @@ class SdmxClient:
609
646
  """Fetch a dataflow's data in `fmt`, unparsed.
610
647
 
611
648
  The byte-faithful counterpart to `get_data_at` / `stream_data_at`, which
612
- hardcode ``Accept: text/csv`` because their caller is about to hand the
649
+ ask for CSV (`csv_media_type`) because their caller is about to hand the
613
650
  body to DuckDB's CSV reader. Here the caller wants what the provider
614
651
  sent, so the format is the caller's to choose and nothing inspects the
615
652
  body afterwards.
@@ -10,7 +10,9 @@ sources cooperating to answer a single URN) is intentionally out of
10
10
  scope; see #46 for that conversation.
11
11
 
12
12
  Example:
13
- local = sl.LocalRegistry("cache.duckdb", source_registry="local")
13
+ # rebuild=True: a cache can be fetched again, so a store schema change may
14
+ # empty it rather than refuse to open (GH-57).
15
+ local = sl.LocalRegistry("cache.duckdb", source_registry="local", rebuild=True).open()
14
16
  estat = sl.RestRegistry(sl.Provider.ESTAT, store=local)
15
17
  sdmx_global = sl.RestRegistry(sl.Provider.SDMX_GLOBAL, store=local)
16
18
 
@@ -22,11 +22,18 @@ class ProviderInfo:
22
22
  tag when a `RestRegistry` materialises through to a
23
23
  local store. Required for federation; ``None`` for ad-hoc
24
24
  URLs without a known label.
25
+ csv_media_type: The ``Accept`` an observation fetch sends, when the
26
+ API version's SDMX-CSV media type is not what this provider
27
+ answers (GH-50). None means that default: SDMX-CSV 1.0 for a
28
+ 2.1 API, 2.0 for a 3.0 one. Set per provider because two 2.1
29
+ providers want opposite headers, and a header listing both gets
30
+ SDMX-ML back from each.
25
31
  """
26
32
 
27
33
  url: str
28
34
  api: str = "2.1"
29
35
  id: str | None = None
36
+ csv_media_type: str | None = None
30
37
 
31
38
 
32
39
  class Provider:
@@ -48,8 +55,13 @@ class Provider:
48
55
  sl.Provider.ECB.api # "2.1"
49
56
  """
50
57
 
58
+ # The data `Accept` each answers, checked live 2026-10-22 (GH-50): ECB
59
+ # serves CSV for `text/csv` alone (406 for either SDMX-CSV type); ESTAT
60
+ # the reverse (406 for `text/csv`); BIS answers `text/csv` with SDMX-ML;
61
+ # OECD serves all three. IMF's is unchecked, so it takes the default.
62
+
51
63
  #: European Central Bank
52
- ECB = ProviderInfo("https://data-api.ecb.europa.eu/service", api="2.1", id="ECB")
64
+ ECB = ProviderInfo("https://data-api.ecb.europa.eu/service", api="2.1", id="ECB", csv_media_type="text/csv")
53
65
  #: Bank for International Settlements
54
66
  BIS = ProviderInfo("https://stats.bis.org/api/v1", api="2.1", id="BIS")
55
67
  #: Eurostat
@@ -178,6 +178,11 @@ class RestRegistry:
178
178
  whose `no-store` is appliance boilerplate, and cannot shorten a
179
179
  generous one. Defaults to 0 — obey the provider exactly. Wanting
180
180
  *less* is spelled with a pinned URN, which is not a pointer.
181
+ csv_media_type: The ``Accept`` `obs` fetches observations with
182
+ (GH-50). Defaults to the `ProviderInfo`'s, else the SDMX-CSV the
183
+ API version defines: 1.0 for 2.1, 2.0 for 3.0. Providers differ —
184
+ ECB serves CSV only for ``text/csv``, Eurostat only for SDMX-CSV
185
+ 1.0 — so pass one when a bare URL's provider refuses the default.
181
186
 
182
187
  **Thread safety: concurrent reads are safe and do not duplicate work.**
183
188
 
@@ -202,9 +207,13 @@ class RestRegistry:
202
207
  store — inherit the local `LocalRegistry`'s rule and remain single-threaded.
203
208
 
204
209
  Example:
205
- # Cache-mode federation: structural reads cached locally.
206
- local = sl.LocalRegistry("cache.duckdb")
207
- with sl.RestRegistry(sl.Provider.ECB, store=local) as reg:
210
+ # Cache-mode federation: structural reads cached locally. A cache can
211
+ # be fetched again, so it may be rebuilt when sdmxlib's store schema
212
+ # moves; any other store on an old schema is refused instead (GH-57).
213
+ with (
214
+ sl.LocalRegistry("cache.duckdb", rebuild=True) as local,
215
+ sl.RestRegistry(sl.Provider.ECB, store=local) as reg,
216
+ ):
208
217
  dsd = reg.get(sl.DataStructure.urn_for(agency="ECB", id="ECB_EXR1"), resolve=True)
209
218
 
210
219
  # Single-source ad-hoc.
@@ -226,6 +235,7 @@ class RestRegistry:
226
235
  store: LocalRegistry | None = None,
227
236
  fetch_cache_size: int = 256,
228
237
  latest_ttl: int = 0,
238
+ csv_media_type: str | None = None,
229
239
  **httpx_kwargs: Unpack[HttpxOptions],
230
240
  ) -> None:
231
241
  if isinstance(endpoint, ProviderInfo):
@@ -233,6 +243,8 @@ class RestRegistry:
233
243
  api = endpoint.api
234
244
  if id is None:
235
245
  id = endpoint.id
246
+ if csv_media_type is None:
247
+ csv_media_type = endpoint.csv_media_type
236
248
  else:
237
249
  url = endpoint
238
250
 
@@ -278,6 +290,7 @@ class RestRegistry:
278
290
  timeout=timeout,
279
291
  headers=headers,
280
292
  latest_ttl=latest_ttl,
293
+ csv_media_type=csv_media_type,
281
294
  **httpx_kwargs,
282
295
  )
283
296
  self._session = Session()
@@ -1001,8 +1014,11 @@ class RestRegistry:
1001
1014
  without a DSD there is no key order, and a key built on a guess
1002
1015
  queries a different slice silently.
1003
1016
  """
1004
- urn = _flow_urn(flow)
1005
- dsd = self._resolve_data_structure(urn)
1017
+ # Bound to the dataflow as fetched, not as asked for: the data URL names
1018
+ # its version, and BIS and OECD refuse `latest` there with a 400 that
1019
+ # ECB and Eurostat let pass (GH-51).
1020
+ resolved, dsd = self._resolve_data_structure(_flow_urn(flow))
1021
+ urn = resolved.urn
1006
1022
  store = DuckDBDataStore.from_registry(self._query_store())
1007
1023
  binding = DataflowBinding.from_dsd(
1008
1024
  dsd,
@@ -1036,8 +1052,8 @@ class RestRegistry:
1036
1052
  raise RuntimeError(msg)
1037
1053
  return str(rows[0][0])
1038
1054
 
1039
- def _resolve_data_structure(self, urn: SdmxUrn[Dataflow]) -> DataStructure:
1040
- """The dataflow's structure, resolved, or a refusal naming what is missing."""
1055
+ def _resolve_data_structure(self, urn: SdmxUrn[Dataflow]) -> tuple[Dataflow, DataStructure]:
1056
+ """The dataflow as fetched, and its structure resolved, or a refusal naming what is missing."""
1041
1057
  flow = self._fetch_one(
1042
1058
  Dataflow, urn.agency, urn.id, urn.version, detail="referencepartial", references="descendants"
1043
1059
  )
@@ -1048,7 +1064,7 @@ class RestRegistry:
1048
1064
  if not isinstance(dsd, DataStructure):
1049
1065
  msg = f"{flow.structure.urn} did not resolve to a DataStructure."
1050
1066
  raise TypeError(msg)
1051
- return dsd
1067
+ return flow, dsd
1052
1068
 
1053
1069
  def _fetch_data_for[R: "RowKind"](
1054
1070
  self, plan: Plan[R], _projection: frozenset[tuple[str, str]] | None = None
@@ -20,7 +20,7 @@ stashes the URI; the DuckDB connection is opened by ``open()`` /
20
20
  dsd = local.get(dsd_urn)
21
21
  cm = local.code_map(codelist_urn, lang="en")
22
22
 
23
- # Share an existing DuckDB connection (e.g. with cataloglib): use the
23
+ # Share an existing DuckDB connection (e.g. with statcatlib): use the
24
24
  # ``from_connection`` classmethod, which doesn't take ownership of the
25
25
  # connection's lifecycle.
26
26
  import duckdb
@@ -37,6 +37,7 @@ import threading
37
37
  import time
38
38
  from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
39
39
  from contextlib import contextmanager
40
+ from importlib import metadata
40
41
  from pathlib import Path
41
42
  from typing import Final, Literal, Self, cast, overload, override
42
43
 
@@ -94,9 +95,11 @@ from sdmxlib.storage.readers import make_lazy_codelist
94
95
  from sdmxlib.storage.schema import (
95
96
  SCHEMA_VERSION,
96
97
  ReadOnlySchemaError,
98
+ StoreSchemaError,
97
99
  check_scan_integrity,
98
100
  connection_is_read_only,
99
101
  get_schema_version,
102
+ holds_rows,
100
103
  migrate,
101
104
  schema_exists,
102
105
  )
@@ -104,6 +107,14 @@ from sdmxlib.storage.schema import (
104
107
  _SIBLING_INDEX = re.compile(r"\[\d+\]")
105
108
 
106
109
 
110
+ def _sdmxlib_version() -> str:
111
+ """This sdmxlib's version, for a message; ``"unknown"`` when not installed as a package."""
112
+ try:
113
+ return metadata.version("sdmxlib")
114
+ except metadata.PackageNotFoundError:
115
+ return "unknown"
116
+
117
+
107
118
  def _declared_path(attribute_path: str) -> str:
108
119
  """The MSD path a reported ``attribute_path`` instantiates.
109
120
 
@@ -573,9 +584,17 @@ class LocalRegistry(SessionFactory):
573
584
  source_registry: str | None = None,
574
585
  resolve_cache_items: int = DEFAULT_RESOLVE_CACHE_ITEMS,
575
586
  target: str | None = None,
587
+ rebuild: bool = False,
576
588
  ) -> None:
577
589
  """Open or wrap a DuckDB-backed registry.
578
590
 
591
+ ``rebuild`` lets opening drop and rebuild a store that holds artefacts
592
+ on another schema version (GH-57). Without it such a store is refused
593
+ with `StoreSchemaError`, because the rebuild empties it: right for a
594
+ cache that `RestRegistry(store=...)` can fetch again, and wrong for a
595
+ bundle or a store filled with ``add``. An empty store, or a new file,
596
+ is created or migrated either way.
597
+
579
598
  ``target`` declares which SDMX-ML version this store's contents are
580
599
  meant to be written as — ``"2.1"``, ``"3.0"`` or ``"3.1"``. Adding an
581
600
  artefact carrying something that format cannot represent then **raises**
@@ -602,6 +621,8 @@ class LocalRegistry(SessionFactory):
602
621
  self._uri = store_path(uri, owner="LocalRegistry", wrap="sl.LocalRegistry.from_connection(conn)")
603
622
  self._source_registry = source_registry
604
623
  self._target = target
624
+ #: Whether opening may drop a populated store on another schema (GH-57).
625
+ self._rebuild = rebuild
605
626
  self._conn: duckdb.DuckDBPyConnection | None = None
606
627
  self._cursor: duckdb.DuckDBPyConnection | None = None
607
628
  self._owns_connection = True
@@ -697,6 +718,7 @@ class LocalRegistry(SessionFactory):
697
718
  source_registry: str | None = None,
698
719
  resolve_cache_items: int = DEFAULT_RESOLVE_CACHE_ITEMS,
699
720
  target: str | None = None,
721
+ rebuild: bool = False,
700
722
  ) -> Self:
701
723
  """Wrap an existing DuckDB connection.
702
724
 
@@ -705,9 +727,10 @@ class LocalRegistry(SessionFactory):
705
727
  is shared with another component (e.g. a co-located DuckLake catalog)
706
728
  that owns its lifecycle.
707
729
 
708
- ``resolve_cache_items`` configures the resolver cache (FIFO), and ``target``
709
- declares an SDMX-ML version to hold the contents to; both are
710
- described on `__init__`.
730
+ ``resolve_cache_items`` configures the resolver cache (FIFO), ``target``
731
+ declares an SDMX-ML version to hold the contents to, and ``rebuild``
732
+ lets a populated store on another schema be dropped; all are described
733
+ on `__init__`.
711
734
  """
712
735
  # `Self`-annotated because `cls.__new__(cls)` is otherwise untyped: the
713
736
  # local would be `Self@__new__` with no field information. (It also
@@ -727,6 +750,7 @@ class LocalRegistry(SessionFactory):
727
750
  # raise `AttributeError` on any registry built this way. A comment was
728
751
  # not enough; `test_constructors_agree_on_fields` is the guard.
729
752
  instance._target = target
753
+ instance._rebuild = rebuild
730
754
  instance._conn = conn
731
755
  instance._cursor = None
732
756
  instance._owns_connection = False
@@ -744,19 +768,33 @@ class LocalRegistry(SessionFactory):
744
768
  return instance
745
769
 
746
770
  def _initialise_schema(self) -> None:
747
- """Migrate or open a cursor on the ``sdmx`` schema."""
771
+ """Migrate or open a cursor on the ``sdmx`` schema.
772
+
773
+ Raises:
774
+ StoreSchemaError: If the store holds artefacts on another schema
775
+ version and ``rebuild`` was not asked for (GH-57).
776
+ ReadOnlySchemaError: If the schema must be created or rebuilt and
777
+ the connection is read-only.
778
+ """
748
779
  if self._conn is None:
749
780
  return
750
781
  version = get_schema_version(self._conn)
751
- if not schema_exists(self._conn) or version != SCHEMA_VERSION:
782
+ exists = schema_exists(self._conn)
783
+ if not exists or version != SCHEMA_VERSION:
784
+ found = f"schema {version}" if exists else "no sdmx schema"
785
+ if exists and not self._rebuild and holds_rows(self._conn):
786
+ msg = (
787
+ f"{self._uri} holds artefacts on store {found}, and this sdmxlib "
788
+ f"({_sdmxlib_version()}) reads schema {SCHEMA_VERSION}, so it was not opened: "
789
+ f"migrating means dropping them. A bundle: rebuild it from its source with this "
790
+ f"sdmxlib, or read it with the one that built it. A cache that can be fetched "
791
+ f"again: open it with rebuild=True."
792
+ )
793
+ raise StoreSchemaError(msg)
752
794
  if connection_is_read_only(self._conn):
753
- found = version if schema_exists(self._conn) else "no sdmx schema"
754
795
  msg = (
755
- f"this store is on {found}, and this build of sdmxlib uses "
756
- f"schema {SCHEMA_VERSION}. The store is a cache and migration "
757
- f"is drop-and-rebuild, which a read-only connection cannot do "
758
- f"— reopen it read-write to rebuild in place, or rebuild the "
759
- f"bundle from its source."
796
+ f"this store has {found}, and this build of sdmxlib uses schema {SCHEMA_VERSION}. "
797
+ f"Creating or rebuilding it needs a read-write connection."
760
798
  )
761
799
  raise ReadOnlySchemaError(msg)
762
800
  self._cursor = migrate(self._conn)
@@ -1540,6 +1578,10 @@ class LocalRegistry(SessionFactory):
1540
1578
  set — §9's rule. The lake is ATTACHed lazily, when something first asks
1541
1579
  for observations, so opening a bundle to read a codelist costs nothing.
1542
1580
 
1581
+ **A bundle is never rebuilt.** Its ``meta.duckdb`` is the published
1582
+ record, so one built on another schema version is refused with
1583
+ `StoreSchemaError` rather than emptied (GH-57).
1584
+
1543
1585
  Args:
1544
1586
  path: The bundle directory.
1545
1587
  source_registry: Provenance tag for artefacts added later.
@@ -1547,6 +1589,9 @@ class LocalRegistry(SessionFactory):
1547
1589
 
1548
1590
  Returns:
1549
1591
  An open registry over the bundle.
1592
+
1593
+ Raises:
1594
+ StoreSchemaError: If the bundle was built on another schema version.
1550
1595
  """
1551
1596
  root = Path(path) if isinstance(path, str) else path
1552
1597
  registry = cls(
@@ -3,7 +3,7 @@
3
3
  Package-internal: plain names in a private module, per CLAUDE.md rule 4. It is
4
4
  deliberately *not* in `sdmxlib.sql`, which is the public cross-library seam —
5
5
  these fragments are an implementation detail of `code_query` and
6
- `hierarchy_query`, not part of the contract pinaxlib composes against.
6
+ `hierarchy_query`, not part of the contract statcatlib composes against.
7
7
 
8
8
  Four matching strengths, exposed on the DSLs as the plain string ops plus
9
9
  three namespaces:
@@ -229,7 +229,7 @@ def canonical_key(value: object) -> tuple[object, ...]:
229
229
  ),
230
230
  )
231
231
  if isinstance(value, SqlRelation):
232
- # A foreign relation — pinaxlib's, or anything else implementing the
232
+ # A foreign relation — statcatlib's, or anything else implementing the
233
233
  # protocol without being attrs. Its compiled SQL and binds are the only
234
234
  # stable description available, and are what a cache would key on.
235
235
  sql, params = value.to_sql()
@@ -692,6 +692,12 @@ class ObsQuery(Query[ObsRow]):
692
692
  forms the index. One measure fills the cells; several give a column
693
693
  per measure per value. Attributes are not carried.
694
694
 
695
+ A `pl.DataFrame`, not a `LazyFrame` (#516), as every other terminal's
696
+ result is: the query has run by the time a pivot can start, so
697
+ laziness after it would push nothing back into the fetch, and when
698
+ the axis's enumeration is unresolved its columns can only be read
699
+ from the rows. ``.lazy()`` on the result costs nothing.
700
+
695
701
  Args:
696
702
  on: The dimension to spread into columns. Needed when the DSD
697
703
  declares no measure dimension.
@@ -25,7 +25,7 @@ back across.
25
25
 
26
26
  The protocol itself is untouched, because it is not the raw-SQL seam: `Query`
27
27
  satisfies it, which is how one structural query filters another, and external
28
- producers satisfy it, which is how pinaxlib composes. Both are compiled
28
+ producers satisfy it, which is how statcatlib composes. Both are compiled
29
29
  relations the planner can still reason about. Raw text is not.
30
30
  """
31
31
 
@@ -16,12 +16,12 @@ there is nothing for a foreign predicate to be assignable to.
16
16
  That is a real narrowing and worth naming rather than burying: a downstream
17
17
  library can no longer inject a *predicate*. It can still inject a *relation*,
18
18
  which is the composition that was actually used — `SqlRelation` has consumers
19
- in `rest.py`, `is_in`, and pinaxlib — and which works precisely because a
19
+ in `rest.py`, `is_in`, and statcatlib — and which works precisely because a
20
20
  single-column subquery carries no row kind to disagree about.
21
21
 
22
22
  A bundle laid out as a single DuckDB file co-locating ``sdmx.*``,
23
- ``pinax.*``, and the DuckLake catalog tables is what makes `SqlRelation`
24
- composition routinely useful across sdmxlib + pinaxlib: subquery tables are
23
+ ``statcat.*``, and the DuckLake catalog tables is what makes `SqlRelation`
24
+ composition routinely useful across sdmxlib + statcatlib: subquery tables are
25
25
  always visible on the same connection.
26
26
  """
27
27
 
@@ -34,7 +34,7 @@ __all__ = ["HasSource", "SqlRelation"]
34
34
  class SqlRelation(Protocol):
35
35
  """A lazy relation usable inside ``WHERE col IN (...)`` or as a join input.
36
36
 
37
- Producers — `sdmxlib.query.query.Select`, `AncestorMap`, and the pinaxlib
37
+ Producers — `sdmxlib.query.query.Select`, `AncestorMap`, and the statcatlib
38
38
  query builders — expose their compiled SELECT as a ``(sql, params)`` pair.
39
39
  The consumer splices the SQL inline as a subquery and concatenates the
40
40
  params with the outer query's binds, so an N-row traversal becomes one
@@ -43,8 +43,8 @@ class SqlRelation(Protocol):
43
43
  The subquery's tables must be visible on the consumer's connection. For
44
44
  `DuckDBDataStore` (which shares its connection with `Registry`), codelist
45
45
  relations compose automatically because both sides see the same
46
- ``sdmx.code`` table. For pinaxlib's ``Catalog``, the same applies when the
47
- bundle uses Topology B (one DuckDB file holding sdmx.*, pinax.*, and the
46
+ ``sdmx.code`` table. For statcatlib's ``Catalog``, the same applies when the
47
+ bundle uses Topology B (one DuckDB file holding sdmx.*, statcat.*, and the
48
48
  DuckLake catalog).
49
49
  """
50
50
 
@@ -79,7 +79,7 @@ class HasSource(Protocol):
79
79
  Optional by design, and deliberately not folded into `SqlRelation`. Making
80
80
  it required would break every existing producer, and a foreign relation
81
81
  that cannot answer is not necessarily wrong — Topology B puts ``sdmx.*``
82
- and ``pinax.*`` on one connection precisely so cross-library composition is
82
+ and ``statcat.*`` on one connection precisely so cross-library composition is
83
83
  legitimate. So: a producer that *can* identify its source gets checked, and
84
84
  one that cannot is trusted, which is exactly the state before this existed.
85
85
  """
@@ -1571,12 +1571,25 @@ def check_scan_integrity(conn: duckdb.DuckDBPyConnection) -> None:
1571
1571
  raise ScanIntegrityError(msg)
1572
1572
 
1573
1573
 
1574
- class ReadOnlySchemaError(Exception):
1574
+ class StoreSchemaError(Exception):
1575
+ """A store holds artefacts on a schema version this build does not read (GH-57).
1576
+
1577
+ Migration is *drop and rebuild*, which is only right for a cache that can
1578
+ be fetched again. A bundle's ``meta.duckdb`` is the published record, and a
1579
+ store filled with ``add`` may have no source to fetch from, so a populated
1580
+ store on another version is refused rather than emptied: sdmxlib 0.68 to
1581
+ 0.70 opened a 0.67 bundle read-write and deleted every artefact in it,
1582
+ silently, for the reader and for the older sdmxlib that built it.
1583
+ ``LocalRegistry(path, rebuild=True)`` asks for the rebuild.
1584
+ """
1585
+
1586
+
1587
+ class ReadOnlySchemaError(StoreSchemaError):
1575
1588
  """A read-only store is not on this build's schema version.
1576
1589
 
1577
- Migration here is *drop and rebuild* — correct for a cache, impossible on
1578
- a connection opened read-only. Raised in place of DuckDB's own
1579
- ``Cannot execute statement of type "DROP" on database … read-only``,
1590
+ Raised when a rebuild would otherwise run, but the connection is read-only:
1591
+ an empty store to create, or ``rebuild=True``. Raised in place of DuckDB's
1592
+ own ``Cannot execute statement of type "DROP" on database … read-only``,
1580
1593
  which names neither version and reads as a bug in the caller.
1581
1594
 
1582
1595
  First hit when 2.19 landed (#145): the bundle-assertion tier opens
@@ -1591,11 +1604,27 @@ def connection_is_read_only(conn: duckdb.DuckDBPyConnection) -> bool:
1591
1604
  return bool(row[0]) if row else False
1592
1605
 
1593
1606
 
1607
+ def holds_rows(conn: duckdb.DuckDBPyConnection) -> bool:
1608
+ """Whether any ``sdmx`` table but ``_meta`` has a row — what a rebuild would lose.
1609
+
1610
+ Asks every table rather than ``artefact`` alone, because the store being
1611
+ asked about is on *another* schema version, whose tables this build does
1612
+ not know.
1613
+ """
1614
+ tables = conn.execute(
1615
+ """
1616
+ SELECT table_name FROM duckdb_tables()
1617
+ WHERE database_name = current_database() AND schema_name = 'sdmx' AND table_name <> '_meta'
1618
+ """
1619
+ ).fetchall()
1620
+ return any(conn.execute(f'SELECT EXISTS (SELECT 1 FROM sdmx."{name}")').fetchone() == (True,) for (name,) in tables)
1621
+
1622
+
1594
1623
  def migrate(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
1595
1624
  """Drop and rebuild the ``sdmx`` schema.
1596
1625
 
1597
- This is a cache — the migration strategy is *drop and rebuild*.
1598
- Users repopulate from remote registries.
1626
+ Everything in it is lost. `LocalRegistry` calls this only for a store with
1627
+ nothing in it, or when opened with ``rebuild=True`` (GH-57).
1599
1628
 
1600
1629
  Returns a fresh cursor with ``search_path = 'sdmx'``.
1601
1630
  """
File without changes
File without changes
File without changes
File without changes
File without changes