diff --git a/bin/unipept.ts b/bin/unipept.ts new file mode 100755 index 00000000..bbb5e051 --- /dev/null +++ b/bin/unipept.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { Unipept } from '../lib/commands/unipept.js'; + +const command = new Unipept(); +command.run(); diff --git a/lib/commands/base_command.ts b/lib/commands/base_command.ts index 35c7b795..efa87e6a 100644 --- a/lib/commands/base_command.ts +++ b/lib/commands/base_command.ts @@ -10,16 +10,14 @@ import { readFileSync } from "fs"; */ export abstract class BaseCommand { public program: Command; - args: string[] | undefined; version: string; - constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean, args?: string[] }) { + constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean }) { this.version = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version; this.program = this.create(options); - this.args = options?.args; } - abstract run(): void; + abstract run(args?: string[]): void; /** * Create sets up the command line program. Implementing classes can add additional options. @@ -47,10 +45,10 @@ export abstract class BaseCommand { /** * This allows us to pass a custom list of strings as arguments to the command during testing. */ - parseArguments() { - if (this.args) { + parseArguments(args?: string[]) { + if (args) { // custom arg parsing to be able to inject args for testing - this.program.parse(this.args, { from: "user" }); + this.program.parse(args, { from: "user" }); } else { this.program.parse(); } diff --git a/lib/commands/peptfilter.ts b/lib/commands/peptfilter.ts index f8435dc4..0bdb17ec 100644 --- a/lib/commands/peptfilter.ts +++ b/lib/commands/peptfilter.ts @@ -7,7 +7,7 @@ export class Peptfilter extends BaseCommand { The input should have one peptide per line. FASTA headers are preserved in the output, so that peptides remain bundled.`; - constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean, args?: string[] }) { + constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean }) { super(options); this.program @@ -24,8 +24,8 @@ The input should have one peptide per line. FASTA headers are preserved in the o * async iterators. This alternative implementation runs in 2.5 seconds. However, I decided that the async iterator implementation is * both more readable and more in line with the implementation of the other commands. */ - async run() { - this.parseArguments(); + async run(args?: string[]) { + this.parseArguments(args); const minLen = this.program.opts().minlen; const maxlen = this.program.opts().maxlen; const lacks = this.program.opts().lacks || []; diff --git a/lib/commands/prot2pept.ts b/lib/commands/prot2pept.ts index a64d862a..4c33ed7c 100644 --- a/lib/commands/prot2pept.ts +++ b/lib/commands/prot2pept.ts @@ -8,7 +8,7 @@ export class Prot2pept extends BaseCommand { The input should have either one protein sequence per line or contain a FASTA formatted list of protein sequences. FASTA headers are preserved in the output, so that peptides can be bundled per protein sequence. `; - constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean, args?: string[] }) { + constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean }) { super(options); this.program @@ -21,8 +21,8 @@ The input should have either one protein sequence per line or contain a FASTA fo * Performance note: Just as with peptfilter, this implementation can be made faster by using line events instead of * async iterators. */ - async run() { - this.parseArguments(); + async run(args?: string[]) { + this.parseArguments(args); let pattern; try { diff --git a/lib/commands/unipept.ts b/lib/commands/unipept.ts new file mode 100644 index 00000000..24c1b29a --- /dev/null +++ b/lib/commands/unipept.ts @@ -0,0 +1,28 @@ +import { BaseCommand } from './base_command.js'; +import { Pept2lca } from './unipept/pept2lca.js'; + +export class Unipept extends BaseCommand { + + readonly description = `The unipept subcommands are command line wrappers around the Unipept web services. + +Subcommands that start with pept expect a list of tryptic peptides as input. Subcommands that start with tax expect a list of NCBI Taxonomy Identifiers as input. Input is passed + +- as separate command line arguments +- in a text file that is passed as an argument to the -i option +- to standard input + +The command will give priority to the first way the input is passed, in the order as listed above. Text files and standard input should have one tryptic peptide or one NCBI Taxonomy Identifier per line.`; + + constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean }) { + super(options); + + this.program + .summary("Command line interface to Unipept web services.") + .description(this.description) + .addCommand(new Pept2lca().command); + } + + async run(args?: string[]) { + this.parseArguments(args); + } +} diff --git a/lib/commands/unipept/pept2lca.ts b/lib/commands/unipept/pept2lca.ts new file mode 100644 index 00000000..97b8a871 --- /dev/null +++ b/lib/commands/unipept/pept2lca.ts @@ -0,0 +1,34 @@ +import { Option } from "commander"; +import { UnipeptSubcommand } from "./unipept_subcommand.js"; + +export class Pept2lca extends UnipeptSubcommand { + + readonly description = `For each tryptic peptide the unipept pept2lca command retrieves from Unipept the lowest common ancestor of the set of taxa from all UniProt entries whose protein sequence contains an exact matches to the tryptic peptide. The lowest common ancestor is based on the topology of the Unipept Taxonomy -- a cleaned up version of the NCBI Taxonomy -- and is itself a record from the NCBI Taxonomy. The command expects a list of tryptic peptides that are passed + +- as separate command line arguments +- in a text file that is passed as an argument to the -i option +- to standard input + +The command will give priority to the first way tryptic peptides are passed, in the order as listed above. Text files and standard input should have one tryptic peptide per line.`; + + constructor() { + super("pept2lca"); + + this.command + .summary("Fetch taxonomic lowest common ancestor of UniProt entries that match tryptic peptides.") + .description(this.description) + .option("-e, --equate", "equate isoleucine (I) and leucine (L) when matching peptides") + .option("-a, --all", "report all information fields of NCBI Taxonomy records available in Unipept. Note that this may have a performance penalty.") + .addOption(new Option("-s --select ", "select the information fields to return. Selected fields are passed as a comma separated list of field names. Multiple -s (or --select) options may be used.")) + .argument("[peptides...]", "optionally, 1 or more peptides") + .action((args, options) => this.run(args, options)); + } + + requiredFields(): string[] { + return ["peptide"]; + } + + defaultBatchSize(): number { + return 100; + } +} diff --git a/lib/commands/unipept/unipept_subcommand.ts b/lib/commands/unipept/unipept_subcommand.ts new file mode 100644 index 00000000..a9e6e593 --- /dev/null +++ b/lib/commands/unipept/unipept_subcommand.ts @@ -0,0 +1,154 @@ +import { Command, Option } from "commander"; +import { createReadStream, createWriteStream, readFileSync } from "fs"; +import { createInterface } from "node:readline"; +import { Interface } from "readline"; +import { Formatter } from "../../formatters/formatter.js"; +import { FormatterFactory } from "../../formatters/formatter_factory.js"; + +export abstract class UnipeptSubcommand { + public command: Command; + static readonly VALID_FORMATS = ["blast", "csv", "json", "xml"]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any = {}; + name: string; + user_agent: string; + host = "https://api.unipept.ugent.be"; + url?: string; + formatter?: Formatter; + outputStream: NodeJS.WritableStream = process.stdout; + firstBatch = true; + selectedFields?: RegExp[]; + fasta: boolean; + + constructor(name: string) { + this.name = name; + const version = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version; + this.user_agent = `unipept-cli/${version}`; + this.command = this.create(name); + this.fasta = false; + } + abstract defaultBatchSize(): number; + + requiredFields(): string[] { + return []; + } + + create(name: string): Command { + const command = new Command(name); + + command.option("-q, --quiet", "disable service messages"); + command.option("-i, --input ", "read input from file"); + command.option("-o, --output ", "write output to file"); + command.addOption(new Option("-f, --format ", "define the output format").choices(UnipeptSubcommand.VALID_FORMATS).default("json")); + command.option("--host ", "specify the server running the Unipept web service"); + + // internal options + command.addOption(new Option("--no-header", "disable the header in csv output").hideHelp()); + command.addOption(new Option("--batch ", "specify the batch size").hideHelp()); + + return command; + } + + async run(args: string[], options: { input?: string }): Promise { + this.options = options; + this.host = this.getHost(); + this.url = `${this.host}/api/v2/${this.name}.json`; + this.formatter = FormatterFactory.getFormatter(this.options.format); + if (this.options.output) { + this.outputStream = createWriteStream(this.options.output); + } + + let slice = []; + + for await (const input of this.getInputIterator(args, options.input)) { + slice.push(input); + if (slice.length >= this.batchSize) { + await this.processBatch(slice); + slice = []; + } + } + await this.processBatch(slice); + } + + async processBatch(slice: string[]): Promise { + if (!this.formatter) throw new Error("Formatter not set"); + + const r = await fetch(this.url as string, { + method: "POST", + body: this.constructRequestBody(slice), + headers: { + "Accept-Encoding": "gzip", + "User-Agent": this.user_agent, + } + }); + const result = await r.json(); + + if (this.firstBatch && this.options.header) { + this.firstBatch = false; + this.outputStream.write(this.formatter.header(result, this.fasta)); + } + + this.outputStream.write(this.formatter.format(result, this.fasta)); + } + + private constructRequestBody(slice: string[]): URLSearchParams { + const names = this.getSelectedFields().length === 0 || this.getSelectedFields().some(regex => regex.toString().includes("name") || regex.toString().includes(".*$")); + return new URLSearchParams({ + input: JSON.stringify(slice), + equate_il: this.options.equate, + extra: this.options.all, + names: this.options.all && names + }); + } + + private getSelectedFields(): RegExp[] { + if (this.selectedFields) return this.selectedFields; + + const fields = (this.options.select as string[])?.flatMap(f => f.split(",")) ?? []; + if (this.fasta && fields.length > 0) { + fields.push(...this.requiredFields()); + } + this.selectedFields = fields.map(f => this.globToRegex(f)); + + return this.selectedFields; + } + + private get batchSize(): number { + if (this.options.batch) { + return +this.options.batch; + } else { + return this.defaultBatchSize(); + } + } + + /** + * Returns an input iterator to use for the request. + * - if arguments are given, use arguments + * - if an input file is given, use the file + * - otherwise, use standard input + */ + private getInputIterator(args: string[], input?: string): string[] | Interface { + if (args.length > 0) { + return args; + } else if (input) { + return createInterface({ input: createReadStream(input) }); + } else { + return createInterface({ input: process.stdin }) + } + } + + private getHost(): string { + const host = this.options.host || this.host; + + // add http:// if needed + if (host.startsWith("http://") || host.startsWith("https://")) { + return host; + } else { + return `http://${host}`; + } + } + + private globToRegex(glob: string): RegExp { + return new RegExp(`^${glob.replace(/\*/g, ".*")}$`); + } +} diff --git a/lib/commands/uniprot.ts b/lib/commands/uniprot.ts index 11f5e57b..e0946251 100644 --- a/lib/commands/uniprot.ts +++ b/lib/commands/uniprot.ts @@ -16,7 +16,7 @@ The command will give priority to the first way UniProt Accession Numbers are pa The uniprot command yields just the protein sequences as a default, but can return several formats.`; - constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean, args?: string[] }) { + constructor(options?: { exitOverride?: boolean, suppressOutput?: boolean }) { super(options); this.program @@ -26,8 +26,8 @@ The uniprot command yields just the protein sequences as a default, but can retu .addOption(new Option("-f, --format ", `output format`).choices(Uniprot.VALID_FORMATS).default("sequence")); } - async run() { - this.parseArguments(); + async run(args?: string[]) { + this.parseArguments(args); const format = this.program.opts().format; const accessions = this.program.args; diff --git a/lib/formatters/csv_formatter.ts b/lib/formatters/csv_formatter.ts new file mode 100644 index 00000000..39b8657f --- /dev/null +++ b/lib/formatters/csv_formatter.ts @@ -0,0 +1,21 @@ +import { Formatter } from "./formatter.js"; +import { stringify } from "csv-stringify/sync"; + +export class CSVFormatter extends Formatter { + + header(sampleData: { [key: string]: string }[], fastaMapper?: boolean | undefined): string { + return stringify([this.getKeys(sampleData, fastaMapper)]); + } + + footer(): string { + return ""; + } + + convert(data: object[]): string { + return stringify(data); + } + + getKeys(data: { [key: string]: string }[], fastaMapper?: boolean | undefined): string[] { + return fastaMapper ? ["fasta_header", ...Object.keys(data[0])] : Object.keys(data[0]); + } +} diff --git a/lib/formatters/formatter.ts b/lib/formatters/formatter.ts new file mode 100644 index 00000000..1c8ecf02 --- /dev/null +++ b/lib/formatters/formatter.ts @@ -0,0 +1,18 @@ +export abstract class Formatter { + + abstract header(sampleData: object, fastaMapper?: boolean): string; + abstract footer(): string; + abstract convert(data: object[], first?: boolean): string; + + format(data: object[], fastaMapper?: boolean, first?: boolean): string { + if (fastaMapper) { + data = this.integrateFastaHeaders(data, fastaMapper); + } + return this.convert(data, first); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + integrateFastaHeaders(data: object[], fastaMapper: boolean): object[] { + return data; + } +} diff --git a/lib/formatters/formatter_factory.ts b/lib/formatters/formatter_factory.ts new file mode 100644 index 00000000..b350df77 --- /dev/null +++ b/lib/formatters/formatter_factory.ts @@ -0,0 +1,11 @@ +import { CSVFormatter } from "./csv_formatter.js"; +import { Formatter } from "./formatter.js"; + +export class FormatterFactory { + static getFormatter(name: string): Formatter { + if (name === "csv") { + return new CSVFormatter(); + } + return new CSVFormatter(); + } +} diff --git a/package.json b/package.json index f27f3c4c..fc1ff4c4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "bin": { "peptfilter": "./bin/peptfilter.js", "prot2pept": "./bin/prot2pept.js", + "unipept": "./bin/unipept.js", "uniprot": "./bin/uniprot.js" }, "scripts": { @@ -19,10 +20,12 @@ "typecheck": "yarn tsc --skipLibCheck --noEmit", "peptfilter": "yarn run tsx bin/peptfilter.ts", "prot2pept": "yarn run tsx bin/prot2pept.ts", + "unipept": "yarn run tsx bin/unipept.ts", "uniprot": "yarn run tsx bin/uniprot.ts" }, "dependencies": { - "commander": "^12.1.0" + "commander": "^12.1.0", + "csv-stringify": "^6.5.0" }, "devDependencies": { "@eslint/js": "^9.5.0", diff --git a/tests/commands/peptfilter.test.ts b/tests/commands/peptfilter.test.ts index 0318fde2..0f13096b 100644 --- a/tests/commands/peptfilter.test.ts +++ b/tests/commands/peptfilter.test.ts @@ -80,8 +80,8 @@ test('test if it passes fasta from stdin', async () => { test('test complex example from stdin', async () => { const stdin = mock.stdin(); - const command = new Peptfilter({ args: ["--minlen", "4", "--maxlen", "10", "--lacks", "B", "--contains", "A"] }); - const run = command.run(); + const command = new Peptfilter(); + const run = command.run(["--minlen", "4", "--maxlen", "10", "--lacks", "B", "--contains", "A"]); stdin.send("A\n"); stdin.send("AAAAAAAAAAA\n"); diff --git a/tests/commands/prot2pept.test.ts b/tests/commands/prot2pept.test.ts index ca01533a..80d7e504 100644 --- a/tests/commands/prot2pept.test.ts +++ b/tests/commands/prot2pept.test.ts @@ -111,8 +111,8 @@ test('test fasta input 3', async () => { test('test custom pattern', async () => { const stdin = mock.stdin(); - const command = new Prot2pept({ args: ["--pattern", "([KR])([^A])"] }); - const run = command.run(); + const command = new Prot2pept(); + const run = command.run(["--pattern", "([KR])([^A])"]); stdin.send("AALTERAALTERPAALTER\n"); stdin.end(); diff --git a/tests/commands/unipept.test.ts b/tests/commands/unipept.test.ts new file mode 100644 index 00000000..cdda14dd --- /dev/null +++ b/tests/commands/unipept.test.ts @@ -0,0 +1,7 @@ +import { Unipept } from '../../lib/commands/unipept'; + +test('test if all commands are available', async () => { + const command = new Unipept(); + const commandNames = command.program.commands.map(c => c.name()); + expect(commandNames).toContain("pept2lca"); +}); diff --git a/tests/commands/unipept/unipept_subcommand.test.ts b/tests/commands/unipept/unipept_subcommand.test.ts new file mode 100644 index 00000000..de816a36 --- /dev/null +++ b/tests/commands/unipept/unipept_subcommand.test.ts @@ -0,0 +1,58 @@ +import { Interface } from 'readline'; +import { Pept2lca } from '../../../lib/commands/unipept/pept2lca'; + +test('test command setup', () => { + const command = new Pept2lca(); + expect(command.name).toBe("pept2lca"); + expect(command.user_agent).toMatch(/^unipept-cli/); + expect(command.command.name()).toBe("pept2lca"); +}); + +test('test correct host', () => { + const command = new Pept2lca(); + + expect(command.host).toBe("https://api.unipept.ugent.be"); + expect(command["getHost"]()).toBe("https://api.unipept.ugent.be"); + + command.options.host = "https://optionshost"; + expect(command["getHost"]()).toBe("https://optionshost"); + + command.options.host = "http://optionshost"; + expect(command["getHost"]()).toBe("http://optionshost"); + + command.options.host = "optionshost"; + expect(command["getHost"]()).toBe("http://optionshost"); +}); + +test('test correct inputIterator', async () => { + const command = new Pept2lca(); + + // should be stdin + let input = command["getInputIterator"]([]) as Interface; + expect(input).toBeInstanceOf(Interface); + input.close(); + + // should be a (non-existant) file and error + input = command["getInputIterator"]([], "filename") as Interface; + input.on("error", (e) => { + expect(e.toString()).toMatch(/no such file/); + }); + + // should be array + const inputArray = command["getInputIterator"](["A", "B"]); + expect(inputArray).toBeInstanceOf(Array); +}); + +test('test selected fields parsing', () => { + const command = new Pept2lca(); + + command.options.select = ["a,b,c"]; + expect(command["getSelectedFields"]()).toStrictEqual([/^a$/, /^b$/, /^c$/]); +}); + +test('test selected fields with wildcards', () => { + const command = new Pept2lca(); + + command.options.select = ["taxon*,name"]; + expect(command["getSelectedFields"]()).toStrictEqual([/^taxon.*$/, /^name$/]); +}); diff --git a/tests/commands/uniprot.test.ts b/tests/commands/uniprot.test.ts index 59ba4f7f..27b7f304 100644 --- a/tests/commands/uniprot.test.ts +++ b/tests/commands/uniprot.test.ts @@ -17,8 +17,8 @@ beforeEach(() => { }); test('test single argument', async () => { - const command = new Uniprot({ args: ["Q6GZX3"] }); - await command.run(); + const command = new Uniprot(); + await command.run(["Q6GZX3"]); expect(writeSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledTimes(0); @@ -26,8 +26,8 @@ test('test single argument', async () => { }); test('test two arguments', async () => { - const command = new Uniprot({ args: ["Q6GZX3", "Q6GZX4"] }); - await command.run(); + const command = new Uniprot(); + await command.run(["Q6GZX3", "Q6GZX4"]); expect(writeSpy).toHaveBeenCalledTimes(2); expect(errorSpy).toHaveBeenCalledTimes(0); @@ -35,8 +35,8 @@ test('test two arguments', async () => { }); test('test fasta output', async () => { - const command = new Uniprot({ args: ["--format", "fasta", "Q6GZX3", "Q6GZX4"] }); - await command.run(); + const command = new Uniprot(); + await command.run(["--format", "fasta", "Q6GZX3", "Q6GZX4"]); expect(writeSpy).toHaveBeenCalledTimes(2); expect(errorSpy).toHaveBeenCalledTimes(0); @@ -78,16 +78,16 @@ test('test double line stdin', async () => { }); test('test on invalid id', async () => { - const command = new Uniprot({ args: ["Bart"] }); - await command.run(); + const command = new Uniprot(); + await command.run(["Bart"]); expect(errorSpy).toHaveBeenCalledTimes(1); }); test('test all valid formats', async () => { for (const format of Uniprot.VALID_FORMATS) { - const command = new Uniprot({ args: ["--format", format, "Q6GZX3"] }); - await command.run(); + const command = new Uniprot(); + await command.run(["--format", format, "Q6GZX3"]); expect(errorSpy).toHaveBeenCalledTimes(0); } diff --git a/tests/formatters/csv_formatter.test.ts b/tests/formatters/csv_formatter.test.ts new file mode 100644 index 00000000..2b9a3b85 --- /dev/null +++ b/tests/formatters/csv_formatter.test.ts @@ -0,0 +1,30 @@ +import { FormatterFactory } from "../../lib/formatters/formatter_factory"; +import { TestObject } from "./test_object"; + +const formatter = FormatterFactory.getFormatter("csv"); + +test('test header', () => { + //const fasta = [["peptide", ">test"]]; + const object = [TestObject.testObject(), TestObject.testObject()]; + expect(formatter.header(object)).toBe(TestObject.asCsvHeader()); + //expect(formatter.header(object, fasta)).toBe(`fasta_header,${TestObject.asCsvHeader()}`); +}); + +test('test footer', () => { + expect(formatter.footer()).toBe(""); +}); + +test('test convert', () => { + const object = [TestObject.testObject(), TestObject.testObject()]; + const csv = [TestObject.asCsv(), TestObject.asCsv(), ""].join("\n"); + + expect(formatter.convert(object, true)).toBe(csv); + expect(formatter.convert(object, false)).toBe(csv); +}); + +test('test format with fasta', () => { + //const fasta = [['>test', '5']]; + //const object = [TestObject.testObject(), TestObject.testObject()]; + //const csv = [`>test,${TestObject.asCsv()}`, TestObject.asCsv(), ""].join("\n"); + //expect(formatter.format(object, fasta, false)).toBe(csv); +}); diff --git a/tests/formatters/formatter_factory.test.ts b/tests/formatters/formatter_factory.test.ts new file mode 100644 index 00000000..05933522 --- /dev/null +++ b/tests/formatters/formatter_factory.test.ts @@ -0,0 +1,11 @@ +import { FormatterFactory } from "../../lib/formatters/formatter_factory"; + +test('test if default formatter is csv', async () => { + const formatter = FormatterFactory.getFormatter("foo"); + expect(formatter.constructor.name).toBe("CSVFormatter"); +}); + +test('test if csv formatter is csv', async () => { + const formatter = FormatterFactory.getFormatter("csv"); + expect(formatter.constructor.name).toBe("CSVFormatter"); +}); diff --git a/tests/formatters/test_object.ts b/tests/formatters/test_object.ts new file mode 100644 index 00000000..097ffc57 --- /dev/null +++ b/tests/formatters/test_object.ts @@ -0,0 +1,21 @@ +export class TestObject { + static testObject() { + return { "integer": 5, "string": "string", "list": ["a", 2, false] }; + } + + static asJson() { + return '{"integer":5,"string":"string","list":["a",2,false]}'; + } + + static asXml() { + return '5stringa2false'; + } + + static asCsv() { + return '5,string,"[""a"",2,false]"'; + } + + static asCsvHeader() { + return "integer,string,list\n"; + } +} diff --git a/yarn.lock b/yarn.lock index bb0cd995..90d703b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1307,6 +1307,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +csv-stringify@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-6.5.0.tgz#7b1491893c917e018a97de9bf9604e23b88647c2" + integrity sha512-edlXFVKcUx7r8Vx5zQucsuMg4wb/xT6qyz+Sr1vnLrdXqlLD1+UKyWNyZ9zn6mUW1ewmGxrpVwAcChGF0HQ/2Q== + debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.5" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e"