equiframe 0.12.4__py3-none-any.whl

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 (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
equiframe/_r_review.R ADDED
@@ -0,0 +1,308 @@
1
+ # Independent exact comparison. Only base R is loaded by the Python launcher.
2
+ # No Python verdict, detector score, expected label or normalized row is read.
3
+
4
+ main <- function(directory) {
5
+ options(warn = 2)
6
+ Sys.setlocale("LC_COLLATE", "C")
7
+ split <- function(line) strsplit(line, "\t", fixed = TRUE)[[1L]]
8
+ expect <- function(condition) {
9
+ if (!isTRUE(condition)) stop("invalid review transport", call. = FALSE)
10
+ }
11
+ integer_text <- function(value) {
12
+ expect(grepl("^-?[0-9]+$", value))
13
+ negative <- startsWith(value, "-")
14
+ digits <- sub("^-", "", value)
15
+ digits <- sub("^0+", "", digits)
16
+ if (!nzchar(digits)) return("0")
17
+ paste0(if (negative) "-" else "", digits)
18
+ }
19
+ # Arbitrary precision integer comparison uses decimal digits, never double.
20
+ int_compare_canonical <- function(left, right) {
21
+ a_negative <- startsWith(left, "-")
22
+ b_negative <- startsWith(right, "-")
23
+ if (a_negative != b_negative) return(if (a_negative) -1L else 1L)
24
+ a <- sub("^-", "", left)
25
+ b <- sub("^-", "", right)
26
+ result <- if (nchar(a) != nchar(b)) sign(nchar(a) - nchar(b)) else {
27
+ if (a == b) 0L else if (a < b) -1L else 1L
28
+ }
29
+ if (a_negative) -result else result
30
+ }
31
+ int_compare <- function(left, right) {
32
+ int_compare_canonical(integer_text(left), integer_text(right))
33
+ }
34
+ twice <- function(value) {
35
+ digits <- as.integer(strsplit(value, "", fixed = TRUE)[[1L]])
36
+ carry <- 0L
37
+ for (i in rev(seq_along(digits))) {
38
+ number <- 2L * digits[i] + carry
39
+ digits[i] <- number %% 10L
40
+ carry <- number %/% 10L
41
+ }
42
+ paste0(if (carry) as.character(carry) else "", paste0(digits, collapse = ""))
43
+ }
44
+ subtract_one <- function(value) {
45
+ digits <- as.integer(strsplit(value, "", fixed = TRUE)[[1L]])
46
+ for (i in rev(seq_along(digits))) {
47
+ if (digits[i] > 0L) {
48
+ digits[i] <- digits[i] - 1L
49
+ break
50
+ }
51
+ digits[i] <- 9L
52
+ }
53
+ integer_text(paste0(digits, collapse = ""))
54
+ }
55
+ power_two <- function(bits) {
56
+ result <- "1"
57
+ for (i in seq_len(bits)) result <- twice(result)
58
+ result
59
+ }
60
+ decimal_parts <- function(value) {
61
+ expect(grepl("^-?[0-9]+(\\.[0-9]+)?([Ee][+-]?[0-9]+)?$", value))
62
+ exponent_parts <- strsplit(tolower(value), "e", fixed = TRUE)[[1L]]
63
+ exponent <- if (length(exponent_parts) == 2L) as.integer(exponent_parts[2L]) else 0L
64
+ expect(!is.na(exponent) && abs(exponent) <= 152L)
65
+ negative <- startsWith(exponent_parts[1L], "-")
66
+ body <- sub("^-", "", exponent_parts[1L])
67
+ dot <- regexpr(".", body, fixed = TRUE)[1L]
68
+ fraction <- if (dot < 0L) 0L else nchar(body) - dot
69
+ digits <- gsub(".", "", body, fixed = TRUE)
70
+ scale <- fraction - exponent
71
+ if (scale < 0L) {
72
+ digits <- paste0(digits, strrep("0", -scale))
73
+ scale <- 0L
74
+ }
75
+ digits <- sub("^0+", "", digits)
76
+ if (!nzchar(digits)) return(list(number = "0", scale = 0L))
77
+ while (scale > 0L && endsWith(digits, "0")) {
78
+ digits <- substr(digits, 1L, nchar(digits) - 1L)
79
+ scale <- scale - 1L
80
+ }
81
+ list(number = paste0(if (negative) "-" else "", digits), scale = scale)
82
+ }
83
+ decimal_compare <- function(left, right) {
84
+ a <- strsplit(left, ":", fixed = TRUE)[[1L]]
85
+ b <- strsplit(right, ":", fixed = TRUE)[[1L]]
86
+ a_scale <- as.integer(a[2L])
87
+ b_scale <- as.integer(b[2L])
88
+ int_compare(
89
+ paste0(a[1L], strrep("0", max(0L, b_scale - a_scale))),
90
+ paste0(b[1L], strrep("0", max(0L, a_scale - b_scale)))
91
+ )
92
+ }
93
+ read_table <- function(path) {
94
+ lines <- readLines(path, encoding = "ASCII", warn = TRUE)
95
+ expect(length(lines) >= 2L && lines[1L] == "EFR_TABLE_1")
96
+ dims <- as.integer(split(lines[2L]))
97
+ expect(length(dims) == 2L && all(!is.na(dims)) && all(dims >= 0L))
98
+ rows <- dims[1L]
99
+ columns <- dims[2L]
100
+ expect(columns > 0L && length(lines) == 2L + columns + rows)
101
+ metadata <- lapply(lines[2L + seq_len(columns)], split)
102
+ expect(all(lengths(metadata) == 2L))
103
+ names <- vapply(metadata, `[[`, "", 1L)
104
+ signatures <- vapply(metadata, `[[`, "", 2L)
105
+ expect(!anyDuplicated(names))
106
+ data <- if (rows) lapply(lines[2L + columns + seq_len(rows)], split) else list()
107
+ expect(all(lengths(data) == columns))
108
+ list(rows = rows, names = names, signatures = signatures, data = data)
109
+ }
110
+
111
+ config <- readLines(file.path(directory, "contract.txt"), encoding = "ASCII")
112
+ expect(length(config) >= 5L && config[1L] == "EFR_CONTRACT_1")
113
+ header <- split(config[2L])
114
+ count <- as.integer(header[1L])
115
+ expect(length(header) == 3L && !is.na(count) && count > 0L)
116
+ expect(header[2L] %in% c("significant", "ignored"))
117
+ expect(header[3L] %in% c("preserve", "distinct"))
118
+ mode <- config[3L]
119
+ expect(mode %in% c("sequence", "bag"))
120
+ columns <- lapply(config[3L + seq_len(count)], split)
121
+ expect(all(lengths(columns) == 4L))
122
+ expected_names <- vapply(columns, `[[`, "", 1L)
123
+ expected_types <- vapply(columns, `[[`, "", 2L)
124
+ cursor <- 4L + count
125
+ key_count <- as.integer(config[cursor])
126
+ expect(!is.na(key_count) && key_count >= 0L)
127
+ cursor <- cursor + 1L
128
+ keys <- if (key_count) lapply(config[cursor - 1L + seq_len(key_count)], split) else list()
129
+ expect(all(lengths(keys) == 3L))
130
+ cursor <- cursor + key_count
131
+ order_header <- split(config[cursor])
132
+ sort_count <- as.integer(order_header[1L])
133
+ expect(length(order_header) == 2L && !is.na(sort_count) && sort_count >= 0L)
134
+ cursor <- cursor + 1L
135
+ sorts <- if (sort_count) lapply(config[cursor - 1L + seq_len(sort_count)], split) else list()
136
+ expect(all(lengths(sorts) == 3L) && length(config) == cursor - 1L + sort_count)
137
+ expect((mode == "sequence") == (sort_count > 0L))
138
+
139
+ violations <- 0L
140
+ violate <- function() violations <<- violations + 1L
141
+ # Bounds belong to the declared integer type, not to an individual cell.
142
+ # Keep the original decimal-digit arithmetic and reuse its exact result.
143
+ integer_ranges <- new.env(hash = TRUE, parent = emptyenv())
144
+ integer_range <- function(bits, signed) {
145
+ key <- paste0(bits, ":", as.integer(signed))
146
+ if (!exists(key, envir = integer_ranges, inherits = FALSE)) {
147
+ limit <- power_two(bits - as.integer(signed))
148
+ assign(key, list(
149
+ minimum = if (signed) paste0("-", limit) else "0",
150
+ maximum = subtract_one(limit)
151
+ ), envir = integer_ranges)
152
+ }
153
+ get(key, envir = integer_ranges, inherits = FALSE)
154
+ }
155
+ column_signatures <- lapply(columns, function(column) {
156
+ strsplit(column[2L], ":", fixed = TRUE)[[1L]]
157
+ })
158
+ column_integer_ranges <- lapply(column_signatures, function(signature) {
159
+ if (signature[1L] == "integer") {
160
+ integer_range(as.integer(signature[2L]), signature[3L] == "1")
161
+ } else NULL
162
+ })
163
+ normalize <- function(token, column, signature, bounds) {
164
+ expect(nzchar(token))
165
+ tag <- substr(token, 1L, 1L)
166
+ value <- substring(token, 2L)
167
+ if (tag == "N") {
168
+ expect(token == "N")
169
+ if (column[3L] == "0") violate()
170
+ return(token)
171
+ }
172
+ kind <- signature[1L]
173
+ if (kind == "boolean") {
174
+ expect(token %in% c("B0", "B1"))
175
+ } else if (kind == "string") {
176
+ expect(tag == "S" && (value == "-" || grepl("^([0-9a-f]{2})+$", value)))
177
+ } else if (kind == "integer") {
178
+ expect(tag == "I")
179
+ canonical <- integer_text(value)
180
+ if (int_compare_canonical(canonical, bounds$minimum) < 0L ||
181
+ int_compare_canonical(canonical, bounds$maximum) > 0L) violate()
182
+ token <- paste0("I", canonical)
183
+ } else if (kind == "decimal") {
184
+ expect(tag == "D")
185
+ parts <- decimal_parts(value)
186
+ precision <- as.integer(signature[2L])
187
+ scale <- as.integer(signature[3L])
188
+ digits <- sub("^-", "", parts$number)
189
+ if (parts$number != "0" &&
190
+ (parts$scale > scale || nchar(digits) + scale - parts$scale > precision)) violate()
191
+ token <- paste0("D", parts$number, ":", parts$scale)
192
+ } else if (kind == "float") {
193
+ expect(tag %in% c("F", "Q", "P", "M"))
194
+ if (tag == "Q" && column[4L] == "forbid") violate()
195
+ if (tag == "F") {
196
+ number <- as.numeric(value)
197
+ expect(is.finite(number))
198
+ # R performs its own binary conversion; signed zero is equal.
199
+ token <- paste0("F", if (number == 0) "0" else sprintf("%a", number))
200
+ } else expect(nchar(token) == 1L)
201
+ } else if (kind %in% c("date", "timestamp")) {
202
+ expect(tag == if (kind == "date") "A" else "T")
203
+ token <- paste0(tag, integer_text(value))
204
+ } else stop("unsupported type", call. = FALSE)
205
+ token
206
+ }
207
+ compare_cell <- function(a, b, sort_spec) {
208
+ if (a == "N" || b == "N") {
209
+ result <- as.integer(a == "N") - as.integer(b == "N")
210
+ return(result * if (sort_spec[3L] == "first") -1L else 1L)
211
+ }
212
+ tag <- substr(a, 1L, 1L)
213
+ other <- substr(b, 1L, 1L)
214
+ if (tag %in% c("M", "F", "P", "Q")) {
215
+ rank <- c(M = 0L, F = 1L, P = 2L, Q = 3L)
216
+ result <- sign(rank[tag] - rank[other])
217
+ if (!result && tag == "F") {
218
+ result <- sign(as.numeric(substring(a, 2L)) - as.numeric(substring(b, 2L)))
219
+ }
220
+ } else if (tag %in% c("I", "A", "T")) {
221
+ result <- int_compare_canonical(substring(a, 2L), substring(b, 2L))
222
+ } else if (tag == "D") {
223
+ result <- decimal_compare(substring(a, 2L), substring(b, 2L))
224
+ } else {
225
+ result <- if (a == b) 0L else if (a < b) -1L else 1L
226
+ }
227
+ result * if (sort_spec[2L] == "ascending") 1L else -1L
228
+ }
229
+ prepare <- function(table) {
230
+ aligned <- match(expected_names, table$names)
231
+ if (length(table$names) != count || anyNA(aligned) ||
232
+ (header[2L] == "significant" && !identical(table$names, expected_names))) {
233
+ violate()
234
+ return(NULL)
235
+ }
236
+ if (!identical(unname(table$signatures[aligned]), unname(expected_types))) {
237
+ violate()
238
+ return(NULL)
239
+ }
240
+ result <- lapply(table$data, function(row) {
241
+ vapply(seq_len(count), function(j) {
242
+ normalize(row[aligned[j]], columns[[j]], column_signatures[[j]],
243
+ column_integer_ranges[[j]])
244
+ }, "")
245
+ })
246
+ encoded <- vapply(result, paste, "", collapse = "\t")
247
+ if (header[3L] == "distinct") violations <<- violations + sum(duplicated(encoded))
248
+ for (key in keys) {
249
+ indices <- as.integer(strsplit(key[3L], ",", fixed = TRUE)[[1L]])
250
+ expect(all(!is.na(indices)) && all(indices >= 1L & indices <= count))
251
+ key_rows <- lapply(result, `[`, indices)
252
+ incomplete <- vapply(key_rows, function(values) any(values == "N"), FALSE)
253
+ if (key[2L] == "reject") violations <<- violations + sum(incomplete)
254
+ if (key[1L] == "1") {
255
+ complete <- vapply(key_rows[!incomplete], paste, "", collapse = "\t")
256
+ violations <<- violations + sum(duplicated(complete))
257
+ }
258
+ }
259
+ if (length(result) > 1L && mode == "sequence") {
260
+ for (i in 2L:length(result)) {
261
+ comparison <- 0L
262
+ for (sort_spec in sorts) {
263
+ j <- as.integer(sort_spec[1L])
264
+ expect(!is.na(j) && j >= 1L && j <= count)
265
+ comparison <- compare_cell(result[[i - 1L]][j], result[[i]][j], sort_spec)
266
+ if (comparison) break
267
+ }
268
+ if (comparison > 0L || (comparison == 0L && order_header[2L] == "reject")) violate()
269
+ }
270
+ }
271
+ if (mode == "bag") sort(encoded, method = "radix") else encoded
272
+ }
273
+ left <- read_table(file.path(directory, "left.txt"))
274
+ right <- read_table(file.path(directory, "right.txt"))
275
+ a <- prepare(left)
276
+ b <- prepare(right)
277
+ matched <- 0L
278
+ if (!is.null(a) && !is.null(b)) {
279
+ if (mode == "sequence") {
280
+ n <- min(length(a), length(b))
281
+ if (n) matched <- sum(a[seq_len(n)] == b[seq_len(n)])
282
+ } else {
283
+ i <- 1L
284
+ j <- 1L
285
+ while (i <= length(a) && j <= length(b)) {
286
+ if (a[i] == b[j]) {
287
+ matched <- matched + 1L
288
+ i <- i + 1L
289
+ j <- j + 1L
290
+ } else if (a[i] < b[j]) i <- i + 1L else j <- j + 1L
291
+ }
292
+ }
293
+ }
294
+ unmatched <- left$rows + right$rows - 2L * matched
295
+ state <- if (unmatched || violations) "different" else "equivalent"
296
+ version <- paste(sprintf("%02x", as.integer(charToRaw(R.version.string))), collapse = "")
297
+ writeLines(c("EFR_RESULT_1", state, as.character(matched), as.character(unmatched),
298
+ as.character(violations), as.character(left$rows), as.character(right$rows),
299
+ version), file.path(directory, "result.txt"), useBytes = TRUE)
300
+ }
301
+
302
+ arguments <- commandArgs(trailingOnly = TRUE)
303
+ if (length(arguments) != 1L) quit(status = 2L)
304
+ tryCatch(main(arguments[1L]), error = function(error) {
305
+ # No row values or paths are printed by the process error channel.
306
+ writeLines("equiframe independent R review failed", stderr())
307
+ quit(status = 3L)
308
+ })
@@ -0,0 +1,167 @@
1
+ """Regular-file reads without following links or blocking on special files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import os
7
+ import stat
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import BinaryIO
12
+
13
+ from equiframe.errors import ArtifactIntegrityError, ArtifactIOError
14
+
15
+
16
+ def absolute_path(
17
+ path: Path, *, path_error: type[ArtifactIntegrityError] = ArtifactIntegrityError
18
+ ) -> Path:
19
+ if ".." in path.parts:
20
+ raise path_error("parent traversal is not permitted")
21
+ try:
22
+ return path.absolute()
23
+ except OSError as error:
24
+ raise ArtifactIOError("cannot determine absolute evidence path") from error
25
+
26
+
27
+ def regular_directory(
28
+ path: Path, *, path_error: type[ArtifactIntegrityError] = ArtifactIntegrityError
29
+ ) -> Path:
30
+ path = absolute_path(path, path_error=path_error)
31
+ try:
32
+ for item in (*reversed(path.parents), path):
33
+ inspected = item.lstat()
34
+ if not (
35
+ stat.S_ISDIR(inspected.st_mode)
36
+ and not getattr(inspected, "st_file_attributes", 0) & 0x400
37
+ ):
38
+ raise path_error("evidence directories must not be symlinks or reparse points")
39
+ except OSError as error:
40
+ raise ArtifactIOError("cannot inspect evidence directory") from error
41
+ return path
42
+
43
+
44
+ def local_input_path(path: Path) -> Path:
45
+ """Admit explicit local parent navigation after inspecting traversed directories.
46
+
47
+ Evidence confinement still uses absolute_path and refuses parent navigation.
48
+ This helper is for caller-selected local paths, which have no fixed root.
49
+ """
50
+ if not isinstance(path, Path):
51
+ raise TypeError("local path must be a Path")
52
+ path = path.absolute()
53
+ current = Path(path.anchor)
54
+ for component in path.parts[1:]:
55
+ if component == "..":
56
+ regular_directory(current)
57
+ current = current.parent
58
+ else:
59
+ current = current / component
60
+ return current
61
+
62
+
63
+ def new_directory_path(path: Path, *, ordinary_file_error: bool = False) -> Path:
64
+ """Check existing lexical ancestors before creating a new output directory."""
65
+ path = absolute_path(path)
66
+ for item in (*reversed(path.parents), path):
67
+ try:
68
+ inspected = item.lstat()
69
+ except FileNotFoundError:
70
+ continue
71
+ except OSError as error:
72
+ raise ArtifactIOError("cannot inspect output directory") from error
73
+ if (
74
+ ordinary_file_error
75
+ and stat.S_ISREG(inspected.st_mode)
76
+ and not (getattr(inspected, "st_file_attributes", 0) & 0x400)
77
+ ):
78
+ raise NotADirectoryError(errno.ENOTDIR, "operation output requires a directory")
79
+ if not stat.S_ISDIR(inspected.st_mode) or (
80
+ getattr(inspected, "st_file_attributes", 0) & 0x400
81
+ ):
82
+ raise ArtifactIntegrityError("output directories must not be links or special files")
83
+ return path
84
+
85
+
86
+ def _walk_error(error: OSError) -> None:
87
+ raise ArtifactIOError("cannot inventory evidence directory") from error
88
+
89
+
90
+ def local_output_directory(path: Path) -> Path:
91
+ """Inspect caller-selected ancestors before normalizing a new operation root.
92
+
93
+ Returning a path does not create directories or sandbox concurrent filesystem
94
+ owners. Each writer retains its existing exclusive publication mechanism.
95
+ """
96
+ return new_directory_path(local_input_path(path), ordinary_file_error=True)
97
+
98
+
99
+ def regular_files(directory: Path) -> tuple[Path, ...]:
100
+ """Inventory a regular directory tree, refusing links and special members."""
101
+ root = regular_directory(directory)
102
+ members: list[Path] = []
103
+ for parent, directories, files in os.walk(root, followlinks=False, onerror=_walk_error):
104
+ for names, expected in ((directories, stat.S_ISDIR), (files, stat.S_ISREG)):
105
+ for name in names:
106
+ path = Path(parent) / name
107
+ try:
108
+ inspected = path.lstat()
109
+ except OSError as error:
110
+ raise ArtifactIOError("cannot inspect evidence member") from error
111
+ if not expected(inspected.st_mode) or (
112
+ getattr(inspected, "st_file_attributes", 0) & 0x400
113
+ ):
114
+ raise ArtifactIntegrityError(
115
+ "evidence inventory rejects links and special files"
116
+ )
117
+ members.extend(Path(parent) / name for name in files)
118
+ return tuple(sorted(members))
119
+
120
+
121
+ @contextmanager
122
+ def open_regular_file(
123
+ path: Path, *, path_error: type[ArtifactIntegrityError] = ArtifactIntegrityError
124
+ ) -> Iterator[BinaryIO]:
125
+ """Pin POSIX directories; on other hosts check identities around open.
126
+
127
+ These checks do not sandbox hostile concurrent mounts or filesystem owners.
128
+ """
129
+ parent = regular_directory(path.parent, path_error=path_error)
130
+ descriptor: int | None = None
131
+ directory_descriptors: list[int] = []
132
+ try:
133
+ before = path.lstat()
134
+ if not (
135
+ stat.S_ISREG(before.st_mode) and not getattr(before, "st_file_attributes", 0) & 0x400
136
+ ):
137
+ raise path_error("evidence must be a regular file, not a link or reparse point")
138
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0)
139
+ if os.open in os.supports_dir_fd and hasattr(os, "O_NOFOLLOW"):
140
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
141
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | no_follow
142
+ current = os.open(parent.anchor, directory_flags)
143
+ directory_descriptors.append(current)
144
+ for component in parent.parts[1:]:
145
+ current = os.open(component, directory_flags, dir_fd=current)
146
+ directory_descriptors.append(current)
147
+ descriptor = os.open(path.name, flags | no_follow, dir_fd=current)
148
+ else:
149
+ descriptor = os.open(path, flags)
150
+ regular_directory(parent, path_error=path_error)
151
+ after = os.fstat(descriptor)
152
+ if not (
153
+ stat.S_ISREG(after.st_mode) and not getattr(after, "st_file_attributes", 0) & 0x400
154
+ ):
155
+ raise path_error("evidence must remain a regular file without reparse points")
156
+ if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
157
+ raise ArtifactIntegrityError("evidence file changed while opening")
158
+ with os.fdopen(descriptor, "rb") as stream:
159
+ descriptor = None
160
+ yield stream
161
+ except OSError as error:
162
+ raise ArtifactIOError("cannot read evidence file") from error
163
+ finally:
164
+ if descriptor is not None:
165
+ os.close(descriptor)
166
+ for directory_descriptor in reversed(directory_descriptors):
167
+ os.close(directory_descriptor)