first commit
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate src/clarizen-api-types.ts from clarizen-schema.json."""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCHEMA = ROOT / "src" / "generated" / "clarizen-schema.json"
|
||||
OUT = ROOT / "src" / "clarizen-api-types.ts"
|
||||
|
||||
ENUMS = {
|
||||
"Operator": [
|
||||
"Equal", "NotEqual", "LessThan", "GreaterThan", "LessThanOrEqual",
|
||||
"GreaterThanOrEqual", "BeginsWith", "EndsWith", "Contains", "DoesNotContain",
|
||||
"In", "Like", "InRange", "NotInRange", "NotIn",
|
||||
],
|
||||
"Order": ["Ascending", "Descending"],
|
||||
"DatePeriod": [
|
||||
"ThisWeek", "ThisMonth", "ThisQuarter", "ThisYear",
|
||||
"NextWeek", "NextMonth", "NextQuarter", "NextYear",
|
||||
"LastWeek", "LastMonth", "LastQuarter", "LastYear",
|
||||
],
|
||||
"FunctionType": ["Count", "Max", "Min", "Sum", "Avg"],
|
||||
"LicenseType": [
|
||||
"Full", "Limited", "Email", "None", "TeamMember", "Social", "ExternalCollaborator",
|
||||
],
|
||||
"NewsFeedMode": ["Following", "All"],
|
||||
"TimesheetState": ["UnSubmitted", "PendingApproval", "Approved", "All"],
|
||||
"ErrorCode": [
|
||||
"EntityNotFound", "InvalidArgument", "MissingArgument", "InvalidOperation",
|
||||
"DuplicateKey", "InvalidField", "InvalidType", "FileNotFound", "VirusCheckFailed",
|
||||
"Unauthorized", "UnsupportedClient", "General", "Internal", "ValidationRuleError",
|
||||
"LoginFailure", "SessionTimeout", "Redirect", "InvalidQuery",
|
||||
"ExecutionThresholdExceeded", "TooManyRequests",
|
||||
],
|
||||
"FieldType": [
|
||||
"Boolean", "String", "Integer", "Long", "Double", "DateTime", "Date",
|
||||
"Entity", "Duration", "Money",
|
||||
],
|
||||
"PresentationType": [
|
||||
"Text", "Numeric", "Date", "Time", "Checkbox", "TextArea", "Currency",
|
||||
"Duration", "ReferenceToObject", "PickList", "Url", "Percent", "Other",
|
||||
],
|
||||
"RecipientType": ["To", "CC"],
|
||||
"AccessType": ["Public", "Private"],
|
||||
"TriggerType": [
|
||||
"Create", "CreateOrEdit", "Delete", "CreateOrEditWithPreviousValueNotEqual",
|
||||
],
|
||||
"HttpMethod": ["DELETE", "GET", "POST", "PUT"],
|
||||
}
|
||||
|
||||
STUB_TYPES = {
|
||||
"ApplyOnMembersResult": "string",
|
||||
"IFormat": "Record<string, JsonValue>",
|
||||
"RoleType": "string",
|
||||
}
|
||||
|
||||
PRIMITIVE_MAP = {
|
||||
"string": "string",
|
||||
"boolean": "boolean",
|
||||
"integer": "number",
|
||||
"long": "number",
|
||||
"double": "number",
|
||||
"datetime": "string",
|
||||
"date": "string",
|
||||
"type": "Record<string, JsonValue>",
|
||||
"json object": "Record<string, JsonValue>",
|
||||
"object": "JsonValue",
|
||||
"entityid": "EntityId",
|
||||
}
|
||||
|
||||
|
||||
def pascal(name: str) -> str:
|
||||
if not name:
|
||||
return name
|
||||
if name[0].isupper():
|
||||
return name.replace(" ", "")
|
||||
return name[0].upper() + name[1:]
|
||||
|
||||
|
||||
ARRAY_PROP_NAMES = frozenset({
|
||||
"fields", "groupby", "orders", "relations", "aggregations",
|
||||
"feeditemoptions", "workitems", "validstates",
|
||||
})
|
||||
|
||||
|
||||
def ts_prop_type(prop: dict) -> str:
|
||||
pname = prop.get("name", "").rstrip("*").lower()
|
||||
href = prop.get("typeHref") or ""
|
||||
if href:
|
||||
tail = href.rstrip("/").split("/")[-1]
|
||||
tail = pascal(tail)
|
||||
if tail in ("ListOf_String", "Array_String"):
|
||||
return "string[]"
|
||||
if pname in ARRAY_PROP_NAMES or tail == "FieldAggregation":
|
||||
if tail == "FieldAggregation":
|
||||
return "FieldAggregation[]"
|
||||
if tail == "FieldDescription":
|
||||
return "FieldDescription[]"
|
||||
if tail == "RelationDescription":
|
||||
return "RelationDescription[]"
|
||||
if tail == "OrderBy":
|
||||
return "OrderBy[]"
|
||||
if tail == "Relation":
|
||||
return "Relation[]"
|
||||
return f"{tail}[]"
|
||||
return tail
|
||||
raw = prop.get("type", "JsonValue").lower()
|
||||
raw = re.sub(r"\(.*\)", "", raw).strip()
|
||||
if raw.endswith("[]"):
|
||||
inner = PRIMITIVE_MAP.get(raw[:-2], pascal(raw[:-2]))
|
||||
return f"{inner}[]"
|
||||
return PRIMITIVE_MAP.get(raw, "JsonValue")
|
||||
|
||||
|
||||
def emit_enum(name: str, values: list[str]) -> str:
|
||||
lines = [f"export type {name} ="]
|
||||
for v in values:
|
||||
lines.append(f' | "{v}"')
|
||||
lines[-1] = lines[-1] + ";"
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def prop_optional(prop_name: str) -> str:
|
||||
return "" if prop_name.endswith("*") else "?"
|
||||
|
||||
|
||||
def clean_prop_name(prop_name: str) -> str:
|
||||
return prop_name.rstrip("*")
|
||||
|
||||
|
||||
def emit_interface(name: str, variant: dict) -> str:
|
||||
props = variant.get("properties", [])
|
||||
if not props and "(enum)" in variant.get("variant", ""):
|
||||
return ""
|
||||
lines = [f"export interface {name} {{"]
|
||||
for p in props:
|
||||
pname = clean_prop_name(p["name"])
|
||||
lines.append(f" {pname}{prop_optional(p['name'])}: {ts_prop_type(p)};")
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
data = json.loads(SCHEMA.read_text())
|
||||
parsed = data["parsed"]
|
||||
|
||||
out: list[str] = [
|
||||
"// Auto-generated from Clarizen REST API V2.0 type docs. Run: npm run generate:types",
|
||||
'import type { JsonValue } from "./types.js";',
|
||||
"",
|
||||
"export type EntityId = string;",
|
||||
"",
|
||||
]
|
||||
|
||||
for enum_name, values in ENUMS.items():
|
||||
out.append(emit_enum(enum_name, values))
|
||||
out.append("")
|
||||
|
||||
# Condition union (manual structure from variants)
|
||||
out.append("export interface AndCondition { and: Condition[]; }")
|
||||
out.append("export interface OrCondition { or: Condition[]; }")
|
||||
out.append("export interface NotCondition { not: Condition; }")
|
||||
out.append("export interface CompareCondition {")
|
||||
out.append(" _type?: \"Compare\";")
|
||||
out.append(" leftExpression: Expression;")
|
||||
out.append(" operator: ComparisonOperator;")
|
||||
out.append(" rightExpression: Expression;")
|
||||
out.append("}")
|
||||
out.append("export interface CzqlCondition {")
|
||||
out.append(" text: string;")
|
||||
out.append(" parameters?: Parameters;")
|
||||
out.append("}")
|
||||
out.append(
|
||||
"export type Condition = AndCondition | OrCondition | NotCondition | CompareCondition | CzqlCondition;"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
# Expression union
|
||||
out.append("export interface FieldExpression { fieldName: string; }")
|
||||
out.append("export interface ConstantExpression { value: JsonValue; }")
|
||||
out.append("export interface ConstantListExpression { values: JsonValue[]; }")
|
||||
out.append("export interface QueryExpression { query: Query; }")
|
||||
out.append("export interface PredefinedDateRangeExpression {")
|
||||
out.append(" value?: DatePeriod;")
|
||||
out.append(" dateRangeValue?: DatePeriod;")
|
||||
out.append("}")
|
||||
out.append(
|
||||
"export type Expression = FieldExpression | ConstantExpression | ConstantListExpression | QueryExpression | PredefinedDateRangeExpression;"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
# Single-variant interfaces from schema
|
||||
skip = {
|
||||
"Condition", "Expression", "Operator", "Order", "Query",
|
||||
"ListOf_String", "Array_String", "EntityId",
|
||||
}
|
||||
for type_name, stub in STUB_TYPES.items():
|
||||
out.append(f"export type {type_name} = {stub};")
|
||||
out.append("")
|
||||
|
||||
for type_name in sorted(parsed.keys()):
|
||||
if type_name in skip or type_name in STUB_TYPES:
|
||||
continue
|
||||
info = parsed[type_name]
|
||||
if len(info["variants"]) != 1:
|
||||
continue
|
||||
v = info["variants"][0]
|
||||
if "(enum)" in v.get("variant", ""):
|
||||
continue
|
||||
iface = emit_interface(type_name, v)
|
||||
if iface:
|
||||
out.append(iface)
|
||||
out.append("")
|
||||
|
||||
# Query variants
|
||||
query_info = parsed.get("Query", {})
|
||||
query_names: list[str] = []
|
||||
for v in query_info.get("variants", []):
|
||||
variant_key = v["variant"]
|
||||
iface_name = pascal(variant_key)
|
||||
if iface_name == "CZQLQuery":
|
||||
iface_name = "CzqlQuery"
|
||||
props = v.get("properties", [])
|
||||
lines = [f"export interface {iface_name} {{"]
|
||||
for p in props:
|
||||
pname = clean_prop_name(p["name"])
|
||||
lines.append(f" {pname}{prop_optional(p['name'])}: {ts_prop_type(p)};")
|
||||
lines.append("}")
|
||||
out.append("\n".join(lines))
|
||||
out.append("")
|
||||
query_names.append(iface_name)
|
||||
|
||||
out.append(f"export type Query = {' | '.join(query_names)};")
|
||||
out.append("")
|
||||
|
||||
# Shared query body (entityQuery variant) for EntityQuery operation
|
||||
out.append("export type EntityQueryBody = EntityQuery;")
|
||||
out.append("")
|
||||
|
||||
out.append("export type ComparisonOperator = Operator;")
|
||||
out.append("export type SortOrder = Order;")
|
||||
out.append("export type ConditionParameters = Parameters;")
|
||||
|
||||
text = "\n".join(out) + "\n"
|
||||
OUT.write_text(text)
|
||||
print(f"Wrote {OUT} ({len(text)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Regenerates src/endpoints.ts from the live Clarizen API docs.
|
||||
* Requires CLARIZEN_API_KEY (or API_KEY_AW) in .env for Accept: text/html.
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const baseUrl = process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com";
|
||||
const apiKey = process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("Set CLARIZEN_API_KEY or API_KEY_AW");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const indexRes = await fetch(`${baseUrl}/V2.0/services/`, {
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
Accept: "text/html",
|
||||
},
|
||||
});
|
||||
|
||||
const html = await indexRes.text();
|
||||
const linkRe = /href="(\/V2\.0\/services\/[^"]+)"/g;
|
||||
const paths = [...new Set([...html.matchAll(linkRe)].map((m) => m[1]))].filter(
|
||||
(p) => !p.endsWith("/objects"),
|
||||
);
|
||||
|
||||
const endpoints: Array<{
|
||||
namespace: string;
|
||||
operation: string;
|
||||
path: string;
|
||||
httpMethods: string[];
|
||||
description: string;
|
||||
}> = [];
|
||||
|
||||
for (const docPath of paths) {
|
||||
const res = await fetch(`${baseUrl}${docPath}`, {
|
||||
headers: {
|
||||
Authorization: `ApiKey ${apiKey}`,
|
||||
Accept: "text/html",
|
||||
},
|
||||
});
|
||||
const page = await res.text();
|
||||
const parts = docPath.split("/");
|
||||
const namespace = parts[3] ?? "";
|
||||
const operation = parts[4] ?? "";
|
||||
const pathMatch = page.match(/<samp>([^<]+)<\/samp>/);
|
||||
const methodMatch = page.match(
|
||||
/<dt>HTTP Method<\/dt>\s*<dd><code>([^<]+)<\/code>/,
|
||||
);
|
||||
const descMatch = page.match(/<dt>Description<\/dt>\s*<dd>([^<]+)/);
|
||||
|
||||
endpoints.push({
|
||||
namespace,
|
||||
operation,
|
||||
path: pathMatch?.[1] ?? docPath,
|
||||
httpMethods: (methodMatch?.[1] ?? "POST")
|
||||
.split(",")
|
||||
.map((m) => m.trim().toUpperCase()),
|
||||
description: (descMatch?.[1] ?? "")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const namespaces = [...new Set(endpoints.map((e) => e.namespace))];
|
||||
|
||||
const out = `import type { EndpointDefinition } from "./types.js";
|
||||
|
||||
/** Auto-generated from ${baseUrl}/V2.0/services/ — run: npm run scrape */
|
||||
export const ENDPOINTS: EndpointDefinition[] = ${JSON.stringify(endpoints, null, 2)};
|
||||
|
||||
export const NAMESPACES = ${JSON.stringify(namespaces, null, 2)} as const;
|
||||
|
||||
export type ClarizenNamespace = (typeof NAMESPACES)[number];
|
||||
`;
|
||||
|
||||
const target = resolve(import.meta.dirname, "../src/endpoints.ts");
|
||||
writeFileSync(target, out);
|
||||
console.log(`Wrote ${endpoints.length} endpoints to ${target}`);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Fetches filtered org metadata and writes src/generated/metadata-catalog.json
|
||||
* and src/generated/metadata-types.ts.
|
||||
*
|
||||
* Requires CLARIZEN_API_KEY or CLARIZEN_USERNAME + CLARIZEN_PASSWORD in .env
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { Clarizen } from "../src/clarizen.js";
|
||||
import { generateMetadataTypesFile } from "../src/metadata/generate-types.js";
|
||||
|
||||
const baseUrl = process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com";
|
||||
const apiKey = process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
|
||||
const userName = process.env.CLARIZEN_USERNAME;
|
||||
const password = process.env.CLARIZEN_PASSWORD;
|
||||
|
||||
const outDir = resolve(import.meta.dirname, "../src/generated");
|
||||
const catalogPath = resolve(outDir, "metadata-catalog.json");
|
||||
const typesPath = resolve(outDir, "metadata-types.ts");
|
||||
|
||||
async function createClient(): Promise<Clarizen> {
|
||||
if (apiKey) {
|
||||
return new Clarizen({ baseUrl, apiToken: apiKey });
|
||||
}
|
||||
if (userName && password) {
|
||||
const clarizen = Clarizen.createForLogin(baseUrl);
|
||||
await clarizen.loginWithDiscovery({ userName, password });
|
||||
return clarizen;
|
||||
}
|
||||
console.error(
|
||||
"Set CLARIZEN_API_KEY or CLARIZEN_USERNAME + CLARIZEN_PASSWORD in .env",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clarizen = await createClient();
|
||||
|
||||
console.log("Fetching metadata catalog...");
|
||||
const catalog = await clarizen.metadata.fetchCatalog({
|
||||
onProgress: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
writeFileSync(typesPath, generateMetadataTypesFile(catalog));
|
||||
|
||||
console.log(
|
||||
`Wrote ${catalog.entities.length} entities to:\n ${catalogPath}\n ${typesPath}`,
|
||||
);
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover all Clarizen API types by crawling services + types docs."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
BASE = os.environ.get("CLARIZEN_BASE_URL", "https://api.clarizen.com")
|
||||
TOKEN = os.environ.get("CLARIZEN_API_KEY") or os.environ.get("API_KEY_AW")
|
||||
if not TOKEN:
|
||||
sys.exit("Set CLARIZEN_API_KEY or API_KEY_AW")
|
||||
|
||||
TYPE_LINK = re.compile(r'href="(/V2\.0/services/types/([^"]+))"', re.I)
|
||||
SERVICE_LINK = re.compile(r'href="(/V2\.0/services/(?!types)[^"]+)"', re.I)
|
||||
VARIANT_NAME = re.compile(r"<dt>Type</dt>\s*<dd>\s*<i>([^<]+)</i>", re.I)
|
||||
PROP_ROW = re.compile(
|
||||
r"<td><i><b>(.*?)</b></i></td>\s*<td>(?:<a href=\"([^\"]+)\">)?([^<]+)",
|
||||
re.I | re.S,
|
||||
)
|
||||
ENUM_ROW = re.compile(r"<tr><td>([^<]+)</td><td>[^<]*</td><td>", re.I)
|
||||
|
||||
|
||||
def fetch(path: str) -> str:
|
||||
url = urljoin(BASE, path)
|
||||
r = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-sL",
|
||||
"--max-time",
|
||||
"20",
|
||||
url,
|
||||
"-H",
|
||||
f"Authorization: ApiKey {TOKEN}",
|
||||
"-H",
|
||||
"Accept: text/html",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def normalize_type_name(href_tail: str) -> str:
|
||||
name = href_tail.split("/")[-1]
|
||||
if name.lower() in ("object", "string", "integer", "boolean", "json object"):
|
||||
return name
|
||||
return name[0].upper() + name[1:] if name else name
|
||||
|
||||
|
||||
def parse_type_page(html: str, type_name: str) -> dict:
|
||||
variants = []
|
||||
parts = re.split(r"<dt>Type</dt>", html, flags=re.I)
|
||||
for part in parts[1:]:
|
||||
name_m = re.search(r"<dd>\s*<i>([^<]+)</i>", part, re.I)
|
||||
if not name_m:
|
||||
continue
|
||||
variant = unescape(name_m.group(1)).strip()
|
||||
desc_m = re.search(
|
||||
r"<dt>Description</dt>\s*<dd>([^<]*)", part, re.I | re.S
|
||||
)
|
||||
description = unescape(desc_m.group(1)).strip() if desc_m else ""
|
||||
|
||||
properties = []
|
||||
prop_section = re.search(r"<dt>Properties</dt>(.*?)<dt>", part, re.I | re.S)
|
||||
if not prop_section:
|
||||
prop_section = re.search(r"<dt>Properties</dt>(.*)", part, re.I | re.S)
|
||||
if prop_section:
|
||||
block = prop_section.group(1)
|
||||
rows = PROP_ROW.findall(block)
|
||||
for pname, href, ptype in rows:
|
||||
clean_name = re.sub(r"<[^>]+>", "", pname)
|
||||
properties.append(
|
||||
{
|
||||
"name": unescape(clean_name).strip(),
|
||||
"typeHref": href or None,
|
||||
"type": unescape(re.sub(r"<[^>]+>", "", ptype)).strip(),
|
||||
}
|
||||
)
|
||||
|
||||
enum_values = []
|
||||
if re.search(r"<dt>Possible Values</dt>", part, re.I):
|
||||
ev_block = re.search(
|
||||
r"<dt>Possible Values</dt>(.*?)<dt>", part, re.I | re.S
|
||||
)
|
||||
if ev_block:
|
||||
enum_values = [
|
||||
unescape(m.group(1)).strip()
|
||||
for m in re.finditer(
|
||||
r"<tr><td>([^<]+)</td>", ev_block.group(1), re.I
|
||||
)
|
||||
]
|
||||
|
||||
variants.append(
|
||||
{
|
||||
"variant": variant,
|
||||
"description": description,
|
||||
"properties": properties,
|
||||
"enumValues": enum_values,
|
||||
}
|
||||
)
|
||||
|
||||
return {"name": type_name, "variants": variants}
|
||||
|
||||
|
||||
def main():
|
||||
seeds = ["/V2.0/services/"]
|
||||
# all operations from index
|
||||
index = fetch("/V2.0/services/")
|
||||
seeds.extend(m.group(1) for m in SERVICE_LINK.finditer(index))
|
||||
|
||||
discovered_types: set[str] = set()
|
||||
queue: list[str] = []
|
||||
parsed: dict[str, dict] = {}
|
||||
|
||||
def enqueue_type(href: str):
|
||||
tail = href.split("/types/")[-1]
|
||||
key = normalize_type_name(tail)
|
||||
if key.lower() in ("object", "string", "integer", "boolean", "json object"):
|
||||
return
|
||||
if key not in discovered_types:
|
||||
discovered_types.add(key)
|
||||
queue.append(key)
|
||||
|
||||
for seed in seeds:
|
||||
html = fetch(seed) if seed != "/V2.0/services/" else index
|
||||
if seed != "/V2.0/services/":
|
||||
html = fetch(seed)
|
||||
for m in TYPE_LINK.finditer(html):
|
||||
enqueue_type(m.group(1))
|
||||
|
||||
while queue:
|
||||
tname = queue.pop(0)
|
||||
if tname in parsed:
|
||||
continue
|
||||
path = f"/V2.0/services/types/{tname}"
|
||||
html = fetch(path)
|
||||
if "<dt>Type</dt>" not in html and "<dt>Possible Values</dt>" not in html:
|
||||
# try lowercase
|
||||
path = f"/V2.0/services/types/{tname[0].lower()}{tname[1:]}"
|
||||
html = fetch(path)
|
||||
info = parse_type_page(html, tname)
|
||||
parsed[tname] = info
|
||||
for v in info["variants"]:
|
||||
for p in v["properties"]:
|
||||
if p.get("typeHref"):
|
||||
enqueue_type(p["typeHref"])
|
||||
|
||||
out = {
|
||||
"typeCount": len(parsed),
|
||||
"types": sorted(parsed.keys()),
|
||||
"parsed": parsed,
|
||||
}
|
||||
out_path = ROOT / "src" / "generated" / "clarizen-schema.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path} ({len(parsed)} types)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user