summaryrefslogtreecommitdiff
path: root/eclass/stainless-python.eclass
blob: 0a6bba2e8813ac5228b7e221649e286eacddaf71 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# Copyright 2026 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

# @ECLASS: stainless-python.eclass
# @MAINTAINER:
# Florian Albrechtskirchinger <falbrechtskirchinger@gmail.com>
# @AUTHOR:
# Florian Albrechtskirchinger <falbrechtskirchinger@gmail.com>
# @SUPPORTED_EAPIS: 8 9
# @BLURB: Handle Python SDKs generated via Stainless.
# @DESCRIPTION:
# The stainless-python eclass manages the building and testing of Python SDKs
# generated via Stainless.
# It automates fetching the OpenAPI spec, provisioning the steady mock server
# from local npm packages, and managing the background mock server process
# lifecycle during tests.

case ${EAPI} in
	8|9) ;;
	*) die "${ECLASS}: EAPI ${EAPI:-0} not supported" ;;
esac

if [[ ! ${_STAINLESS_PYTHON_ECLASS} ]]; then
_STAINLESS_PYTHON_ECLASS=1

inherit distutils-r1

# @ECLASS_VARIABLE: STAINLESS_SPEC_BASE_URI
# @PRE_INHERIT
# @DESCRIPTION:
# The base URI for Stainless SDK OpenAPI spec files.
: "${STAINLESS_SPEC_BASE_URI:=https://storage.googleapis.com/stainless-sdk-openapi-specs}"

# @ECLASS_VARIABLE: STAINLESS_SPEC_PATH
# @PRE_INHERIT
# @DEFAULT_UNSET
# @REQUIRED
# @DESCRIPTION:
# The path of the OpenAPI spec file relative to ${STAINLESS_SPEC_BASE_URI}.

# @ECLASS_VARIABLE: STAINLESS_SPEC_DISTNAME
# @PRE_INHERIT
# @DESCRIPTION:
# The target filename of the OpenAPI spec file in ${DISTDIR}.
: "${STAINLESS_SPEC_DISTNAME:=${PN}-openapi-spec-${PV}.yml}"

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_VERSION
# @PRE_INHERIT
# @DEFAULT_UNSET
# @REQUIRED
# @DESCRIPTION:
# The version string of the mock server.

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_PACKAGE_JSON
# @PRE_INHERIT
# @DEFAULT_UNSET
# @REQUIRED
# @DESCRIPTION:
# The full path to the mock server package.json.

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_PACKAGE_LOCK_JSON
# @PRE_INHERIT
# @DESCRIPTION:
# The full path to the mock server package-lock.json.
: "${STAINLESS_MOCK_SERVER_PACKAGE_LOCK_JSON:=${STAINLESS_MOCK_SERVER_PACKAGE_JSON%.json}-lock.json}"

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_PORT
# @DESCRIPTION:
# The requested port for the mock server. 0 to bind to a random available port.
: "${STAINLESS_MOCK_SERVER_PORT:=0}"

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_EXTRA_ARGS
# @DESCRIPTION:
# Additional arguments to pass to the mock server.
STAINLESS_MOCK_SERVER_EXTRA_ARGS=()

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_DISTFILES
# @INTERNAL
# @DESCRIPTION:
# The array of npm packages for the mock server.
STAINLESS_MOCK_SERVER_DISTFILES=()

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_DIR
# @INTERNAL
# @DESCRIPTION:
# The full path to the mock server directory.
: "${STAINLESS_MOCK_SERVER_DIR:=${WORKDIR}/stainless-mock-server}"

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_EXEC
# @INTERNAL
# @DESCRIPTION:
# The full path to the mock server executable.
: "${STAINLESS_MOCK_SERVER_EXEC:=${STAINLESS_MOCK_SERVER_DIR}/node_modules/.bin/steady}"

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_PID
# @INTERNAL
# @DESCRIPTION:
# The PID assigned to the mock server.

# @ECLASS_VARIABLE: STAINLESS_MOCK_SERVER_PORT_ACTUAL
# @INTERNAL
# @DESCRIPTION:
# The port the mock server actually bound to.

