Files
superwork/vscode/src/codeReader/rules/celery.ts
T

642 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IndexedFile, IndexEntry, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
import type { Call } from './pythonTree'
import { aliasesOf, callOf, lastName, stringContent, stringValue, syntaxChildren } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
/**
* Celery 库规则(Python),文件导入了 celery 才启用:
* - 创建应用 `X = Celery(...)` → 「Celery 应用」卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数各一行、行名就是参数名。
* - 定时任务表 `X.conf.beat_schedule = {...}`、`X.conf.update(beat_schedule={...})` → 「定时任务」卡片:应用 + 任务表(任务 / 频率 / 选项 / 说明)。
* 频率按调度写法换算成人话(秒数算式、timedelta、crontab),说明取条目里、紧挨着条目的 # 注释。
* - 任务表里的任务名是规则自己查定义的引用:在整个工作区里找 `@….task`、`@shared_task`、`@task` 装饰的函数,
* 任务名取装饰器的 name=,没写就是 Celery 默认的「模块路径.函数名」。
* 规则只经 contract 拿信息,直接读的是 Python 语法树;用词都在词表里,按翻译插件的显示语言挑。
*/
const RULE_ID = 'celery'
const LIBRARIES = ['celery']
/** 调度写法里的 crontab、timedelta 可能带着导入时起的别名。 */
const SCHEDULE_LIBRARIES = ['celery', 'datetime']
const APP_KIND = 'celery.app'
const BEAT_KIND = 'celery.beat'
const APP_COLOR: ThemeColor = { dark: '#98c379', light: '#4d7f17' }
const BEAT_COLOR: ThemeColor = { dark: '#56b6c2', light: '#127f8c' }
const APP_ICON: KindIconName = 'server-cog'
const BEAT_ICON: KindIconName = 'calendar-clock'
/** `Celery(...)` 的位置参数依次是哪个参数(celery.app.base.Celery.__init__)。 */
const APP_PARAMETERS = ['main', 'loader', 'backend', 'amqp', 'events', 'log', 'control', 'set_as_current', 'tasks', 'broker', 'include', 'changes', 'config_source', 'fixups', 'task_cls', 'autofinalize', 'namespace', 'strict_typing']
/** 有专门行名的应用参数,按这个顺序排在别的参数前面。 */
const APP_NAMED = ['main', 'broker', 'backend'] as const
/** 条目字典里讲任务名、调度的键;别的键(args、kwargs、options……)都是选项。 */
const TASK_KEY = 'task'
const SCHEDULE_KEY = 'schedule'
/** timedelta 的参数依次是哪个单位、一个单位多少秒。 */
const TIMEDELTA_UNITS: [string, number][] = [['days', 86400], ['seconds', 1], ['microseconds', 1e-6], ['milliseconds', 1e-3], ['minutes', 60], ['hours', 3600], ['weeks', 604800]]
/** crontab 的位置参数依次是哪个字段。 */
const CRONTAB_FIELDS = ['minute', 'hour', 'day_of_week', 'day_of_month', 'month_of_year']
/** crontab 的 day_of_week 能写的英文名(前三个字母),下标就是它的数字,0 是周日。 */
const WEEKDAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
/** 写成秒数的调度可能长这样:名字、属性、下标、算式(`每 X 秒`);调用等别的写法照写法翻。 */
const NUMBER_LIKE = new Set(['identifier', 'attribute', 'subscript', 'binary_operator', 'unary_operator', 'parenthesized_expression', 'integer', 'float'])
// ─── 频率:换算出来的结构,词表按它说成人话 ─────────────────────────────────────
type Unit = 'second' | 'minute' | 'hour' | 'day'
/** crontab 字段里的一段取值:一个数,或 from 到 to。 */
interface Span { from: number, to?: number }
/** 哪些天:每天、每周几、每月几号、每年几月几号。 */
type Days
= | { kind: 'daily' }
| { kind: 'weekly', days: Span[] }
| { kind: 'monthly', days: Span[] }
| { kind: 'yearly', months: Span[], days: Span[] }
/** 一天里什么时候:每 step 分钟、每 step 小时的第几分、几点几分。 */
type Time
= | { kind: 'minutes', step: number }
| { kind: 'hourly', step: number, minutes: number[] }
| { kind: 'at', times: { hour: number, minute: number }[] }
type Frequency
= | { kind: 'interval', count: number, unit: Unit }
| { kind: 'crontab', days: Days, time: Time }
// ─── 用词 ─────────────────────────────────────────────────────────────────────
/** 一种显示语言的用词。 */
interface Words {
appKind: string
beatKind: string
labels: Record<'name' | 'doc' | 'main' | 'broker' | 'backend' | 'app' | 'tasks', string>
/** `**x` 展开的条目、参数 */
spread: string
heads: Record<'task' | 'frequency' | 'options' | 'doc', string>
/** 任务表那一行的值:几条,另有几处展开 */
entries: (count: number, spreads: number) => string
/** 条目的键和任务名不一样时跟在任务名后面 */
entryKey: (key: string) => string
noTask: string
/** 条目里的选项键 → 名字;表里没有的写键本身 */
options: Record<string, string>
/** 调度是个写成秒数的表达式时前后的字:「每 X 秒」 */
everySeconds: [string, string]
frequency: (frequency: Frequency) => string
}
const ZH_UNITS: Record<Unit, [string, string]> = { second: ['每秒', '秒'], minute: ['每分钟', '分钟'], hour: ['每小时', '小时'], day: ['每天', '天'] }
const ZH_WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function zhSpans(spans: Span[], one: (n: number) => string, between: (from: number, to: number) => string): string {
return spans.map(span => span.to === undefined ? one(span.from) : between(span.from, span.to)).join('、')
}
function zhDays(days: Days): string {
const weekday = (n: number): string => ZH_WEEKDAYS[n % 7]
const day = (n: number): string => `${n} 日`
const dayRange = (from: number, to: number): string => `${from} 至 ${to} 日`
switch (days.kind) {
case 'daily':
return '每天'
case 'weekly':
return `每${zhSpans(days.days, weekday, (from, to) => `${weekday(from)}至${weekday(to)}`)}`
case 'monthly':
return `每月 ${zhSpans(days.days, day, dayRange)}`
case 'yearly':
return `每年 ${zhSpans(days.months, n => `${n} 月`, (from, to) => `${from} 至 ${to} 月`)} ${zhSpans(days.days, day, dayRange)}`
}
}
function zhTime(time: Time): string {
const pad = (n: number): string => String(n).padStart(2, '0')
switch (time.kind) {
case 'minutes':
return time.step === 1 ? '每分钟' : `每 ${time.step} 分钟`
case 'hourly': {
const every = time.step === 1 ? '每小时' : `每 ${time.step} 小时`
return time.minutes.length === 1 && time.minutes[0] === 0 ? `${every}整点` : `${every}的第 ${time.minutes.join('、')} 分`
}
case 'at':
return time.times.map(({ hour, minute }) => `${pad(hour)}:${pad(minute)}`).join('、')
}
}
const ZH: Words = {
appKind: 'Celery 应用',
beatKind: '定时任务',
labels: { name: '名称', doc: '说明', main: '应用名', broker: '消息队列', backend: '结果存储', app: '应用', tasks: '任务' },
spread: '展开',
heads: { task: '任务', frequency: '频率', options: '选项', doc: '说明' },
entries: (count, spreads) => spreads > 0 ? `${count} 条,另有展开 ${spreads} 处` : `${count} 条`,
entryKey: key => `(条目名 ${key})`,
noTask: '没写任务名',
options: { args: '位置参数', kwargs: '关键字参数', options: '执行选项' },
everySeconds: ['每 ', ' 秒'],
frequency: (frequency) => {
if (frequency.kind === 'interval') {
const [one, unit] = ZH_UNITS[frequency.unit]
return frequency.count === 1 ? one : `每 ${frequency.count} ${unit}`
}
const { days, time } = frequency
if (time.kind === 'at')
return `${zhDays(days)} ${zhTime(time)}`
return days.kind === 'daily' ? zhTime(time) : `${zhDays(days)} ${zhTime(time)}`
},
}
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
const WORDS: Record<string, Words> = { zh: ZH }
/** 认一块要用的:宿主给的 context、挑好的用词、别名表。 */
interface Env {
context: RuleContext
words: Words
aliases: Map<string, string>
}
// ─── 频率:从调度写法换算 ──────────────────────────────────────────────────────
/** 只由数字字面量和 + - * / // % ** 组成的算式的值;带名字、调用的算不出来。 */
function numberOf(node: Node | null | undefined): number | undefined {
switch (node?.type) {
case 'integer':
case 'float': {
const value = Number(node.text.replace(/_/g, ''))
return Number.isFinite(value) ? value : undefined
}
case 'parenthesized_expression': {
const inner = syntaxChildren(node)
return inner.length === 1 ? numberOf(inner[0]) : undefined
}
case 'unary_operator': {
const value = numberOf(node.childForFieldName('argument'))
const operator = node.childForFieldName('operator')?.type
return value === undefined ? undefined : operator === '-' ? -value : operator === '+' ? value : undefined
}
case 'binary_operator': {
const left = numberOf(node.childForFieldName('left'))
const right = numberOf(node.childForFieldName('right'))
if (left === undefined || right === undefined)
return undefined
switch (node.childForFieldName('operator')?.type) {
case '+': return left + right
case '-': return left - right
case '*': return left * right
case '/': return right === 0 ? undefined : left / right
case '//': return right === 0 ? undefined : Math.floor(left / right)
case '%': return right === 0 ? undefined : left % right
case '**': return left ** right
default: return undefined
}
}
default:
return undefined
}
}
/** 秒数换成最大的整单位:86400 → 每天,7200 → 每 2 小时,90 → 每 90 秒。 */
function interval(seconds: number): Frequency | undefined {
if (!Number.isFinite(seconds) || seconds <= 0)
return undefined
for (const [unit, size] of [['day', 86400], ['hour', 3600], ['minute', 60]] as const) {
if (Number.isInteger(seconds / size))
return { kind: 'interval', count: seconds / size, unit }
}
return { kind: 'interval', count: seconds, unit: 'second' }
}
/** `timedelta(...)` 一共多少秒;有参数算不出来就没有。 */
function timedeltaSeconds(call: Call): number | undefined {
if (call.splats.length > 0)
return undefined
const units = new Map(TIMEDELTA_UNITS)
const parts: [Node, number | undefined][] = [
...call.positional.map((arg, i): [Node, number | undefined] => [arg, TIMEDELTA_UNITS[i]?.[1]]),
...[...call.keywords].map(([key, value]): [Node, number | undefined] => [value, units.get(key)]),
]
let total = 0
for (const [node, size] of parts) {
const value = numberOf(node)
if (value === undefined || size === undefined)
return undefined
total += value * size
}
return total
}
/** crontab 的一个字段:*、*\/n、一串数(逗号隔开,可以是 a-b 一段);列表 / 集合 / 元组里的整数也算一串数。 */
type Field = { kind: 'any' } | { kind: 'step', step: number } | { kind: 'spans', spans: Span[] }
function parseField(text: string, names: readonly string[] | undefined): Field | undefined {
if (text === '*')
return { kind: 'any' }
const step = /^\*\/(\d+)$/.exec(text)
if (step)
return Number(step[1]) > 0 ? { kind: 'step', step: Number(step[1]) } : undefined
const value = (part: string): number | undefined => {
if (/^\d+$/.test(part))
return Number(part)
const index = names?.indexOf(part.slice(0, 3)) ?? -1
return part.length >= 3 && index >= 0 ? index : undefined
}
const spans: Span[] = []
for (const part of text.split(',')) {
const [from, to, ...rest] = part.trim().split('-')
const start = value(from)
const end = to === undefined ? undefined : value(to)
if (rest.length > 0 || start === undefined || (to !== undefined && end === undefined))
return undefined
spans.push(end === undefined ? { from: start } : { from: start, to: end })
}
return { kind: 'spans', spans }
}
function fieldOf(node: Node | undefined, names?: readonly string[]): Field | undefined {
if (!node)
return { kind: 'any' }
if (node.type === 'integer')
return parseField(node.text, undefined)
if (node.type === 'list' || node.type === 'set' || node.type === 'tuple') {
const items = syntaxChildren(node)
return items.length > 0 && items.every(item => item.type === 'integer') ? parseField(items.map(item => item.text).join(','), undefined) : undefined
}
const text = stringValue(node)
return text === undefined ? undefined : parseField(text.trim().toLowerCase(), names)
}
/** 一个个单独的数(没有 a-b 一段),都在 0..max 里。 */
function singles(field: Field, max: number): number[] | undefined {
if (field.kind !== 'spans' || field.spans.some(span => span.to !== undefined || span.from > max))
return undefined
return field.spans.map(span => span.from).sort((a, b) => a - b)
}
/** 取值都在 min..max 里的几段。 */
function spansWithin(field: Field, min: number, max: number): Span[] | undefined {
if (field.kind !== 'spans' || field.spans.some(span => span.from < min || (span.to ?? span.from) > max))
return undefined
return field.spans
}
function timeOf(minute: Field, hour: Field): Time | undefined {
if (hour.kind === 'any' && minute.kind === 'any')
return { kind: 'minutes', step: 1 }
if (hour.kind === 'any' && minute.kind === 'step')
return { kind: 'minutes', step: minute.step }
const minutes = singles(minute, 59)
if (!minutes)
return undefined
if (hour.kind === 'any')
return { kind: 'hourly', step: 1, minutes }
if (hour.kind === 'step')
return { kind: 'hourly', step: hour.step, minutes }
const hours = singles(hour, 23)
return hours && { kind: 'at', times: hours.flatMap(h => minutes.map(m => ({ hour: h, minute: m }))) }
}
function daysOf(week: Field, month: Field, year: Field): Days | undefined {
const any = (field: Field): boolean => field.kind === 'any'
if (any(week) && any(month) && any(year))
return { kind: 'daily' }
if (any(month) && any(year)) {
const days = spansWithin(week, 0, 7)
return days && { kind: 'weekly', days }
}
if (!any(week))
return undefined
const days = spansWithin(month, 1, 31)
if (!days)
return undefined
if (any(year))
return { kind: 'monthly', days }
const months = spansWithin(year, 1, 12)
return months && { kind: 'yearly', months, days }
}
function crontabFrequency(call: Call): Frequency | undefined {
if (call.splats.length > 0)
return undefined
const fields = new Map<string, Node>()
call.positional.forEach((arg, i) => fields.set(CRONTAB_FIELDS[i] ?? String(i), arg))
for (const [key, value] of call.keywords)
fields.set(key, value)
if (call.positional.length > CRONTAB_FIELDS.length)
return undefined
const [minute, hour, week, month, year] = CRONTAB_FIELDS.map(field => fieldOf(fields.get(field), field === 'day_of_week' ? WEEKDAY_NAMES : undefined))
if (!minute || !hour || !week || !month || !year)
return undefined
const days = daysOf(week, month, year)
const time = timeOf(minute, hour)
return days && time && { kind: 'crontab', days, time }
}
/** 调度写法换算成频率:秒数算式、timedelta、crontab、schedule(run_every=…);换算不了的没有。 */
function frequencyOf(node: Node, aliases: ReadonlyMap<string, string>): Frequency | undefined {
const seconds = numberOf(node)
if (seconds !== undefined)
return interval(seconds)
const call = callOf(node, aliases)
switch (call?.name) {
case 'timedelta': {
const total = timedeltaSeconds(call)
return total === undefined ? undefined : interval(total)
}
case 'crontab':
return crontabFrequency(call)
case 'schedule': {
const every = call.keywords.get('run_every') ?? call.positional[0]
return every && frequencyOf(every, aliases)
}
default:
return undefined
}
}
/** 频率一格:换算得出的说成人话;写成秒数的表达式写「每 X 秒」;别的照写法翻。 */
function frequencyCell(node: Node, env: Env): Inline[] {
const frequency = frequencyOf(node, env.aliases)
if (frequency)
return [{ text: env.words.frequency(frequency), role: 'keyword' }]
if (!NUMBER_LIKE.has(node.type))
return env.context.translate(node)
const [before, after] = env.words.everySeconds
return [{ text: before, role: 'keyword' }, ...env.context.translate(node), { text: after, role: 'keyword' }]
}
// ─── 定时任务表 ───────────────────────────────────────────────────────────────
/** 定时任务表里的一条:`"键": {...}`(或别的值),或 `**x` 展开;comments 是讲它的注释。 */
interface Entry {
node: Node
key?: Node
value: Node
spread: boolean
comments: Node[]
}
/**
* 字典的各条,照源码顺序。讲一条的注释:写在它里面的、写在它最后一行行尾的、紧挨在它上面单独成行的;
* 最后一条后面单独成行的注释不讲哪一条。
*/
function entriesOf(dictionary: Node): Entry[] {
const entries: Entry[] = []
let pending: Node[] = []
for (const child of dictionary.namedChildren) {
if (!child)
continue
if (child.type === 'comment') {
const last = entries.at(-1)
if (last && pending.length === 0 && child.startPosition.row === last.node.endPosition.row)
last.comments.push(child)
else
pending.push(child)
continue
}
const spread = child.type === 'dictionary_splat'
const key = child.type === 'pair' ? child.childForFieldName('key') : null
const value = spread ? syntaxChildren(child)[0] : child.childForFieldName('value')
if (value && (spread || key)) {
const inside = spread ? [] : child.descendantsOfType('comment').filter((comment): comment is Node => comment !== null)
entries.push({ node: child, key: key ?? undefined, value, spread, comments: [...pending, ...inside] })
}
pending = []
}
return entries
}
/** 条目字典里的一项:键是字符串时 name 是它的内容。 */
interface Option { name?: string, key?: Node, value: Node }
function optionsOf(dictionary: Node): Option[] {
return syntaxChildren(dictionary).flatMap((child): Option[] => {
if (child.type === 'dictionary_splat')
return syntaxChildren(child).slice(0, 1).map(value => ({ value }))
const key = child.childForFieldName('key')
const value = child.childForFieldName('value')
return child.type === 'pair' && key && value ? [{ name: stringValue(key), key, value }] : []
})
}
/** 条目里除了任务名、调度的项(args、kwargs、options……)。 */
function extraOptions(entry: Entry): Option[] {
if (entry.spread || entry.value.type !== 'dictionary')
return []
return optionsOf(entry.value).filter(option => option.name !== TASK_KEY && option.name !== SCHEDULE_KEY)
}
/** 字符串照写的内容(不带引号),带源码下标;别的写法交给翻译插件。 */
function stringOr(node: Node, env: Env, extra: Partial<Segment> = {}): Inline[] {
const content = stringContent(node)
return content?.node ? [{ text: content.text, role: 'string', offset: content.node.startIndex, ...extra }] : env.context.translate(node)
}
/** 任务名一格:字符串写的任务名是规则自己能查定义的引用;条目的键和任务名不一样时键跟在后面(淡色)。 */
function taskCell(entry: Entry, task: Node | undefined, env: Env): Inline[] {
const taskName = stringContent(task)
const cell: Inline[] = task
? stringOr(task, env, taskName?.node ? { reference: { rule: RULE_ID, key: taskName.text } } : {})
: [hint(env.words.noTask)]
const key = entry.key && stringValue(entry.key)
return key !== undefined && key !== taskName?.text ? [...cell, hint(env.words.entryKey(key))] : cell
}
function optionsCell(options: Option[], env: Env): Inline[] {
const { words, context } = env
return joined(options.map(({ name: optionName, key, value }) => {
const label = optionName === undefined ? (key ? context.translate(key) : [hint(words.spread)]) : [word(words.options[optionName] ?? optionName)]
return [...label, plain(' '), ...context.translate(value)]
}), hint(' · '))
}
/** 注释的原文(翻译插件去掉 #),几条连成一段。 */
function commentText(comments: Node[], env: Env): Inline[] {
const text = comments.map(comment => env.context.doc(comment)?.body?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
}
/** 任务表的一行:任务 | 频率 | (选项)| 说明。展开的条目写「展开」和展开的表达式;值不是字典的条目写键和值。 */
function entryItem(entry: Entry, withOptions: boolean, env: Env): PropertyItem {
const { context, words } = env
const row = (task: Inline[], frequency: Inline[], options: Inline[]): PropertyItem =>
({ cells: [task, frequency, ...(withOptions ? [options] : []), commentText(entry.comments, env)] })
if (entry.spread)
return row([hint(words.spread)], context.translate(entry.value), [])
if (entry.value.type !== 'dictionary')
return row(entry.key ? stringOr(entry.key, env) : [], context.translate(entry.value), [])
const options = optionsOf(entry.value)
const task = options.find(option => option.name === TASK_KEY)?.value
const schedule = options.find(option => option.name === SCHEDULE_KEY)?.value
return row(taskCell(entry, task, env), schedule ? frequencyCell(schedule, env) : [], optionsCell(extraOptions(entry), env))
}
/** `X.conf.<attribute>` 里的 X;不是这种写法就没有。 */
function appOfConf(node: Node | null | undefined, attribute: string): Node | undefined {
if (node?.type !== 'attribute' || node.childForFieldName('attribute')?.text !== attribute)
return undefined
const conf = node.childForFieldName('object')
if (conf?.type !== 'attribute' || conf.childForFieldName('attribute')?.text !== 'conf')
return undefined
return conf.childForFieldName('object') ?? undefined
}
/** 定时任务卡片:应用 / 说明 / (conf.update 里别的配置各一行)/ 任务表。 */
function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleCard {
const { words, context } = env
const { labels, heads } = words
const entries = entriesOf(schedule)
const spreads = entries.filter(entry => entry.spread).length
const withOptions = entries.some(entry => extraOptions(entry).length > 0)
const markdown = doc && context.doc(doc)?.body
const rows: PropertyRow[] = [
{ label: labels.app, value: context.translate(app) },
...(markdown ? [{ label: labels.doc, value: markdown }] : []),
...settings.map(([label, value]) => ({ label, value: context.translate(value) })),
{
label: labels.tasks,
value: [hint(words.entries(entries.length - spreads, spreads))],
columns: [heads.task, heads.frequency, ...(withOptions ? [heads.options] : []), heads.doc],
items: entries.map(entry => entryItem(entry, withOptions, env)),
},
]
return { kind: BEAT_KIND, badge: { label: words.beatKind, color: BEAT_COLOR, icon: BEAT_ICON }, rows, absorbs: [] }
}
// ─── Celery 应用 ──────────────────────────────────────────────────────────────
/** Celery 应用卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数照写的顺序各一行,行名是参数名。 */
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleCard {
const { words, context } = env
const { labels } = words
const parameters = new Map<string, Node>()
call.positional.forEach((arg, i) => parameters.set(APP_PARAMETERS[i] ?? String(i), arg))
for (const [key, value] of call.keywords)
parameters.set(key, value)
const markdown = doc && context.doc(doc)?.body
const named = new Set<string>(APP_NAMED)
const rows: PropertyRow[] = [
{ label: labels.name, value: target.type === 'identifier' ? [name(target, 'variable')] : context.translate(target) },
...(markdown ? [{ label: labels.doc, value: markdown }] : []),
...APP_NAMED.flatMap((key) => {
const value = parameters.get(key)
return value ? [{ label: labels[key], value: context.translate(value) }] : []
}),
...[...parameters].filter(([key]) => !named.has(key)).map(([key, value]) => ({ label: key, value: context.translate(value) })),
...call.splats.map(value => ({ label: words.spread, value: context.translate(value) })),
]
return {
kind: APP_KIND,
badge: { label: words.appKind, color: APP_COLOR, icon: APP_ICON },
rows,
...(target.type === 'identifier' && { defines: range(target) }),
absorbs: [],
}
}
// ─── 任务名 → 定义:工作区索引 ─────────────────────────────────────────────────
const TASK_DECORATORS = new Set(['task', 'shared_task'])
/**
* 文件的模块路径:从它所在的目录往上,有 `__init__.py` 的都是包;包外面还套着 `src` 目录时(命名空间包)从 `src` 下面算起。
* `backend/src/luffy_agent/commerce/tasks.py` → luffy_agent.commerce.tasks。
*/
function moduleOf(file: IndexedFile): string {
const directories = file.path.split('/')
const fileName = directories.pop() ?? ''
let start = directories.length
while (start > 0 && file.exists([...directories.slice(0, start), '__init__.py'].join('/')))
start--
const src = start > 0 ? directories.lastIndexOf('src', start - 1) : -1
if (src >= 0)
start = src + 1
const stem = fileName.replace(/\.py$/, '')
return [...directories.slice(start), ...(stem === '__init__' ? [] : [stem])].join('.')
}
/** 写在模块里(不在函数、类里面):Celery 默认的任务名只对这样的函数是「模块路径.函数名」。 */
function atModuleLevel(node: Node): boolean {
for (let parent = node.parent; parent; parent = parent.parent) {
if (parent.type === 'function_definition' || parent.type === 'class_definition')
return false
}
return true
}
/** 装饰器是 `@<任何东西>.task`、`@shared_task`、`@task`(带不带括号都算)时,给出任务名;name= 写的不是字符串就认不出来。 */
function taskNameOf(decorator: Node, fn: Node, module: string, moduleLevel: boolean): string | undefined {
const [expression] = syntaxChildren(decorator)
const call = expression?.type === 'call' ? expression : undefined
if (!TASK_DECORATORS.has(lastName(call ? call.childForFieldName('function') : expression) ?? ''))
return undefined
const explicit = callOf(call, new Map())?.keywords.get('name')
if (explicit)
return stringValue(explicit)
if (!moduleLevel)
return undefined
return module ? `${module}.${fn.text}` : fn.text
}
function taskEntries(root: Node, file: IndexedFile): IndexEntry[] {
const module = moduleOf(file)
return root.descendantsOfType('decorated_definition').flatMap((decorated): IndexEntry[] => {
const definition = decorated?.childForFieldName('definition')
const fn = definition?.type === 'function_definition' ? definition.childForFieldName('name') : null
if (!decorated || !fn)
return []
const moduleLevel = atModuleLevel(decorated)
const key = syntaxChildren(decorated)
.filter(child => child.type === 'decorator')
.map(decorator => taskNameOf(decorator, fn, module, moduleLevel))
.find(found => found !== undefined)
return key ? [{ key, name: range(fn) }] : []
})
}
/** 整个工作区里的 Celery 任务:任务名 → 定义处的函数名。任务文件常常只导入自己的应用实例,不要求导入 celery。 */
const TASK_INDEX: WorkspaceIndex = {
id: 'celery.tasks',
languageId: 'python',
include: '**/*.py',
mentions: source => /\.task\b|\bshared_task\b|@task\b/.test(source),
entries: taskEntries,
}
export const celeryRule: LibraryRule = {
id: RULE_ID,
label: 'Celery 应用与定时任务',
languageIds: ['python'],
libraries: LIBRARIES,
claim: (block, context) => {
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, aliases: aliasesOf(context.imports, SCHEDULE_LIBRARIES) }
if (block.kind === 'variable' || block.kind === 'const') {
const [declarator, ...rest] = block.facts.declarators
if (!declarator?.value || rest.length > 0)
return undefined
const call = callOf(declarator.value, env.aliases)
if (call?.name === 'Celery')
return appBlock(declarator.name, call, block.facts.doc, env)
const app = appOfConf(declarator.name, 'beat_schedule')
return app && declarator.value.type === 'dictionary' ? beatBlock(app, declarator.value, [], block.facts.doc, env) : undefined
}
if (block.kind !== 'statement')
return undefined
const [expression] = syntaxChildren(block.node)
const call = callOf(expression, env.aliases)
const app = expression?.type === 'call' ? appOfConf(expression.childForFieldName('function'), 'update') : undefined
const schedule = call?.keywords.get('beat_schedule')
if (!app || !call || schedule?.type !== 'dictionary')
return undefined
const settings: [string, Node][] = [
...call.positional.map((value): [string, Node] => [env.words.spread, value]),
...[...call.keywords].filter(([key]) => key !== 'beat_schedule'),
...call.splats.map((value): [string, Node] => [env.words.spread, value]),
]
return beatBlock(app, schedule, settings, undefined, env)
},
resolve: (key, workspace) => workspace.lookup(TASK_INDEX, key),
}