@appium/docutils
Version:
Documentation generation utilities for Appium and related projects
324 lines • 12.6 kB
JavaScript
"use strict";
/**
* Validates an environment for building documentation; used by `validate` command
*
* @module
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DocutilsValidator = void 0;
const support_1 = require("@appium/support");
const chalk_1 = __importDefault(require("chalk"));
const lodash_1 = __importDefault(require("lodash"));
const node_events_1 = require("node:events");
const teen_process_1 = require("teen_process");
const constants_1 = require("./constants");
const error_1 = require("./error");
const fs_1 = require("./fs");
const logger_1 = require("./logger");
/**
* Matches the Python version string from `python --version`
*/
const PYTHON_VER_STR = 'Python 3.';
/**
* Matches the MkDocs version string from `mkdocs --version`
*/
const MKDOCS_VERSION_REGEX = /\s+version\s+(\d+\.\d+\.\S+)/;
const log = (0, logger_1.getLogger)('validate');
/**
* This class is designed to run _all_ validation checks (as requested by the user), and emit events for
* each failure encountered.
*
* Whenever a method _rejects or throws_, this is considered an "unexpected" error, and the validation
* will abort.
*
* @todo Use [`strict-event-emitter-types`](https://npm.im/strict-event-emitter-types)
*/
class DocutilsValidator extends node_events_1.EventEmitter {
/**
* Current working directory. Defaults to `process.cwd()`
* @todo This cannot yet be overridden by user
*/
cwd;
/**
* Path to `python` executable.
*/
pythonPath;
/**
* List of validations to perform
*/
validations = new Set();
/**
* Mapping of error messages to errors.
*
* Used to prevent duplicate emission of errors and track error count; if non-empty, the validation
* process should be considered to have failed.
*
* Reset after {@linkcode DocutilsValidator.validate validate} completes.
*/
emittedErrors = new Map();
/**
* Path to `mkdocs.yml`. If not provided, will be lazily resolved.
*/
mkDocsYmlPath;
/**
* Emitted when validation begins with a list of validation kinds to be performed
* @event
*/
static BEGIN = 'begin';
/**
* Emitted when validation ends with an error count
* @event
*/
static END = 'end';
/**
* Emitted when a validation fails, with the associated {@linkcode DocutilsError}
* @event
*/
static FAILURE = 'fail';
/**
* Emitted when a validation succeeds
* @event
*/
static SUCCESS = 'ok';
requirementsTxt;
/**
* Creates a listener to track errors emitted
*/
constructor(opts = {}) {
super();
this.pythonPath = opts.pythonPath;
this.cwd = opts.cwd ?? process.cwd();
this.mkDocsYmlPath = opts.mkdocsYml;
if (opts.python) {
this.validations.add(constants_1.NAME_PYTHON);
}
if (opts.mkdocs) {
this.validations.add(constants_1.NAME_MKDOCS);
}
// this just tracks the emitted errors
this.on(DocutilsValidator.FAILURE, (err) => {
this.emittedErrors.set(err.message, err);
});
}
/**
* Runs the configured validations, then resets internal state upon completion or rejection.
*/
async validate() {
try {
this.emit(DocutilsValidator.BEGIN, [...this.validations]);
if (this.validations.has(constants_1.NAME_PYTHON)) {
await this.validatePythonVersion();
await this.validatePythonDeps();
}
if (this.validations.has(constants_1.NAME_MKDOCS)) {
await this.validateMkDocs();
await this.validateMkDocsConfig();
}
this.emit(DocutilsValidator.END, this.emittedErrors.size);
}
finally {
this.reset();
}
}
/**
* If a thing like `err` has not already been emitted, emit
* {@linkcode DocutilsValidator.FAILURE}.
* @param err A validation error
* @returns
*/
fail(err) {
const dErr = lodash_1.default.isString(err) ? new error_1.DocutilsError(err) : err;
if (!this.emittedErrors.has(dErr.message)) {
this.emit(DocutilsValidator.FAILURE, dErr);
}
}
/**
* Emits a {@linkcode DocutilsValidator.SUCCESS} event
* @param message Success message
*/
ok(message) {
this.emit(DocutilsValidator.SUCCESS, message);
}
/**
* Parses a `requirements.txt` file and returns an array of packages
*
* Caches the result.
* @returns List of package data w/ name and version
*/
async parseRequirementsTxt() {
if (this.requirementsTxt) {
return this.requirementsTxt;
}
const requiredPackages = [];
try {
let requirementsTxt = await support_1.fs.readFile(constants_1.REQUIREMENTS_TXT_PATH, 'utf8');
requirementsTxt = requirementsTxt.trim();
log.debug('Raw %s: %s', constants_1.NAME_REQUIREMENTS_TXT, requirementsTxt);
for (const line of requirementsTxt.split(/\r?\n/)) {
const [name, version] = line.trim().split('==');
requiredPackages.push({ name, version });
}
log.debug('Parsed %s: %O', constants_1.NAME_REQUIREMENTS_TXT, requiredPackages);
}
catch {
throw new error_1.DocutilsError(`Could not find ${constants_1.REQUIREMENTS_TXT_PATH}. This is a bug`);
}
return (this.requirementsTxt = requiredPackages);
}
/**
* Resets the cache of emitted errors
*/
reset() {
this.emittedErrors.clear();
}
/**
* Validates that the correct version of `mkdocs` is installed
*/
async validateMkDocs() {
log.debug(`Validating MkDocs version`);
const pythonPath = this.pythonPath ?? (await (0, fs_1.findPython)());
if (!pythonPath) {
return this.fail(constants_1.MESSAGE_PYTHON_MISSING);
}
const mkdocsInstalled = await (0, fs_1.isMkDocsInstalled)();
if (!mkdocsInstalled) {
return this.fail(`Could not find MkDocs executable; please run "${constants_1.NAME_BIN} init"`);
}
let rawMkDocsVersion;
try {
({ stdout: rawMkDocsVersion } = await (0, teen_process_1.exec)(pythonPath, ['-m', constants_1.NAME_MKDOCS, '--version']));
}
catch (err) {
return this.fail(`Failed to get MkDocs version: ${err}`);
}
const match = rawMkDocsVersion.match(MKDOCS_VERSION_REGEX);
if (!match) {
return this.fail(`Could not parse MkDocs version. Output was ${rawMkDocsVersion}`);
}
const version = match[1];
const reqs = await this.parseRequirementsTxt();
const mkDocsPipPkg = lodash_1.default.find(reqs, { name: constants_1.NAME_MKDOCS });
if (!mkDocsPipPkg) {
throw new error_1.DocutilsError(`No ${constants_1.NAME_MKDOCS} package in ${constants_1.REQUIREMENTS_TXT_PATH}. This is a bug`);
}
const { version: mkDocsReqdVersion } = mkDocsPipPkg;
if (version !== mkDocsReqdVersion) {
return this.fail(`MkDocs v${version} is installed, but v${mkDocsReqdVersion} is required`);
}
this.ok('MkDocs install OK');
}
/**
* Validates (sort of) an `mkdocs.yml` config file.
*
* It checks if the file exists, if it can be parsed as YAML, and if it has a `site_name` property.
*/
async validateMkDocsConfig() {
log.debug(`Validating ${constants_1.NAME_MKDOCS_YML}`);
const mkDocsYmlPath = this.mkDocsYmlPath ?? (await (0, fs_1.findMkDocsYml)(this.cwd));
if (!mkDocsYmlPath) {
return this.fail(`Could not find ${constants_1.NAME_MKDOCS_YML} from ${this.cwd}; please run "${constants_1.NAME_BIN} init"`);
}
let mkDocsYml;
try {
mkDocsYml = await (0, fs_1.readMkDocsYml)(mkDocsYmlPath);
}
catch (e) {
const err = e;
if (err.code === constants_1.NAME_ERR_ENOENT) {
return this.fail(`Could not find ${constants_1.NAME_MKDOCS_YML} at ${mkDocsYmlPath}. Use --mkdocs-yml to specify a different path.`);
}
return this.fail(`Could not parse ${mkDocsYmlPath}: ${err}`);
}
if (!mkDocsYml.site_name) {
return this.fail(`Could not find required property "site_name" in ${mkDocsYmlPath}`);
}
this.ok(`MkDocs config at ${mkDocsYmlPath} OK`);
}
/**
* Asserts that the dependencies as listed in `requirements.txt` are installed.
*
* @privateRemarks This lists all installed packages with `pip` and then compares them to the
* contents of our `requirements.txt`. Versions _must_ match exactly.
*/
async validatePythonDeps() {
log.debug(`Validating required ${constants_1.NAME_PYTHON} package versions`);
const pythonPath = this.pythonPath ?? (await (0, fs_1.findPython)());
if (!pythonPath) {
return this.fail(constants_1.MESSAGE_PYTHON_MISSING);
}
let pipListOutput;
try {
({ stdout: pipListOutput } = await (0, teen_process_1.exec)(pythonPath, [
'-m',
constants_1.NAME_PIP,
'list',
'--format',
'json',
]));
}
catch {
return this.fail(`Could not find ${constants_1.NAME_PIP} installation for Python at ${pythonPath}. Is it installed?`);
}
let installedPkgs;
try {
installedPkgs = JSON.parse(pipListOutput);
}
catch {
return this.fail(`Could not parse output of "${constants_1.NAME_PIP} list" as JSON: ${pipListOutput}`);
}
const pkgsByName = lodash_1.default.mapValues(lodash_1.default.keyBy(installedPkgs, 'name'), 'version');
log.debug('Installed Python packages: %O', pkgsByName);
const requiredPackages = await this.parseRequirementsTxt();
const missingPackages = [];
const invalidVersionPackages = [];
for (const reqdPkg of requiredPackages) {
const version = pkgsByName[reqdPkg.name];
if (!version) {
missingPackages.push(reqdPkg);
}
else if (version !== reqdPkg.version) {
invalidVersionPackages.push([reqdPkg, { name: reqdPkg.name, version }]);
}
}
const msgParts = [];
if (missingPackages.length) {
msgParts.push(`The following required ${support_1.util.pluralize('package', missingPackages.length)} could not be found:\n${missingPackages
.map((p) => (0, chalk_1.default) `- {yellow ${p.name}} @ {yellow ${p.version}}`)
.join('\n')}`);
}
if (invalidVersionPackages.length) {
msgParts.push(`The following required ${support_1.util.pluralize('package', invalidVersionPackages.length)} are installed, but at the wrong version:\n${invalidVersionPackages
.map(([expected, actual]) => (0, chalk_1.default) `- {yellow ${expected.name}} @ {yellow ${expected.version}} (found {red ${actual.version}})`)
.join('\n')}`);
}
if (msgParts.length) {
return this.fail(`Required Python dependency validation failed:\n\n${msgParts.join('\n\n')}`);
}
this.ok('Python dependencies OK');
}
/**
* Asserts that the Python version is 3.x
*/
async validatePythonVersion() {
log.debug(`Validating ${constants_1.NAME_PYTHON} version`);
const pythonPath = this.pythonPath ?? (await (0, fs_1.findPython)());
if (!pythonPath) {
return this.fail(constants_1.MESSAGE_PYTHON_MISSING);
}
try {
const { stdout } = await (0, teen_process_1.exec)(pythonPath, ['--version']);
if (!stdout.includes(PYTHON_VER_STR)) {
return this.fail(`Could not find Python 3.x in PATH; found ${stdout}`);
}
}
catch {
return this.fail(`Could not retrieve Python version`);
}
this.ok('Python version OK');
}
}
exports.DocutilsValidator = DocutilsValidator;
//# sourceMappingURL=validate.js.map