_stainless_set_globals() {
	[[ -n ${STAINLESS_SPEC_PATH} ]] ||
		die "STAINLESS_SPEC_PATH undefined."
	[[ -n ${STAINLESS_MOCK_SERVER_VERSION} ]] ||
		die "STAINLESS_MOCK_SERVER_VERSION undefined."
	[[ -n ${STAINLESS_MOCK_SERVER_PACKAGE_JSON} ]] ||
		die "STAINLESS_MOCK_SERVER_PACKAGE_JSON undefined."

	local stdy_base_uri="https://registry.npmjs.org/@stdy"
	local stdy_pv="${STAINLESS_MOCK_SERVER_VERSION}"

	SRC_URI="
		test? (
			${STAINLESS_SPEC_BASE_URI}/${STAINLESS_SPEC_PATH}
				-> ${STAINLESS_SPEC_DISTNAME}

			${stdy_base_uri}/cli/-/cli-${stdy_pv}.tgz
				-> stdy-cli-${stdy_pv}.npm.tgz

			amd64? (
				${stdy_base_uri}/cli-linux-x64/-/cli-linux-x64-${stdy_pv}.tgz
					-> stdy-cli-linux-x64-${stdy_pv}.npm.tgz
			)

			arm64? (
				${stdy_base_uri}/cli-linux-arm64/-/cli-linux-arm64-${stdy_pv}.tgz
					-> stdy-cli-linux-arm64-${stdy_pv}.npm.tgz
			)
		)
	"

	STAINLESS_MOCK_SERVER_DISTFILES=(
		"stdy-cli-${stdy_pv}.npm.tgz"
		"stdy-cli-linux-x64-${stdy_pv}.npm.tgz"
		"stdy-cli-linux-arm64-${stdy_pv}.npm.tgz"
	)
	readonly STAINLESS_MOCK_SERVER_DISTFILES

	BDEPEND="
		test? (
			net-libs/nodejs[npm]
			net-misc/curl
			sys-apps/iproute2
		)
	"
}
_stainless_set_globals
unset -f _stainless_set_globals

# @FUNCTION: stainless_setup_mock_server
# @USAGE:
# @DESCRIPTION:
# Prepare the mock server by populating the npm cache and installing the npm
# packages.
stainless_setup_mock_server() {
	einfo "Populating npm cache ..."

	local -x npm_config_offline="true"
	local -x npm_config_audit="false"
	local -x npm_config_fund="false"
	local -x npm_config_cache="${WORKDIR}/stainless-npm-cache"
	mkdir -p "${npm_config_cache}" || die

	local f
	for f in "${STAINLESS_MOCK_SERVER_DISTFILES[@]}"; do
		[[ -e ${DISTDIR}/${f} ]] || continue
		if ! npm cache add "${DISTDIR}/${f}" &>"${T}/stainless-npm-cache-add.log"; then
			eerror "npm cache add '${f}' failed:"
			cat "${T}/stainless-npm-cache-add.log"
			die "npm cache add '${f}' failed"
		fi
	done

	einfo "Installing mock server ..."

	mkdir -p "${STAINLESS_MOCK_SERVER_DIR}" || die

	cp "${STAINLESS_MOCK_SERVER_PACKAGE_JSON}" \
		"${STAINLESS_MOCK_SERVER_DIR}/package.json" || die
	cp "${STAINLESS_MOCK_SERVER_PACKAGE_LOCK_JSON}" \
		"${STAINLESS_MOCK_SERVER_DIR}/package-lock.json" || die

	pushd "${STAINLESS_MOCK_SERVER_DIR}" >/dev/null || die
	if ! npm ci &>"${T}/stainless-npm-ci.log"; then
		eerror "npm ci failed:"
		cat "${T}/stainless-npm-ci.log"
		die "npm ci failed"
	fi
	popd >/dev/null || die

	[[ -x ${STAINLESS_MOCK_SERVER_EXEC} ]] || die
}

