@uknows/setup 0.1.0 → 0.2.0
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.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which OpenTelemetry set (PINS.python.sets, newest first) a Python app gets. The first set that fits the direct
|
|
3
|
+
* limits of the app (protobuf and opentelemetry-* in its dependency files) and its Python version wins. pip apps get
|
|
4
|
+
* exact pins from that set. uv and Poetry apps get floors, and the set only decides which instrumentation packages
|
|
5
|
+
* exist for them (bootstrap SINCE). The plan says why when it is not the newest set.
|
|
6
|
+
*/
|
|
7
|
+
import { PINS, RESOLVE } from '../contract.mjs'
|
|
8
|
+
import { tomlArray, tomlEntries, tomlTable } from '../toml.mjs'
|
|
9
|
+
import { clauses, compareVersion, lowestRelease, overlaps, satisfies } from '../versions.mjs'
|
|
10
|
+
import { normalize } from './python-bootstrap.mjs'
|
|
11
|
+
|
|
12
|
+
/** opentelemetry-* packages on the core release line (1.x), and on the contrib line (0.xb). Other names are ignored. */
|
|
13
|
+
const CORE = /^opentelemetry-(?:api|sdk|proto|exporter-otlp(?:-proto-(?:http|grpc|common))?)$/
|
|
14
|
+
const CONTRIB = /^opentelemetry-(?:distro|instrumentation(?:-[\w-]+)?|semantic-conventions|util-http)$/
|
|
15
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
16
|
+
const read = (ws, path) => { try { return ws.read(path) } catch { return null } }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{name: string, spec: string, style: 'python'|'poetry', file: string, line: number, value: string}} Limit
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The direct limits that decide the set: protobuf and opentelemetry-* entries with a version limit, in requirements
|
|
24
|
+
* files (1 level of -r), [project] dependencies and [tool.poetry.dependencies] of these pyproject.toml files.
|
|
25
|
+
* @param {any} ws
|
|
26
|
+
* @param {string[]} files dependency files: requirements*.txt or pyproject.toml
|
|
27
|
+
* @returns {Limit[]}
|
|
28
|
+
*/
|
|
29
|
+
export function directLimits(ws, files) {
|
|
30
|
+
const out = []
|
|
31
|
+
const relevant = (name) => name === 'protobuf' || CORE.test(name) || CONTRIB.test(name)
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
const fromRequirements = (file, depth) => {
|
|
34
|
+
if (seen.has(file)) return
|
|
35
|
+
seen.add(file)
|
|
36
|
+
const text = read(ws, file)
|
|
37
|
+
if (text === null) return
|
|
38
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
39
|
+
const line = raw.replace(/(^|\s)#.*$/, '').trim()
|
|
40
|
+
const include = /^(?:-r|--requirement)(?:\s+|=)(\S+)/.exec(line)
|
|
41
|
+
if (include && depth === 0) return fromRequirements(join(dirOf(file), include[1]), 1)
|
|
42
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~][^;]*)?/.exec(line)
|
|
43
|
+
if (m?.[2] && relevant(normalize(m[1]))) out.push({ name: normalize(m[1]), spec: m[2].trim(), style: 'python', file, line: i + 1, value: line.split(';')[0].trim() })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (/\.txt$/.test(file)) { fromRequirements(file, 0); continue }
|
|
48
|
+
const text = read(ws, file)
|
|
49
|
+
if (text === null) continue
|
|
50
|
+
const lines = text.split(/\r?\n/)
|
|
51
|
+
for (const req of tomlArray(tomlTable(text, 'project'), 'dependencies')) {
|
|
52
|
+
const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~][^;]*)?/.exec(req)
|
|
53
|
+
if (!m?.[2] || !relevant(normalize(m[1]))) continue
|
|
54
|
+
const line = lines.findIndex((l) => l.includes(req)) + 1
|
|
55
|
+
out.push({ name: normalize(m[1]), spec: m[2].trim(), style: 'python', file, line, value: req.split(';')[0].trim() })
|
|
56
|
+
}
|
|
57
|
+
for (const { key, value, line } of tomlEntries(text, 'tool.poetry.dependencies')) {
|
|
58
|
+
const name = normalize(key)
|
|
59
|
+
const spec = /^["']([^"']*)["']/.exec(value)?.[1] ?? /\bversion\s*=\s*["']([^"']*)["']/.exec(value)?.[1]
|
|
60
|
+
if (spec && spec !== '*' && relevant(name)) out.push({ name, spec, style: 'poetry', file, line, value: `${key} = ${value}` })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Python versions a uv or Poetry project must support: the lowest of requires-python, or python in Poetry. */
|
|
67
|
+
export function projectPython(ws, files) {
|
|
68
|
+
for (const file of files.filter((f) => /pyproject\.toml$/.test(f))) {
|
|
69
|
+
const text = read(ws, file) ?? ''
|
|
70
|
+
const requires = /^\s*requires-python\s*=\s*["']([^"']+)["']/m.exec(tomlTable(text, 'project'))?.[1]
|
|
71
|
+
const low = requires ? lowestRelease(requires) : null
|
|
72
|
+
if (low) return low
|
|
73
|
+
const poetry = tomlEntries(text, 'tool.poetry.dependencies').find((e) => e.key === 'python')
|
|
74
|
+
const spec = poetry && /^["']([^"']*)["']/.exec(poetry.value)?.[1]
|
|
75
|
+
const lowPoetry = spec ? lowestRelease(spec, 'poetry') : null
|
|
76
|
+
if (lowPoetry) return lowPoetry
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when the set allows the limit. */
|
|
82
|
+
function fits(set, limit) {
|
|
83
|
+
if (limit.name === 'protobuf') return overlaps(limit.spec, set.protobuf, limit.style)
|
|
84
|
+
if (CORE.test(limit.name)) return satisfies(set.core, limit.spec, limit.style)
|
|
85
|
+
if (CONTRIB.test(limit.name)) return satisfies(set.contrib, limit.spec, limit.style)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The first set that fits. why: the plan sentence when it is not the newest set. When none fits: the required step.
|
|
91
|
+
* @param {{limits: Limit[], python: [number, number]|null, tool?: string|null}} opts tool: uv or poetry for floors, else pins.
|
|
92
|
+
* @returns {{set: typeof PINS.python.sets[number], why: string|null}|{block: import('../types.mjs').Block}}
|
|
93
|
+
*/
|
|
94
|
+
export function pickSet({ limits, python, tool = null }) {
|
|
95
|
+
const sets = PINS.python.sets
|
|
96
|
+
const pyOk = (set) => !python || python[0] * 1000 + python[1] >= set.minPython[0] * 1000 + set.minPython[1]
|
|
97
|
+
const index = sets.findIndex((set) => pyOk(set) && limits.every((l) => fits(set, l)))
|
|
98
|
+
if (index < 0) {
|
|
99
|
+
const limit = limits.find((l) => sets.filter(pyOk).every((set) => !fits(set, l)))
|
|
100
|
+
if (!limit) return { block: oldPython(python) }
|
|
101
|
+
return { block: { title: RESOLVE.python.noSet.title, lines: RESOLVE.python.noSet.lines.map((l) => fill(l, limit)) } }
|
|
102
|
+
}
|
|
103
|
+
const set = sets[index]
|
|
104
|
+
if (index === 0) return { set, why: null }
|
|
105
|
+
const newest = sets[0]
|
|
106
|
+
const limit = limits.find((l) => !fits(newest, l))
|
|
107
|
+
const values = { ...limit, python: python?.join('.'), newest: newest.core, need: newest.minPython.join('.'), core: set.core, contrib: set.contrib, tool }
|
|
108
|
+
const text = limit ? (tool ? RESOLVE.python.floorLimit : RESOLVE.python.olderLimit) : tool ? RESOLVE.python.floorPython : RESOLVE.python.olderPython
|
|
109
|
+
return { set, why: fill(text, values) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The step for a Python version below every set. */
|
|
113
|
+
function oldPython(python) {
|
|
114
|
+
const oldest = PINS.python.sets.at(-1)
|
|
115
|
+
const need = oldest.minPython.join('.')
|
|
116
|
+
return { title: `Use Python ${need} or later`, lines: [`This app uses Python ${python.join('.')}.`,
|
|
117
|
+
`opentelemetry-distro ${oldest.contrib} needs Python ${need} or later.`, 'Update the Python version of the app. Then run npx @uknows/setup again.'] }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Requirement text of 1 package: name==version or name>=floor. */
|
|
121
|
+
export const requirement = (name, version, floor = null) => (floor ? `${name}>=${floor}` : `${name}==${version}`)
|
|
122
|
+
|
|
123
|
+
/** The package name of a requirement text. */
|
|
124
|
+
export const requirementName = (req) => normalize(/^[A-Za-z0-9][A-Za-z0-9._-]*/.exec(req)?.[0] ?? req)
|
|
125
|
+
|
|
126
|
+
/** The lowest protobuf release a lock has, as a limit, so the set fits a lock that keeps protobuf below 5. */
|
|
127
|
+
export function lockedLimit(installed, name) {
|
|
128
|
+
const item = installed?.packages?.get(name)
|
|
129
|
+
if (!item?.version || !clauses(`==${item.version}`).length) return null
|
|
130
|
+
return { name, spec: `==${item.version}`, style: 'python', file: item.file, line: item.line ?? 0, value: `${item.name} ${item.version}` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { compareVersion }
|
|
134
|
+
|
|
135
|
+
const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
|
|
136
|
+
function join(dir, rel) {
|
|
137
|
+
const parts = dir === '.' ? [] : dir.split('/')
|
|
138
|
+
for (const part of rel.split('/')) {
|
|
139
|
+
if (part === '..') parts.pop()
|
|
140
|
+
else if (part && part !== '.') parts.push(part)
|
|
141
|
+
}
|
|
142
|
+
return parts.join('/') || '.'
|
|
143
|
+
}
|
package/lib/stacks/python.mjs
CHANGED
|
@@ -3,18 +3,36 @@
|
|
|
3
3
|
* instrumentation packages that `opentelemetry-bootstrap -a requirements`
|
|
4
4
|
* would print, then start the app with opentelemetry-instrument.
|
|
5
5
|
*
|
|
6
|
+
* Versions: pip apps (requirements.txt, or a pyproject.toml with no lock) get
|
|
7
|
+
* exact pins from the first known-good set that fits the app (python-sets.mjs).
|
|
8
|
+
* uv and Poetry apps get floors, and the tool runs their own resolver (uv lock in
|
|
9
|
+
* the workspace root, poetry lock). The engine records the lock it rewrites as a
|
|
10
|
+
* text change. The set decides which instrumentation packages exist for them.
|
|
11
|
+
*
|
|
12
|
+
* Logs: the OpenTelemetry logging package on the root logger changes what the app prints (Python's last-resort output,
|
|
13
|
+
* logging.warning() before any setup and Flask's own handler stop: install proof, 22 Oct 2026). So it stays off the
|
|
14
|
+
* root logger (OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION=false) and the UKNOWS log bridge (PYTHON_LOG_BRIDGE, a folder that
|
|
15
|
+
* the image copies and PYTHONPATH names) sends the log records instead. Only an image whose start gets
|
|
16
|
+
* opentelemetry-instrument gets the bridge and the logging package. Without it the app sends no logs.
|
|
17
|
+
*
|
|
6
18
|
* Safety rule: the start command gets opentelemetry-instrument only when the
|
|
7
19
|
* current build surely installs the packages: a RUN line of the image installs
|
|
8
|
-
* the file this tool changed
|
|
9
|
-
*
|
|
10
|
-
*
|
|
20
|
+
* the file this tool changed (uv sync --package installs only that member), or
|
|
21
|
+
* (without a Dockerfile) the platform installs it, and its lock has them after
|
|
22
|
+
* this run. Otherwise the image would fail to start, so the start goes into a
|
|
23
|
+
* manual step instead. A Procfile is never changed: the hosts this tool supports
|
|
24
|
+
* do not read it, so its line is a required step.
|
|
11
25
|
*/
|
|
26
|
+
import { buildContext, ignoredBy } from '../context.mjs'
|
|
12
27
|
import { Doc, indentOf } from '../doc.mjs'
|
|
13
|
-
import { dockerStart,
|
|
14
|
-
import { PINS, STACKS } from '../contract.mjs'
|
|
28
|
+
import { dockerStart, formatEnv, insertBeforeStart, insertStart, parseDockerfile, reachingStages } from '../dockerfile.mjs'
|
|
29
|
+
import { PINS, PYTHON_BRIDGE_COPY, PYTHON_BRIDGE_PATH, PYTHON_LOG_BRIDGE, RESOLVE, STACKS } from '../contract.mjs'
|
|
30
|
+
import { pyName, tomlString, tomlTable } from '../toml.mjs'
|
|
15
31
|
import { upFrom } from '../workspace.mjs'
|
|
16
32
|
import { startOverrides } from './k8s.mjs'
|
|
17
33
|
import { bootstrap, normalize } from './python-bootstrap.mjs'
|
|
34
|
+
import { LOCK_TOOLS, lockStep } from './python-lock.mjs'
|
|
35
|
+
import { directLimits, lockedLimit, pickSet, projectPython, requirement, requirementName } from './python-sets.mjs'
|
|
18
36
|
|
|
19
37
|
const STACK = STACKS.find((s) => s.id === 'python')
|
|
20
38
|
const INSTRUMENT = 'opentelemetry-instrument'
|
|
@@ -22,32 +40,82 @@ const RUNNERS = /^(poetry|uv|pipenv|pdm|hatch|rye)$/
|
|
|
22
40
|
const LOCKS = { 'uv.lock': 'uv lock', 'poetry.lock': 'poetry lock', 'pdm.lock': 'pdm lock' }
|
|
23
41
|
const SECRET = /(password|passwd|token|secret|credential|authorization|api[-_]?key|connection[-_]?string)/i
|
|
24
42
|
const AGAIN = 'Then run npx @uknows/setup again. It puts opentelemetry-instrument in front of the start command.'
|
|
43
|
+
const DISTRO = 'opentelemetry-distro', EXPORTER = 'opentelemetry-exporter-otlp-proto-http'
|
|
44
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Options of uv run and poetry run before the command: options that take a value, options that do not, and options
|
|
48
|
+
* that change what the command is (opentelemetry-instrument cannot go in front of a module or a script).
|
|
49
|
+
*/
|
|
50
|
+
const RUN_OPTIONS = {
|
|
51
|
+
uv: {
|
|
52
|
+
value: new Set(['--extra', '--group', '--no-group', '--only-group', '--package', '--with', '--with-editable', '--with-requirements',
|
|
53
|
+
'--python', '-p', '--env-file', '--index', '--default-index', '--index-url', '-i', '--extra-index-url', '--find-links', '-f',
|
|
54
|
+
'--upgrade-package', '-P', '--reinstall-package', '--no-build-package', '--no-binary-package', '--index-strategy', '--keyring-provider',
|
|
55
|
+
'--resolution', '--prerelease', '--fork-strategy', '--exclude-newer', '--exclude-newer-package', '--no-sources-package', '--link-mode',
|
|
56
|
+
'--config-setting', '-C', '--config-settings-package', '--python-platform', '--directory', '--project', '--config-file', '--cache-dir',
|
|
57
|
+
'--color', '--python-preference', '--allow-insecure-host', '--refresh-package', '--no-extra']),
|
|
58
|
+
flag: new Set(['--no-sync', '--frozen', '--locked', '--no-dev', '--dev', '--all-extras', '--all-groups', '--no-default-groups', '--only-dev',
|
|
59
|
+
'--no-editable', '--exact', '--inexact', '--isolated', '--active', '--no-active', '--no-project', '--no-config', '--offline', '--no-cache', '-n',
|
|
60
|
+
'-q', '--quiet', '-v', '--verbose', '--native-tls', '--no-progress', '--all-packages', '--no-env-file', '--compile-bytecode',
|
|
61
|
+
'--no-build-isolation', '--no-build', '--no-binary', '--reinstall', '--upgrade', '-U', '--refresh', '--no-python-downloads',
|
|
62
|
+
'--managed-python', '--no-managed-python', '--preview', '--no-sources', '--no-install-project', '--no-install-workspace', '--no-install-local']),
|
|
63
|
+
refuse: new Set(['-m', '--module', '-s', '--script', '--gui-script', '-w']),
|
|
64
|
+
},
|
|
65
|
+
poetry: {
|
|
66
|
+
value: new Set(['-C', '--directory', '-P', '--project']),
|
|
67
|
+
flag: new Set(['-q', '--quiet', '-v', '-vv', '-vvv', '--verbose', '-n', '--no-interaction', '--ansi', '--no-ansi', '--no-plugins', '--no-cache']),
|
|
68
|
+
refuse: new Set(),
|
|
69
|
+
},
|
|
70
|
+
}
|
|
25
71
|
|
|
26
72
|
/** @type {import('../types.mjs').Applier} */
|
|
27
73
|
export const python = {
|
|
28
74
|
id: 'python',
|
|
29
75
|
plan(service, ctx) {
|
|
30
76
|
const { ws } = ctx
|
|
77
|
+
const view = ctx.repo ?? ws
|
|
31
78
|
const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
79
|
+
const docker = service.dockerfile ? read(ws, service.dockerfile) : null
|
|
80
|
+
const deps = pickDeps(service, ws, docker)
|
|
81
|
+
const lockFile = deps.kind === 'pep621' || deps.kind === 'poetry' ? findLock(view, dirOf(deps.file)) : null
|
|
82
|
+
const tool = lockFile ? LOCK_TOOLS[base(lockFile)] ?? null : null
|
|
83
|
+
const installed = ctx.installed ?? ctx.existing?.installed ?? null
|
|
84
|
+
|
|
85
|
+
// The OpenTelemetry versions: the first known-good set that fits the limits and the Python of the app.
|
|
86
|
+
const files = deps.file ? [deps.file, ...(tool && lockFile ? [join(dirOf(lockFile), 'pyproject.toml')] : [])] : []
|
|
87
|
+
const limits = [...directLimits(view, [...new Set(files)]), ...(tool ? [lockedLimit(installed, 'protobuf')].filter(Boolean) : [])]
|
|
88
|
+
const runtime = /^(\d+)\.(\d+)/.exec(service.runtimeVersion ?? '')?.slice(1, 3).map(Number) ?? null
|
|
89
|
+
const pythonVersion = (tool && projectPython(view, [...new Set(files)])) || runtime
|
|
90
|
+
const picked = pickSet({ limits, python: pythonVersion, tool: tool && `${tool} lock` })
|
|
91
|
+
if (picked.block) {
|
|
92
|
+
out.manual.push(picked.block)
|
|
93
|
+
out.stop = true
|
|
38
94
|
return out
|
|
39
95
|
}
|
|
40
|
-
|
|
96
|
+
const { set } = picked
|
|
97
|
+
if (picked.why) out.notes.push(picked.why)
|
|
98
|
+
if (!pythonVersion) out.notes.push(`Check that the app uses Python ${set.minPython.join('.')} or later. ${DISTRO} ${set.contrib} needs it.`)
|
|
41
99
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
100
|
+
// uv and Poetry: floors, and the names in the lock closure count too, as opentelemetry-bootstrap reads what is
|
|
101
|
+
// installed. Packages that arrive only through OpenTelemetry packages do not count, so a second run adds nothing.
|
|
102
|
+
const floor = tool ? PINS.python.floors : null
|
|
103
|
+
const app = (item) => item.direct || !item.via.length || item.via.some((v) => !v.startsWith('opentelemetry-'))
|
|
104
|
+
const names = new Set([...deps.names, ...(tool && installed ? [...installed.packages].filter(([, item]) => app(item)).map(([key]) => key) : [])])
|
|
105
|
+
// The log bridge: only where the image installs the packages and its start can get opentelemetry-instrument.
|
|
106
|
+
const bridge = docker !== null && deps.proven ? placeBridge(service, view, docker, ctx) : null
|
|
107
|
+
const wanted = [requirement(DISTRO, set.contrib, floor?.contrib), requirement(EXPORTER, set.core, floor?.core),
|
|
108
|
+
...bootstrap(names, set.contrib, { floor: floor?.contrib ?? null, logs: Boolean(bridge?.copy) })]
|
|
109
|
+
const missing = wanted.filter((req) => !deps.names.has(requirementName(req)))
|
|
110
|
+
const lock = lockFile && { file: lockFile, step: tool ? lockStep({ view, lockFile, tools: ctx.tools, dockerfile: service.dockerfile, docker, packages: 'the OpenTelemetry packages' }) : null }
|
|
111
|
+
const ready = addPackages(deps, missing, ws, view, out, lock)
|
|
112
|
+
if (out.stop) return out
|
|
47
113
|
|
|
48
|
-
if (ready && deps.proven)
|
|
49
|
-
|
|
50
|
-
|
|
114
|
+
if (ready && deps.proven) {
|
|
115
|
+
wrapStart(service, ws, docker, out)
|
|
116
|
+
if (bridge) addBridge(service, ws, docker, bridge, out)
|
|
117
|
+
} else if (ready) {
|
|
118
|
+
out.manual.push({ title: `Install ${deps.file} in the image`, verify: true, lines: [
|
|
51
119
|
`${service.dockerfile} does not install ${deps.file}, so the image would not have opentelemetry-instrument. This tool did not change the start command.`,
|
|
52
120
|
`Install ${deps.file} in the final image, for example with ${deps.kind === 'requirements' ? `RUN pip install -r ${base(deps.file)}` : 'RUN pip install .'}.`,
|
|
53
121
|
AGAIN] })
|
|
@@ -70,21 +138,91 @@ export const python = {
|
|
|
70
138
|
'Add the production value with the 5 UKNOWS settings.'].filter(Boolean) })
|
|
71
139
|
}
|
|
72
140
|
}
|
|
141
|
+
// The settings of the docs for Python: the logging package off the root logger, and the host name of the app.
|
|
142
|
+
for (const line of STACK.extraSettings?.lines ?? []) out.extraSettings.push({ name: line.slice(0, line.indexOf('=')), value: line.slice(line.indexOf('=') + 1) })
|
|
73
143
|
return out
|
|
74
144
|
},
|
|
75
145
|
}
|
|
76
146
|
|
|
147
|
+
const BRIDGE_FILES = PYTHON_LOG_BRIDGE.files.map((file) => ({ path: file.path, text: `${file.content}\n` }))
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Where the log bridge goes: {dir, copy} with the COPY line from the build context, or {why} when this tool is not sure
|
|
151
|
+
* that the image would have it. The folder sits next to the dependency file of the service.
|
|
152
|
+
*/
|
|
153
|
+
function placeBridge(service, view, docker, ctx = {}) {
|
|
154
|
+
const dir = join(service.dir, PYTHON_LOG_BRIDGE.dir)
|
|
155
|
+
const info = parseDockerfile(docker)
|
|
156
|
+
const start = dockerStart(info)
|
|
157
|
+
// The bridge needs opentelemetry-instrument in the start, which wrapStart adds where it can.
|
|
158
|
+
if (!start || instrumentAt(start.argv ?? start.raw.replace(/^exec\s+/, '').split(/\s+/)) === null) {
|
|
159
|
+
return { why: `this tool cannot start the app with opentelemetry-instrument in ${service.dockerfile}` }
|
|
160
|
+
}
|
|
161
|
+
const context = buildContext(view, service.dockerfile, info, service.dir)
|
|
162
|
+
if (context === null) return { why: `this tool cannot tell the build context of ${service.dockerfile} from its COPY lines` }
|
|
163
|
+
if (context !== '.' && !dir.startsWith(`${context}/`)) return { why: `${dir} is outside the build context of ${service.dockerfile}` }
|
|
164
|
+
const source = context === '.' ? dir : dir.slice(context.length + 1)
|
|
165
|
+
for (const file of BRIDGE_FILES) {
|
|
166
|
+
const ignore = ignoredBy(view, context, service.dockerfile, `${source}/${file.path}`)
|
|
167
|
+
if (ignore) return { why: `${ignore} leaves ${dir} out of the build context` }
|
|
168
|
+
}
|
|
169
|
+
if (!read(view, join(dir, BRIDGE_FILES[0].path)) && ctx.ignored?.(join(dir, BRIDGE_FILES[0].path))) {
|
|
170
|
+
return { why: `.gitignore leaves ${dir} out of git, so the build in your pipeline would not have it` }
|
|
171
|
+
}
|
|
172
|
+
return { dir, copy: PYTHON_BRIDGE_COPY.replace(` ${PYTHON_LOG_BRIDGE.dir} `, ` ${source} `) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The bridge in the repo and in the image: its 3 files (a file that is there stays as it is), the COPY line before the
|
|
177
|
+
* start of the final stage, and PYTHONPATH. Or, when the image would not have it, a note that the app sends no logs.
|
|
178
|
+
*/
|
|
179
|
+
function addBridge(service, ws, docker, bridge, out) {
|
|
180
|
+
if (!bridge.copy) {
|
|
181
|
+
out.notes.push(`This app sends no logs to UKNOWS: ${bridge.why}. Its traces go as usual.`)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
for (const file of BRIDGE_FILES) {
|
|
185
|
+
const path = join(bridge.dir, file.path)
|
|
186
|
+
const current = read(ws, path)
|
|
187
|
+
if (current === null) out.edits.push({ file: path, before: null, after: file.text, why: 'Send the log records of the app without changing what it prints.' })
|
|
188
|
+
else if (current !== file.text) out.notes.push(`${path} is there already. This tool keeps it as it is.`)
|
|
189
|
+
}
|
|
190
|
+
// The COPY line and PYTHONPATH, in 1 block before the start. PYTHONPATH keeps a value that the image has (${...} is
|
|
191
|
+
// left for Docker to fill in, so it is not escaped).
|
|
192
|
+
const earlier = out.edits.find((e) => e.file === service.dockerfile)
|
|
193
|
+
const now = earlier ? earlier.after : docker
|
|
194
|
+
const lines = now.split('\n').map((l) => l.trim())
|
|
195
|
+
const path = formatEnv([[PYTHON_BRIDGE_PATH.slice(0, PYTHON_BRIDGE_PATH.indexOf('=')), PYTHON_BRIDGE_PATH.slice(PYTHON_BRIDGE_PATH.indexOf('=') + 1)]],
|
|
196
|
+
{ raw: true, escape: parseDockerfile(now).escape })
|
|
197
|
+
const missing = [...(lines.includes(bridge.copy) ? [] : [bridge.copy]), ...path.filter((l) => !lines.includes(l.trim()))]
|
|
198
|
+
const after = missing.length ? insertBeforeStart(now, missing) : now
|
|
199
|
+
if (after !== now) {
|
|
200
|
+
if (earlier) earlier.after = after
|
|
201
|
+
else out.edits.push({ file: service.dockerfile, before: docker, after, why: 'Copy the UKNOWS log bridge into the image.' })
|
|
202
|
+
}
|
|
203
|
+
out.summary.push(`Send the log records of the app with the UKNOWS log bridge (${bridge.dir}). What the app prints stays the same.`)
|
|
204
|
+
}
|
|
205
|
+
|
|
77
206
|
/**
|
|
78
207
|
* Add the missing packages. Returns true when the dependency list has them after this run, and its lockfile too.
|
|
79
|
-
*
|
|
208
|
+
* lock: the uv, Poetry or PDM lockfile of the project, and the lock step for it. A uv or Poetry lock that lacks the
|
|
209
|
+
* packages gets a lock command, which runs after the edits. When the tool cannot run it, nothing changes (stop).
|
|
80
210
|
*/
|
|
81
|
-
function addPackages(deps, missing, ws, view, out) {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
211
|
+
function addPackages(deps, missing, ws, view, out, lock) {
|
|
212
|
+
const lockReady = !lock || /name\s*=\s*"opentelemetry-distro"/.test(read(view, lock.file) ?? '')
|
|
213
|
+
const relock = Boolean(lock) && (missing.length > 0 || !lockReady)
|
|
214
|
+
if (relock && lock.step?.block) {
|
|
215
|
+
out.manual.push(lock.step.block)
|
|
216
|
+
out.stop = true
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
const refresh = () => {
|
|
220
|
+
if (!relock) return true
|
|
221
|
+
if (lock.step?.command) { out.commands.push(lock.step.command); return true }
|
|
222
|
+
out.manual.push(relockBlock(lock.file, deps.file))
|
|
223
|
+
return false
|
|
87
224
|
}
|
|
225
|
+
if (!missing.length) return refresh()
|
|
88
226
|
const snippet = { caption: 'Packages to add', language: 'text', code: missing.join('\n') }
|
|
89
227
|
const stop = (title, lines, code) => {
|
|
90
228
|
out.manual.push({ title, lines: [...lines, AGAIN], snippets: [code ? { ...snippet, code } : snippet] })
|
|
@@ -98,22 +236,14 @@ function addPackages(deps, missing, ws, view, out) {
|
|
|
98
236
|
return stop('Add the OpenTelemetry packages', ['This app uses Pipenv. Add the packages with pipenv install.'],
|
|
99
237
|
`pipenv install ${missing.join(' ')}`)
|
|
100
238
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
'Add these packages with the tool that writes the file, such as pip-compile --generate-hashes.'])
|
|
104
|
-
}
|
|
105
|
-
const source = deps.kind === 'requirements' ? deps.file.replace(/\.txt$/, '.in') : null
|
|
106
|
-
if (source && source !== deps.file && read(ws, source) !== null) {
|
|
107
|
-
return stop('Add the OpenTelemetry packages', [`${deps.file} is built from ${source} by pip-tools.`,
|
|
108
|
-
`Add these lines to ${source}.`, `Run pip-compile ${source.split('/').pop()}.`])
|
|
109
|
-
}
|
|
239
|
+
const compiled = deps.kind === 'requirements' ? compiledRequirements(ws, deps.file) : null
|
|
240
|
+
if (compiled) return stop('Add the OpenTelemetry packages', compiled)
|
|
110
241
|
const before = read(ws, deps.file)
|
|
111
242
|
const after = deps.kind === 'requirements' ? appendLines(before, missing) : editPyproject(before, deps, missing)
|
|
112
243
|
if (after === null) return stop('Add the OpenTelemetry packages', [`This tool cannot change the dependency list in ${deps.file}.`, 'Add these packages to it.'])
|
|
113
244
|
out.edits.push({ file: deps.file, before, after, why: 'Add the OpenTelemetry packages.' })
|
|
114
|
-
out.summary.push(`Add ${missing.length} OpenTelemetry packages to ${deps.file}.`)
|
|
115
|
-
|
|
116
|
-
return lockReady
|
|
245
|
+
out.summary.push(`Add ${missing.length} OpenTelemetry packages to ${deps.file}${lock?.step?.command ? `, as lower limits. ${lock.step.command.cmd} lock picks the versions` : ''}.`)
|
|
246
|
+
return refresh()
|
|
117
247
|
}
|
|
118
248
|
|
|
119
249
|
function relockBlock(lock, file) {
|
|
@@ -123,6 +253,29 @@ function relockBlock(lock, file) {
|
|
|
123
253
|
...(name === 'poetry.lock' ? ['On Poetry 1, run poetry lock --no-update instead.'] : []), `Commit ${name}.`, AGAIN] }
|
|
124
254
|
}
|
|
125
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Where opentelemetry-instrument goes in a start command: 0, or after uv run or poetry run and their options.
|
|
258
|
+
* null when it cannot go in safely (another runner, a module or script option, an option this tool does not know).
|
|
259
|
+
*/
|
|
260
|
+
export function instrumentAt(words) {
|
|
261
|
+
if (!words?.length) return null
|
|
262
|
+
const runner = base(words[0])
|
|
263
|
+
if (!RUNNERS.test(runner) || words[1] !== 'run') return 0
|
|
264
|
+
const options = RUN_OPTIONS[runner]
|
|
265
|
+
if (!options) return null
|
|
266
|
+
for (let i = 2; i < words.length; i++) {
|
|
267
|
+
const w = words[i]
|
|
268
|
+
if (w === '--') return i + 1 < words.length ? i + 1 : null
|
|
269
|
+
if (!w.startsWith('-')) return i
|
|
270
|
+
const flag = w.split('=')[0]
|
|
271
|
+
if (options.refuse.has(flag)) return null
|
|
272
|
+
if (w.includes('=') && (options.value.has(flag) || options.flag.has(flag))) continue
|
|
273
|
+
if (options.value.has(flag)) { i++; continue }
|
|
274
|
+
if (!options.flag.has(flag)) return null
|
|
275
|
+
}
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
|
|
126
279
|
function wrapStart(service, ws, docker, out) {
|
|
127
280
|
const done = (file, before, after, where) => {
|
|
128
281
|
out.edits.push({ file, before, after, why: 'Start the app with opentelemetry-instrument.' })
|
|
@@ -135,11 +288,12 @@ function wrapStart(service, ws, docker, out) {
|
|
|
135
288
|
const words = start.argv ?? start.raw.replace(/^exec\s+/, '').split(/\s+/)
|
|
136
289
|
const instruction = info.entrypoint ? 'ENTRYPOINT' : 'CMD'
|
|
137
290
|
const overrides = startOverrides(service, instruction, INSTRUMENT)
|
|
138
|
-
|
|
291
|
+
const at = instrumentAt(words)
|
|
292
|
+
if (at !== null && [base(words[at]), base(words[0])].includes(INSTRUMENT)) { out.manual.push(...overrides); return }
|
|
139
293
|
const where = `${instruction} in ${service.dockerfile}`
|
|
140
294
|
// A venv that is not on PATH: use the opentelemetry-instrument next to the start program.
|
|
141
|
-
const word = /venv\/bin\/[^/]+$/.test(words[0]) ? words[0].replace(/[^/]+$/, INSTRUMENT) : INSTRUMENT
|
|
142
|
-
const after =
|
|
295
|
+
const word = at === 0 && /venv\/bin\/[^/]+$/.test(words[0]) ? words[0].replace(/[^/]+$/, INSTRUMENT) : INSTRUMENT
|
|
296
|
+
const after = at === null ? null : insertStart(docker, at, [word])
|
|
143
297
|
if (after === null) out.manual.push(startBlock(`the ${where}`))
|
|
144
298
|
else {
|
|
145
299
|
if (after !== docker) done(service.dockerfile, docker, after, where)
|
|
@@ -149,16 +303,19 @@ function wrapStart(service, ws, docker, out) {
|
|
|
149
303
|
}
|
|
150
304
|
const web = !service.start || service.start.kind === 'procfile' ? procfileWeb(ws, service) : null
|
|
151
305
|
if (!web) { out.manual.push(startBlock(null, service.start?.kind === 'answer' ? service.start.raw : null)); return }
|
|
306
|
+
// A Procfile is never changed. The supported hosts do not read it, so the line is a step for platforms that do.
|
|
152
307
|
const words = web.cmd.trim().split(/\s+/)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
308
|
+
const at = instrumentAt(words)
|
|
309
|
+
if (at !== null && base(words[at]) === INSTRUMENT) return
|
|
310
|
+
const safe = at !== null && words[0] && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) && !/[;|`]|&&|&\s*$/.test(web.cmd)
|
|
311
|
+
const lines = RESOLVE.python.procfile.lines.map((l) => fill(l, { file: web.file }))
|
|
312
|
+
if (!safe) {
|
|
313
|
+
out.manual.push({ title: RESOLVE.python.procfile.title, verify: true, lines: [lines[0], note(/in front of the command/)].filter(Boolean),
|
|
314
|
+
snippets: [{ caption: 'Start command', language: 'bash', code: STACK.start }] })
|
|
157
315
|
return
|
|
158
316
|
}
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
done(web.file, web.text, doc.toString(), `web line in ${web.file}`)
|
|
317
|
+
const line = `${web.head}${[...words.slice(0, at), INSTRUMENT, ...words.slice(at)].join(' ')}`
|
|
318
|
+
out.manual.push({ title: RESOLVE.python.procfile.title, verify: true, lines, snippets: [{ caption: web.file, language: 'text', code: line }] })
|
|
162
319
|
}
|
|
163
320
|
|
|
164
321
|
function startBlock(where, answer = null) {
|
|
@@ -195,9 +352,12 @@ function pickDeps(service, ws, docker) {
|
|
|
195
352
|
}
|
|
196
353
|
const pipfile = (proven) => read(ws, join(dir, 'Pipfile')) !== null && { kind: 'pipfile', file: join(dir, 'Pipfile'), names: new Set(), hashes: false, proven }
|
|
197
354
|
if (installs) {
|
|
198
|
-
const file = installs.files.find((f) => read(ws, f) !== null)
|
|
355
|
+
const file = installs.files.find((f) => !/\.toml$/.test(f) && read(ws, f) !== null)
|
|
199
356
|
if (file) return requirements(file, true)
|
|
200
|
-
|
|
357
|
+
// uv sync --package <name> installs that workspace member only: the pyproject.toml of another member is not proven.
|
|
358
|
+
const own = tomlString(tomlTable(read(ws, join(dir, 'pyproject.toml')) ?? '', 'project'), 'name')
|
|
359
|
+
const member = installs.packages === null || (own !== null && installs.packages.includes(pyName(own)))
|
|
360
|
+
const found = (installs.project && member && project(true)) || (installs.pipfile && pipfile(true))
|
|
201
361
|
if (found) return found
|
|
202
362
|
}
|
|
203
363
|
// Not installed by a RUN line (or no Dockerfile): the usual files.
|
|
@@ -208,11 +368,15 @@ function pickDeps(service, ws, docker) {
|
|
|
208
368
|
|
|
209
369
|
/**
|
|
210
370
|
* The dependency files that the image surely installs, from the RUN lines of the stages that reach the final image:
|
|
211
|
-
* files given to pip install -r, the project (pip install . with any flags,
|
|
212
|
-
* or an export of them), and the Pipfile (pipenv install). Paths are repo paths, found from the WORKDIR.
|
|
371
|
+
* files given to pip install -r, the project (pip install . with any flags, pip install -r pyproject.toml, poetry
|
|
372
|
+
* install, uv sync, pdm install, or an export of them), and the Pipfile (pipenv install). Paths are repo paths, found from the WORKDIR.
|
|
373
|
+
* packages: when every project install is uv sync --package <name>, the names of those workspace members (PEP 503).
|
|
374
|
+
* uv sync installs only that member and what it depends on. null: the whole project (or every member).
|
|
375
|
+
* @returns {{files: string[], project: boolean, pipfile: boolean, packages: string[]|null}}
|
|
213
376
|
*/
|
|
214
|
-
function dockerInstalls(service, docker) {
|
|
215
|
-
const out = { files: [], project: false, pipfile: false }
|
|
377
|
+
export function dockerInstalls(service, docker) {
|
|
378
|
+
const out = { files: [], project: false, pipfile: false, packages: null }
|
|
379
|
+
const named = [], whole = []
|
|
216
380
|
let exported = false
|
|
217
381
|
for (const stage of reachingStages(parseDockerfile(docker))) {
|
|
218
382
|
let workdir = ''
|
|
@@ -224,20 +388,29 @@ function dockerInstalls(service, docker) {
|
|
|
224
388
|
if (x.keyword !== 'RUN') continue
|
|
225
389
|
for (const part of (x.argv ? x.argv.join(' ') : x.args).split(/&&|\|\||[;|\n]/)) {
|
|
226
390
|
if (/\b(?:poetry|uv|pdm)\s+export\b/.test(part)) exported = true
|
|
227
|
-
if (/\b(?:poetry\s+(?:install|export)|uv\s+(?:sync|export)|pdm\s+(?:install|sync|export))\b/.test(part))
|
|
391
|
+
if (/\b(?:poetry\s+(?:install|export)|uv\s+(?:sync|export)|pdm\s+(?:install|sync|export))\b/.test(part)) {
|
|
392
|
+
out.project = true
|
|
393
|
+
const member = /\buv\s+(?:sync|export)\b.*?\s--package(?:\s+|=)["']?([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(part)
|
|
394
|
+
if (member && !/\s--all-packages\b/.test(part)) named.push(pyName(member[1]))
|
|
395
|
+
else whole.push(part)
|
|
396
|
+
}
|
|
228
397
|
if (/\bpipenv\s+(?:install|sync)\b/.test(part)) out.pipfile = true
|
|
229
398
|
if (!/\bpip3?\s+install\b/.test(part)) continue
|
|
230
399
|
const words = part.trim().split(/\s+/).map((w) => w.replace(/^["']|["']$/g, ''))
|
|
231
400
|
for (let k = 0; k < words.length; k++) {
|
|
232
401
|
const flag = /^(?:-r|--requirement)(?:=(.+))?$/.exec(words[k])
|
|
233
|
-
|
|
234
|
-
|
|
402
|
+
const file = flag ? flag[1] ?? words[++k] ?? '' : null
|
|
403
|
+
// uv pip install -r pyproject.toml installs the dependencies of the project, not a requirements file.
|
|
404
|
+
if (file !== null && /\.toml$/.test(file)) { out.project = true; whole.push(part) }
|
|
405
|
+
else if (file !== null) out.files.push(...repoPaths(service, workdir, file))
|
|
406
|
+
else if (/^\.\/?(?:\[[^\]]*\])?$/.test(words[k])) { out.project = true; whole.push(part) }
|
|
235
407
|
}
|
|
236
408
|
}
|
|
237
409
|
}
|
|
238
410
|
}
|
|
239
411
|
// An exported list (poetry export -o requirements.txt) is built from the lockfile, not from a file in the repo.
|
|
240
412
|
if (exported) out.files = []
|
|
413
|
+
if (named.length && !whole.length) out.packages = [...new Set(named)]
|
|
241
414
|
return out
|
|
242
415
|
}
|
|
243
416
|
|
|
@@ -249,6 +422,26 @@ function repoPaths(service, workdir, raw) {
|
|
|
249
422
|
return [resolvePath(service.dir, path), resolvePath(dirOf(service.dockerfile), path)].filter(Boolean)
|
|
250
423
|
}
|
|
251
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Why this tool must not add lines to a requirements file, as the lines to print: it uses --hash (pip then needs a
|
|
427
|
+
* hash for every line), a .in file next to it builds it (pip-tools), or pip-compile or uv pip compile wrote it (the
|
|
428
|
+
* next compile drops lines that this tool adds). null when the tool can add lines.
|
|
429
|
+
*/
|
|
430
|
+
export function compiledRequirements(ws, file) {
|
|
431
|
+
if (requirementNames(ws, file).hashes) {
|
|
432
|
+
return [`${file} uses --hash, so this tool cannot add lines to it.`, 'Add these packages with the tool that writes the file, such as pip-compile --generate-hashes.']
|
|
433
|
+
}
|
|
434
|
+
const source = file.replace(/\.txt$/, '.in')
|
|
435
|
+
if (source !== file && read(ws, source) !== null) return [`${file} is built from ${source} by pip-tools.`, `Add these lines to ${source}.`, `Run pip-compile ${base(source)}.`]
|
|
436
|
+
const head = (read(ws, file) ?? '').split(/\r?\n/).slice(0, 12).join('\n')
|
|
437
|
+
const tool = /^#\s*This file (?:is|was) autogenerated by (pip-compile|uv)\b/m.exec(head)?.[1]
|
|
438
|
+
if (tool) {
|
|
439
|
+
const name = tool === 'uv' ? 'uv pip compile' : 'pip-compile'
|
|
440
|
+
return [`${name} writes ${file}, so the next ${name} run would drop lines that this tool adds.`, `Add these packages to the file that ${name} reads. Then run ${name} again.`]
|
|
441
|
+
}
|
|
442
|
+
return null
|
|
443
|
+
}
|
|
444
|
+
|
|
252
445
|
/** Requirement names in a requirements file, following -r includes 1 level deep. */
|
|
253
446
|
function requirementNames(ws, file, depth = 0) {
|
|
254
447
|
const text = read(ws, file) ?? ''
|
|
@@ -271,7 +464,7 @@ function requirementNames(ws, file, depth = 0) {
|
|
|
271
464
|
}
|
|
272
465
|
|
|
273
466
|
/** The PEP 621 [project] dependencies array, else the [tool.poetry.dependencies] table. null when neither. */
|
|
274
|
-
function parsePyproject(text) {
|
|
467
|
+
export function parsePyproject(text) {
|
|
275
468
|
const doc = new Doc(text)
|
|
276
469
|
const table = (re) => {
|
|
277
470
|
const head = doc.find((line) => re.test(line.replace(/#.*$/, '').trim()))
|
|
@@ -320,11 +513,16 @@ function scanArray(doc, line, col) {
|
|
|
320
513
|
return null
|
|
321
514
|
}
|
|
322
515
|
|
|
323
|
-
function editPyproject(text, deps, missing) {
|
|
516
|
+
export function editPyproject(text, deps, missing) {
|
|
324
517
|
const doc = new Doc(text)
|
|
325
518
|
if (deps.kind === 'poetry') {
|
|
326
519
|
const indent = deps.last > deps.head ? indentOf(doc.get(deps.last)) : ''
|
|
327
|
-
|
|
520
|
+
// Poetry writes an exact version as "1.2" and a limit as ">=1.2".
|
|
521
|
+
const entry = (req) => {
|
|
522
|
+
const [, name, op, version] = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(==|>=|<=|~=|!=|>|<)?\s*(.*)$/.exec(req)
|
|
523
|
+
return `${indent}${name} = "${!op || op === '==' ? version || '*' : `${op}${version}`}"`
|
|
524
|
+
}
|
|
525
|
+
doc.insert(deps.last + 1, ...missing.map(entry))
|
|
328
526
|
return doc.toString()
|
|
329
527
|
}
|
|
330
528
|
const { items, close } = deps.array
|
|
@@ -352,13 +550,13 @@ function editPyproject(text, deps, missing) {
|
|
|
352
550
|
return doc.toString()
|
|
353
551
|
}
|
|
354
552
|
|
|
355
|
-
function appendLines(text, lines) {
|
|
553
|
+
export function appendLines(text, lines) {
|
|
356
554
|
const eol = new Doc(text).eol
|
|
357
555
|
return (text === '' || text.endsWith('\n') ? text : text + eol) + lines.map((line) => line + eol).join('')
|
|
358
556
|
}
|
|
359
557
|
|
|
360
558
|
/** The nearest uv, Poetry or PDM lockfile, up to the top of the git repo (above the --path folder too). */
|
|
361
|
-
function findLock(view, dir) {
|
|
559
|
+
export function findLock(view, dir) {
|
|
362
560
|
for (const d of upFrom(dir, view.top ?? '.')) {
|
|
363
561
|
for (const name of Object.keys(LOCKS)) if (read(view, join(d, name)) !== null) return join(d, name)
|
|
364
562
|
}
|