From dd84d37c6ea89c64db712c7c94709f3181a7bd1f Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Wed, 11 Sep 2024 14:06:58 +0300 Subject: [PATCH] feat: `runs ls` (#640) --- .github/workflows/check.yaml | 2 +- package.json | 5 +- src/commands/builds/ls.ts | 55 ++++------- src/commands/create.ts | 6 +- src/commands/run.ts | 6 +- src/commands/runs/ls.ts | 135 +++++++++++++++++++++++++++ src/lib/commands/responsive-table.ts | 79 ++++++++++++++++ src/lib/consts.ts | 2 +- yarn.lock | 31 ++---- 9 files changed, 251 insertions(+), 70 deletions(-) create mode 100644 src/commands/runs/ls.ts create mode 100644 src/lib/commands/responsive-table.ts diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7cd3eddc..3fb38818 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -62,7 +62,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.os }} steps: diff --git a/package.json b/package.json index 5861f693..82601dd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apify-cli", - "version": "0.20.7", + "version": "0.21.0", "description": "Apify command-line interface (CLI) helps you manage the Apify cloud platform and develop, build, and deploy Apify Actors.", "exports": "./dist/index.js", "types": "./dist/index.d.ts", @@ -74,7 +74,7 @@ "archiver": "~7.0.1", "axios": "~1.7.3", "chalk": "~5.3.0", - "cli-table": "^0.3.11", + "cli-table3": "^0.6.5", "computer-name": "~0.1.0", "configparser": "~0.3.10", "cors": "~2.8.5", @@ -113,7 +113,6 @@ "@types/adm-zip": "^0.5.5", "@types/archiver": "^6.0.2", "@types/chai": "^4.3.17", - "@types/cli-table": "^0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/fs-extra": "^11", diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index 05475263..2bd2bfe1 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -1,37 +1,19 @@ import { Flags } from '@oclif/core'; import type { BuildCollectionClientListItem } from 'apify-client'; import chalk from 'chalk'; -import Table from 'cli-table'; import { ApifyCommand } from '../../lib/apify_command.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; +import { ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { error, simpleLog } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, objectGroupBy, ShortDurationFormatter } from '../../lib/utils.js'; -const tableFactory = (compact = false) => { - const options: Record = { - head: ['Number', 'ID', 'Status', 'Took'], - style: { - head: ['cyan', 'cyan', 'cyan', 'cyan'], - compact, - }, - }; - - if (compact) { - options.chars = { - 'mid': '', - 'left-mid': '', - 'mid-mid': '', - 'right-mid': '', - middle: ' ', - 'top-mid': '─', - 'bottom-mid': '─', - }; - } - - return new Table<[string, string, string, string]>(options); -}; +const tableFactory = () => + new ResponsiveTable({ + allColumns: ['Number', 'ID', 'Status', 'Took'], + mandatoryColumns: ['Number', 'ID', 'Status', 'Took'], + }); export class BuildLsCommand extends ApifyCommand { static override description = 'Lists all builds of the Actor.'; @@ -132,7 +114,6 @@ export class BuildLsCommand extends ApifyCommand { const latestBuildTag = actorInfo.versions.find((v) => v.versionNumber === actorVersion)?.buildTag; const table = this.generateTableForActorVersion({ buildsForVersion, - compact, buildTagToActorVersion, }); @@ -142,7 +123,7 @@ export class BuildLsCommand extends ApifyCommand { const message = [ chalk.reset(`Builds for Actor Version ${chalk.yellow(actorVersion)}${latestBuildTagMessage}`), - table.toString(), + table.render(compact), '', ]; @@ -155,15 +136,13 @@ export class BuildLsCommand extends ApifyCommand { } private generateTableForActorVersion({ - compact, buildsForVersion, buildTagToActorVersion, }: { - compact: boolean; buildsForVersion: BuildCollectionClientListItem[]; buildTagToActorVersion: Record; }) { - const table = tableFactory(compact); + const table = tableFactory(); for (const build of buildsForVersion) { // TODO: untyped field, https://github.com/apify/apify-client-js/issues/526 @@ -173,23 +152,23 @@ export class BuildLsCommand extends ApifyCommand { ? ` (${chalk.yellow(buildTagToActorVersion[buildNumber])})` : ''; - const tableRow: [string, string, string, string] = [ - `${buildNumber}${hasTag}`, - chalk.gray(build.id), - prettyPrintStatus(build.status), - '', - ]; + let finishedAt: string; if (build.finishedAt) { const diff = build.finishedAt.getTime() - build.startedAt.getTime(); - tableRow[3] = chalk.gray(`${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); + finishedAt = chalk.gray(`${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); } else { const diff = Date.now() - build.startedAt.getTime(); - tableRow[3] = chalk.gray(`Running for ${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); + finishedAt = chalk.gray(`Running for ${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); } - table.push(tableRow); + table.pushRow({ + Number: `${buildNumber}${hasTag}`, + ID: chalk.gray(build.id), + Status: prettyPrintStatus(build.status), + Took: finishedAt, + }); } return table; diff --git a/src/commands/create.ts b/src/commands/create.ts index 19c9b470..7133d33f 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -235,16 +235,16 @@ export class CreateCommand extends ApifyCommand { dependenciesInstalled = true; } else { warning({ - message: `Python Actors require Python 3.8 or higher, but you have Python ${pythonVersion}!`, + message: `Python Actors require Python 3.9 or higher, but you have Python ${pythonVersion}!`, }); warning({ - message: 'Please install Python 3.8 or higher to be able to run Python Actors locally.', + message: 'Please install Python 3.9 or higher to be able to run Python Actors locally.', }); } } else { warning({ message: - 'No Python detected! Please install Python 3.8 or higher to be able to run Python Actors locally.', + 'No Python detected! Please install Python 3.9 or higher to be able to run Python Actors locally.', }); } } diff --git a/src/commands/run.ts b/src/commands/run.ts index 706f3fbe..bbc08800 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -341,16 +341,16 @@ export class RunCommand extends ApifyCommand { } } else { error({ - message: `Python Actors require Python 3.8 or higher, but you have Python ${pythonVersion}!`, + message: `Python Actors require Python 3.9 or higher, but you have Python ${pythonVersion}!`, }); error({ - message: 'Please install Python 3.8 or higher to be able to run Python Actors locally.', + message: 'Please install Python 3.9 or higher to be able to run Python Actors locally.', }); } } else { error({ message: - 'No Python detected! Please install Python 3.8 or higher to be able to run Python Actors locally.', + 'No Python detected! Please install Python 3.9 or higher to be able to run Python Actors locally.', }); } } diff --git a/src/commands/runs/ls.ts b/src/commands/runs/ls.ts new file mode 100644 index 00000000..904c8533 --- /dev/null +++ b/src/commands/runs/ls.ts @@ -0,0 +1,135 @@ +import { Flags } from '@oclif/core'; +import { Timestamp } from '@sapphire/timestamp'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; +import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; +import { ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, ShortDurationFormatter } from '../../lib/utils.js'; + +const multilineTimestampFormatter = new Timestamp(`YYYY-MM-DD[\n]HH:mm:ss`); + +const table = new ResponsiveTable({ + allColumns: ['ID', 'Status', 'Results', 'Usage', 'Started At', 'Took', 'Build No.', 'Origin'], + mandatoryColumns: ['ID', 'Status', 'Results', 'Usage', 'Started At', 'Took'], + columnAlignments: { + Results: 'right', + Usage: 'right', + Took: 'right', + 'Build No.': 'right', + }, +}); + +export class RunsLsCommand extends ApifyCommand { + static override description = 'Lists all runs of the Actor.'; + + static override flags = { + actor: Flags.string({ + description: + 'Optional Actor ID or Name to list runs for. By default, it will use the Actor from the current directory.', + }), + offset: Flags.integer({ + description: 'Number of runs that will be skipped.', + default: 0, + }), + limit: Flags.integer({ + description: 'Number of runs that will be listed.', + default: 10, + }), + desc: Flags.boolean({ + description: 'Sort runs in descending order.', + default: false, + }), + compact: Flags.boolean({ + description: 'Display a compact table.', + default: false, + char: 'c', + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { actor, desc, limit, offset, compact, json } = this.flags; + + const client = await getLoggedClientOrThrow(); + + // Should we allow users to list any runs, not just actor-specific runs? Right now it works like `builds ls`, requiring an actor + const ctx = await resolveActorContext({ providedActorNameOrId: actor, client }); + + if (!ctx.valid) { + error({ + message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor=".`, + }); + + return; + } + + const allRuns = await client.actor(ctx.id).runs().list({ desc, limit, offset }); + + if (json) { + return allRuns; + } + + if (!allRuns.items.length) { + simpleLog({ + message: 'There are no recent runs found for this Actor.', + }); + + return; + } + + const message = [ + `${chalk.reset('Showing')} ${chalk.yellow(allRuns.items.length)} out of ${chalk.yellow(allRuns.total)} runs for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})`, + ]; + + const datasetInfos = new Map( + await Promise.all( + allRuns.items.map(async (run) => + client + .dataset(run.defaultDatasetId) + .get() + .then( + (data) => [run.id, chalk.yellow(data?.itemCount ?? 0)] as const, + () => [run.id, chalk.gray('N/A')] as const, + ), + ), + ), + ); + + for (const run of allRuns.items) { + let tookString: string; + + if (run.finishedAt) { + const diff = run.finishedAt.getTime() - run.startedAt.getTime(); + + tookString = chalk.gray(`${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); + } else { + const diff = Date.now() - run.startedAt.getTime(); + + tookString = chalk.gray(`Running for ${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); + } + + table.pushRow({ + ID: chalk.gray(run.id), + Status: prettyPrintStatus(run.status), + Results: datasetInfos.get(run.id) || chalk.gray('N/A'), + Usage: chalk.cyan(`$${(run.usageTotalUsd ?? 0).toFixed(3)}`), + 'Started At': multilineTimestampFormatter.display(run.startedAt), + Took: tookString, + 'Build No.': run.buildNumber, + Origin: run.meta.origin ?? 'UNKNOWN', + }); + } + + message.push(table.render(compact)); + + simpleLog({ + message: message.join('\n'), + }); + + return undefined; + } +} diff --git a/src/lib/commands/responsive-table.ts b/src/lib/commands/responsive-table.ts new file mode 100644 index 00000000..db766d6f --- /dev/null +++ b/src/lib/commands/responsive-table.ts @@ -0,0 +1,79 @@ +import Table from 'cli-table3'; + +const compactModeChars = { + 'mid': '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '', + middle: ' ', + 'top-mid': '─', + 'bottom-mid': '─', +}; + +function generateHeaderColors(length: number): string[] { + return Array.from({ length }, () => 'cyan'); +} + +const terminalColumns = process.stdout.columns ?? 100; + +export interface ResponsiveTableOptions< + AllColumns extends string, + MandatoryColumns extends NoInfer = AllColumns, +> { + /** + * Represents all the columns the that this table should show, and their order + */ + allColumns: AllColumns[]; + /** + * Represents the columns that are mandatory for the user to see, even if the terminal size is less than adequate (<100). + * Make sure this field includes columns that provide enough context AND that will fit in an 80-column terminal. + */ + mandatoryColumns: MandatoryColumns[]; + /** + * By default, all columns are left-aligned. You can specify columns that should be aligned in the middle or right + */ + columnAlignments?: Partial>; +} + +export class ResponsiveTable = AllColumns> { + private options: ResponsiveTableOptions; + + private rows: Record[] = []; + + constructor(options: ResponsiveTableOptions) { + this.options = options; + } + + pushRow(item: Record) { + this.rows.push(item); + } + + render(compact = false): string { + const head = terminalColumns < 100 ? this.options.mandatoryColumns : this.options.allColumns; + const headColors = generateHeaderColors(head.length); + const chars = compact ? compactModeChars : undefined; + + const colAligns: ('left' | 'right' | 'center')[] = []; + + for (const column of head) { + colAligns.push(this.options.columnAlignments?.[column] || 'left'); + } + + const table = new Table({ + head, + style: { + head: headColors, + compact, + }, + colAligns, + chars, + }); + + for (const rowData of this.rows) { + const row = head.map((col) => rowData[col]); + table.push(row); + } + + return table.toString(); + } +} diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 6450a27d..d2aaf4e4 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -78,7 +78,7 @@ export const CURRENT_APIFY_CLI_VERSION = pkg.version; export const APIFY_CLIENT_DEFAULT_HEADERS = { 'X-Apify-Request-Origin': META_ORIGINS.CLI }; -export const MINIMUM_SUPPORTED_PYTHON_VERSION = '3.8.0'; +export const MINIMUM_SUPPORTED_PYTHON_VERSION = '3.9.0'; export const PYTHON_VENV_PATH = '.venv'; diff --git a/yarn.lock b/yarn.lock index 1ae660c7..8c8d6f15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2819,13 +2819,6 @@ __metadata: languageName: node linkType: hard -"@types/cli-table@npm:^0": - version: 0.3.4 - resolution: "@types/cli-table@npm:0.3.4" - checksum: 10c0/dc266f9b8daae5efab0741546f6edccb96ece167a80ebf0c389d811969ea42f3f340575cc38923abdebae8fed1eec322c6769db1e0e4280bd5360daee7cef0ca - languageName: node - linkType: hard - "@types/connect@npm:*": version: 3.4.38 resolution: "@types/connect@npm:3.4.38" @@ -3656,7 +3649,6 @@ __metadata: "@types/adm-zip": "npm:^0.5.5" "@types/archiver": "npm:^6.0.2" "@types/chai": "npm:^4.3.17" - "@types/cli-table": "npm:^0" "@types/cors": "npm:^2.8.17" "@types/express": "npm:^4.17.21" "@types/fs-extra": "npm:^11" @@ -3679,7 +3671,7 @@ __metadata: axios: "npm:~1.7.3" chai: "npm:^4.4.1" chalk: "npm:~5.3.0" - cli-table: "npm:^0.3.11" + cli-table3: "npm:^0.6.5" computer-name: "npm:~0.1.0" configparser: "npm:~0.3.10" cors: "npm:~2.8.5" @@ -4575,12 +4567,16 @@ __metadata: languageName: node linkType: hard -"cli-table@npm:^0.3.11": - version: 0.3.11 - resolution: "cli-table@npm:0.3.11" +"cli-table3@npm:^0.6.5": + version: 0.6.5 + resolution: "cli-table3@npm:0.6.5" dependencies: - colors: "npm:1.0.3" - checksum: 10c0/6e31da4e19e942bf01749ff78d7988b01e0101955ce2b1e413eecdc115d4bb9271396464761491256a7d3feeedb5f37ae505f4314c4f8044b5d0f4b579c18f29 + "@colors/colors": "npm:1.5.0" + string-width: "npm:^4.2.0" + dependenciesMeta: + "@colors/colors": + optional: true + checksum: 10c0/d7cc9ed12212ae68241cc7a3133c52b844113b17856e11f4f81308acc3febcea7cc9fd298e70933e294dd642866b29fd5d113c2c098948701d0c35f09455de78 languageName: node linkType: hard @@ -4683,13 +4679,6 @@ __metadata: languageName: node linkType: hard -"colors@npm:1.0.3": - version: 1.0.3 - resolution: "colors@npm:1.0.3" - checksum: 10c0/f9e40dd8b3e1a65378a7ced3fced15ddfd60aaf38e99a7521a7fdb25056b15e092f651cd0f5aa1e9b04fa8ce3616d094e07fc6c2bb261e24098db1ddd3d09a1d - languageName: node - linkType: hard - "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8"