# @FUNCTION: _stainless_read_cmdline
# @USAGE: <pid|'self'> <out_var_name>
# @INTERNAL
# @DESCRIPTION:
# Read /proc/<pid|'self'>/cmdline into the provided array variable.
_stainless_read_cmdline() {
	(( ${#} == 2 )) || die "Usage: ${FUNCNAME} <pid|'self'> <out_var_name>"

	local pid="${1}"
	local -n out_var="${2}"

	readarray -d '' out_var <"/proc/${pid}/cmdline" 2>/dev/null || die
}

# @FUNCTION: _stainless_is_mock_server_running
# @USAGE:
# @INTERNAL
# @DESCRIPTION:
# Check if the mock server is running.
_stainless_is_mock_server_running() {
	[[ -n ${STAINLESS_MOCK_SERVER_PID} ]] || die

	kill -0 "${STAINLESS_MOCK_SERVER_PID}" 2>/dev/null || return 1

	local -a args
	_stainless_read_cmdline "${STAINLESS_MOCK_SERVER_PID}" args

	# Check args[1] (native) or args[2] (QEMU user-mode emulation)
	[[ ${args[1]} == "${STAINLESS_MOCK_SERVER_EXEC}" ||
		${args[2]} == "${STAINLESS_MOCK_SERVER_EXEC}" ]]
}

# @FUNCTION: _stainless_mock_server_health_check
# @USAGE:
# @INTERNAL
# @DESCRIPTION:
# Call the mock server health check endpoint.
_stainless_mock_server_health_check() {
	local base_url="http://127.0.0.1:${STAINLESS_MOCK_SERVER_PORT_ACTUAL}"

	# omit '|| die'; caller checks exit code
	curl -sf "${base_url}/_x-steady/health" &>/dev/null
}

# @FUNCTION: _stainless_wait_for_mock_server
# @USAGE:
# @INTERNAL
# @DESCRIPTION:
# Wait for the mock server to start.
_stainless_wait_for_mock_server() {
	[[ -n ${STAINLESS_MOCK_SERVER_PID} ]] || die

	local -a self_args mock_args
	_stainless_read_cmdline self self_args

	# 30s timeout (300 attempts with 0.1s waits)
	local attempts=0 ready=0
	while (( attempts < 300 )); do
		# We can arrive here between fork() & execve()
		# Wait for Bash to spawn the mock server
		if [[ -n ${mock_args[*]} && ${self_args[*]} != "${mock_args[*]}" ]]; then
			if ! _stainless_is_mock_server_running; then
				eerror "Mock server exited during startup:"
				cat "${T}/stainless-steady.log"
				die "Mock server exited during startup"
			fi

			# Discover actual port
			if [[ -z ${STAINLESS_MOCK_SERVER_PORT_ACTUAL} ]]; then
				if (( STAINLESS_MOCK_SERVER_PORT == 0 )); then
					local -a pids
					readarray -t pids \
						< <(pgrep -P "${STAINLESS_MOCK_SERVER_PID}" 2>/dev/null)

					if (( ${#pids[@]} > 1 )); then
						die "Mock server unexpectedly spawned multiple child processes"
					elif (( ${#pids[@]} == 1 )); then
						local port
						port=$(ss -Hntlp 2>/dev/null | awk -v pid="${pids[0]}" \
							'$0 ~ "pid="pid"," {split($4, a, ":"); print a[length(a)]}')
						if [[ -n ${port} ]]; then
							STAINLESS_MOCK_SERVER_PORT_ACTUAL=${port}
						fi
					fi
				else
					STAINLESS_MOCK_SERVER_PORT_ACTUAL=${STAINLESS_MOCK_SERVER_PORT}
				fi
			fi

			# Confirm startup by connecting to health check endpoint
			if [[ -n ${STAINLESS_MOCK_SERVER_PORT_ACTUAL} ]]; then
				if _stainless_mock_server_health_check; then
					ready=1
					break
				fi
			fi
		fi

		(( attempts++ ))
		sleep 0.1

		_stainless_read_cmdline "${STAINLESS_MOCK_SERVER_PID}" mock_args
	done

	if (( ! ready )); then
		# Note: Possible sign of failed port discovery
		eerror "Timed out waiting for mock server to start:"
		cat "${T}/stainless-steady.log"
		die "Timed out waiting for mock server to start"
	fi
}

# @FUNCTION: stainless_start_mock_server
# @USAGE:
# @DESCRIPTION:
# Start the mock server and wait for it to become ready.
stainless_start_mock_server() {
	if (( STAINLESS_MOCK_SERVER_PORT != 0 )); then
		if _stainless_mock_server_health_check; then
			die "Mock server address already in use"
		fi
	fi

	einfo "Starting mock server ..."

	# Replicate the logic from scripts/mock --daemon
	"${STAINLESS_MOCK_SERVER_EXEC}" \
		--host 127.0.0.1 -p "${STAINLESS_MOCK_SERVER_PORT}" \
		--validator-form-array-format=brackets \
		--validator-query-array-format=brackets \
		--validator-form-object-format=brackets \
		--validator-query-object-format=brackets \
		"${STAINLESS_MOCK_SERVER_EXTRA_ARGS[@]}" \
		"${DISTDIR}/${STAINLESS_SPEC_DISTNAME}" &>"${T}/stainless-steady.log" &
	STAINLESS_MOCK_SERVER_PID=${!}

	_stainless_wait_for_mock_server

	einfo "Mock server ready"
}

# @FUNCTION: stainless_stop_mock_server
# @USAGE:
# @DESCRIPTION:
# Stop the mock server.
stainless_stop_mock_server() {
	[[ -n ${STAINLESS_MOCK_SERVER_PID} ]] || die

	if _stainless_is_mock_server_running; then
		einfo "Stopping mock server ..."
		kill "${STAINLESS_MOCK_SERVER_PID}" || die
		wait "${STAINLESS_MOCK_SERVER_PID}" 2>/dev/null
	fi
}

# @FUNCTION: stainless-python_pkg_setup
# @DESCRIPTION:
# Abort if testing is enabled on an architecture unsupported by the mock server.
stainless-python_pkg_setup() {
	if use test && ! { use amd64 || use arm64; }; then
		die "Tests are only supported on amd64 and arm64"
	fi
}

# @FUNCTION: stainless-python_src_unpack
# @USAGE:
# @DESCRIPTION:
# Implement unpacking of dist files, skipping the mock server npm packages,
# and run deferred validation of the mock server version.
stainless-python_src_unpack() {
	# Validate mock server version:
	# Ensure STAINLESS_MOCK_SERVER_VERSION matches package.json
	local json expected_pv
	json=$(<"${STAINLESS_MOCK_SERVER_PACKAGE_JSON}") || die

	[[ ${json} =~ \"@stdy/cli\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]] &&
		expected_pv=${BASH_REMATCH[1]}

	[[ -n ${expected_pv} ]] ||
		die "Expected '@stdy/cli' dependency declaration in package.json"

	[[ ${STAINLESS_MOCK_SERVER_VERSION} == "${expected_pv}" ]] ||
		die "Mock server version mismatch:" \
			"'${STAINLESS_MOCK_SERVER_VERSION}' (from var) !=" \
			"'${expected_pv}' (from package.json)"

	local f
	for f in ${A}; do
		! has "${f}" "${STAINLESS_MOCK_SERVER_DISTFILES[@]}" && unpack "${f}"
	done
}

# @FUNCTION: stainless-python_src_test
# @USAGE:
# @DESCRIPTION:
# Implement the mock server setup, startup, test run, and shutdown logic.
stainless-python_src_test() {
	stainless_setup_mock_server

	stainless_start_mock_server

	local -x TEST_API_BASE_URL="http://127.0.0.1:${STAINLESS_MOCK_SERVER_PORT_ACTUAL}"
	nonfatal distutils-r1_src_test
	local ret=${?}

	stainless_stop_mock_server

	(( ret == 0 )) || die
}

# @FUNCTION: _stainless_death_cleanup
# @USAGE:
# @INTERNAL
# @DESCRIPTION:
# Death hook function to gracefully stop a running mock server during abnormal
# test termination.
_stainless_death_cleanup() {
	[[ -n ${STAINLESS_MOCK_SERVER_PID} ]] || return 0

	kill "${STAINLESS_MOCK_SERVER_PID}" 2>/dev/null
	wait "${STAINLESS_MOCK_SERVER_PID}" 2>/dev/null
}

has _stainless_death_cleanup "${EBUILD_DEATH_HOOKS}" ||
	EBUILD_DEATH_HOOKS+=" _stainless_death_cleanup"

EXPORT_FUNCTIONS pkg_setup src_unpack src_test

fi