Skip to content

Commit

Permalink
rework file system
Browse files Browse the repository at this point in the history
  • Loading branch information
fulpm committed Oct 1, 2024
1 parent eecbb8e commit 3e64086
Show file tree
Hide file tree
Showing 24 changed files with 232 additions and 63 deletions.
4 changes: 2 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ This major change refactors the client and types for all Howso Engine operations

### Client Changes

- `BaseClient` has been renamed to `AbstractHowsoClient`.
- A new intermediary autogenerated abstract client `TraineeClient` has been introduced.
- `BaseClient` has been renamed to `AbstractBaseClient`.
- A new intermediary autogenerated abstract client `AbstractTraineeClient` has been introduced.
- `WasmClient` has been renamed to `HowsoWorkerClient`.
- `ProblemError` has been renamed to `HowsoError`.
- `ValidationError` has been renamed to `HowsoValidationError`.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,12 @@ You can then create the worker client using a url import:
```ts
import howsoUrl from "@howso/engine/lib/howso.caml?url";
import migrationsUrl from "@howso/engine/lib/migrations.caml?url";
import { type ClientOptions, HowsoWorkerClient } from "@howso/engine";
import { type ClientOptions, HowsoWorkerClient, BrowserFileSystem } from "@howso/engine";

const getClient = async (options?: ClientOptions): Promise<HowsoWorkerClient> => {
const worker = new Worker(new URL("@/workers/AmalgamWorker", import.meta.url), { type: "module" });
const client = new HowsoWorkerClient(worker, {
const fs = new BrowserFileSystem(worker);
const client = new HowsoWorkerClient(worker, fs, {
howsoUrl,
migrationsUrl,
...options,
Expand Down
1 change: 1 addition & 0 deletions codegen/filters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function registerFilters(env: Environment) {
env.addFilter(strings.pascalCase.name, strings.pascalCase);
env.addFilter(strings.camelCase.name, strings.camelCase);
env.addFilter(strings.toJson.name, strings.toJson);
env.addFilter(strings.autoQuote.name, strings.autoQuote);
env.addFilter(types.isString.name, types.isString);
env.addFilter(types.isArray.name, types.isArray);
env.addFilter(types.convertType.name, types.convertType);
Expand Down
9 changes: 9 additions & 0 deletions codegen/filters/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ export function camelCase(value: any) {
export function toJson(value: any) {
return JSON.stringify(value);
}

export function autoQuote(value: any) {
// Quote values for safe use as object keys
const str = typeof value === "string" ? value : String(value);
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str)) {
return str;
}
return this.env.filters.safe(JSON.stringify(str));
}
2 changes: 1 addition & 1 deletion codegen/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class Generator {
targetLabels[label] = definition;
}
}
this.renderFile(this.clientDir, "trainee.ts", "client/trainee.njk", {
this.renderFile(this.clientDir, "AbstractTraineeClient.ts", "client/AbstractTraineeClient.njk", {
labels: targetLabels,
shims: this.responseShims,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import type { ClientResponse, Session, Trainee } from "../types";
import type * as schemas from "../types/schemas";
import type * as shims from "../types/shims";
import { AbstractHowsoClient } from "./base";
import { AbstractBaseClient } from "./AbstractBaseClient";

export abstract class TraineeClient extends AbstractHowsoClient {
export abstract class AbstractTraineeClient extends AbstractBaseClient {
/** Create a new Trainee. */
public abstract createTrainee(trainee: Omit<Trainee, "id">): Promise<Trainee>;

Expand Down
2 changes: 1 addition & 1 deletion codegen/templates/schemas/label.njk
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
export type {{ name }}Request = {
{%- for key, schema in parameters | dictsort -%}
{{ comment(schema) | indent(2) }}
{{ key }}{% if schema.required != true %}?{% endif %}: {{ field(schema) }};
{{ key | autoQuote }}{% if schema.required != true %}?{% endif %}: {{ field(schema) }};
{%- if not loop.last %}
{% endif %}
{%- endfor %}
Expand Down
7 changes: 6 additions & 1 deletion codegen/templates/schemas/types/_assoc.njk
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
{
{%- for key, value in schema.indices | dictsort -%}
{{ comment(value) | indent(2) }}
{{ key }}{% if not value | isSchemaOrRef or value.required != true %}?{% endif %}: {{ field(value) | indent(2) }};
{{ key | autoQuote }}{% if not value | isSchemaOrRef or value.required != true %}?{% endif %}: {{ field(value) | indent(2) }};
{%- endfor %}
}
{%- if schema.additional_indices %}
& Record<string, {{ field(schema.additional_indices) }}>
{%- endif %}
{%- if schema.dynamic_indices %}
{%- for dynamic_key, dynamic_value in schema.dynamic_indices | dictsort %}
& Record<`{{ dynamic_key }}`, {{ field(dynamic_value) }}>
{%- endfor %}
{%- endif %}
{%- elif schema.additional_indices -%}
Record<string, {{ field(schema.additional_indices) }}>
{%- else -%}
Expand Down
2 changes: 1 addition & 1 deletion src/client/base.ts → src/client/AbstractBaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type ExecuteResponse<R = unknown> = {
warnings: string[];
};

export abstract class AbstractHowsoClient {
export abstract class AbstractBaseClient {
protected abstract cache: CacheMap<ClientCache>;

/**
Expand Down
4 changes: 2 additions & 2 deletions src/client/trainee.ts → src/client/AbstractTraineeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import type { ClientResponse, Session, Trainee } from "../types";
import type * as schemas from "../types/schemas";
import type * as shims from "../types/shims";
import { AbstractHowsoClient } from "./base";
import { AbstractBaseClient } from "./AbstractBaseClient";

export abstract class TraineeClient extends AbstractHowsoClient {
export abstract class AbstractTraineeClient extends AbstractBaseClient {
/** Create a new Trainee. */
public abstract createTrainee(trainee: Omit<Trainee, "id">): Promise<Trainee>;

Expand Down
38 changes: 38 additions & 0 deletions src/client/worker/HowsoWorkerClient.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @jest-environment node
*/

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { Worker } from "node:worker_threads";
import { inferFeatureAttributes } from "../../features";
import { HowsoWorkerClient } from "./HowsoWorkerClient";
import { NodeFileSystem } from "./filesystem";

global.structuredClone = (val) => JSON.parse(JSON.stringify(val));
describe("Node HowsoWorkerClient", () => {
let worker: Worker;
let client: HowsoWorkerClient;
beforeAll(async () => {
worker = new Worker(resolve(__dirname, "../../tests/assets/NodeWorker.js"));
const fs = new NodeFileSystem(worker);
client = new HowsoWorkerClient(worker, fs, {
trace: false,
howsoUrl: resolve(__dirname, "../../engine/howso.caml"),
});
await client.setup();
const dataPath = resolve(__dirname, "../../tests/assets/iris.json");
const data = JSON.parse(readFileSync(dataPath, { encoding: "utf8" }));
const feature_attributes = await inferFeatureAttributes(data, "array", {});
const trainee = await client.createTrainee({ name: "My Trainee" });
await client.setFeatureAttributes(trainee.id, { feature_attributes });
});
afterAll(() => {
worker?.terminate();
});
it("should queryTrainees", async () => {
const trainees = await client.queryTrainees();
expect(trainees.length).toBe(1);
expect(trainees[0].name).toBe("My Trainee");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
type AmalgamRequest,
type AmalgamResponseBody,
} from "@howso/amalgam-lang";
import type { Worker as NodeWorker } from "node:worker_threads";
import { v4 as uuid } from "uuid";
import type { FeatureAttributesIndex, Session, Trainee, TrainResponse } from "../../types";
import type * as schemas from "../../types/schemas";
import { ClientCache, ExecuteResponse } from "../base";
import { ClientCache, ExecuteResponse } from "../AbstractBaseClient";
import { AbstractTraineeClient } from "../AbstractTraineeClient";
import { HowsoError, RequiredError } from "../errors";
import { TraineeClient } from "../trainee";
import { batcher, BatchOptions, CacheMap } from "../utilities";
import { FileSystemClient } from "./files";
import { AbstractFileSystem } from "./filesystem";

export interface ClientOptions {
trace?: boolean;
Expand All @@ -22,13 +23,13 @@ export interface ClientOptions {
migrationsUrl?: string | URL;
}

export class HowsoWorkerClient extends TraineeClient {
public readonly fs: FileSystemClient;
export class HowsoWorkerClient extends AbstractTraineeClient {
protected activeSession?: Session;
protected cache: CacheMap<Required<ClientCache>>;

constructor(
protected readonly worker: Worker,
protected readonly worker: Worker | NodeWorker,
public readonly fs: AbstractFileSystem<Worker | NodeWorker>,
protected readonly options: ClientOptions,
) {
super();
Expand All @@ -39,7 +40,6 @@ export class HowsoWorkerClient extends TraineeClient {
throw new RequiredError("options", "Client options are required.");
}
this.cache = new CacheMap();
this.fs = new FileSystemClient(this.worker);
}

/**
Expand Down Expand Up @@ -102,7 +102,10 @@ export class HowsoWorkerClient extends TraineeClient {
reject();
}
};
this.worker.postMessage(request, [channel.port2]);
this.worker.postMessage(request, [
// @ts-expect-error The port will match browser/nodejs depending on the context
channel.port2,
]);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,13 @@ import type {
FileSystemResponseBody,
IFileSystem,
} from "@howso/amalgam-lang";
import { isNode } from "../utilities";
import type { Worker as NodeWorker } from "node:worker_threads";

export class FileSystemClient implements IFileSystem {
protected readonly baseDir: string;
export abstract class AbstractFileSystem<T extends Worker | NodeWorker> implements IFileSystem {
protected abstract readonly baseDir: string;
protected abstract readonly worker: T;
public readonly entityExt = "caml";

constructor(
private readonly worker: Worker,
baseDir?: string,
) {
this.baseDir = baseDir ?? "/app/";
}

public get libDir(): string {
return this.baseDir;
}
Expand All @@ -30,6 +24,8 @@ export class FileSystemClient implements IFileSystem {
return this.join(this.libDir, "migrations", "/");
}

public abstract prepareFile(parent: string, name: string, url: string): Promise<void>;

public join(...parts: string[]): string {
const segments = [];
if (parts?.[0]?.startsWith("/")) {
Expand Down Expand Up @@ -59,20 +55,13 @@ export class FileSystemClient implements IFileSystem {
reject(ev.data?.error);
}
};
this.worker.postMessage(request, [channel.port2]);
this.worker.postMessage(request, [
// @ts-expect-error The port will match browser/nodejs depending on the context
channel.port2,
]);
});
}

public async prepareFile(parent: string, name: string, url: string): Promise<void> {
if (isNode) {
const { readFile } = await import("node:fs/promises");
const data = await readFile(url);
this.writeFile(this.join(parent, name), data);
} else {
this.createLazyFile(parent, name, url);
}
}

public async analyzePath(path: string, dontResolveLastLink?: boolean): Promise<FS.Analyze> {
return await this.dispatch({
type: "request",
Expand Down
16 changes: 16 additions & 0 deletions src/client/worker/filesystem/BrowserFileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AbstractFileSystem } from "./AbstractFileSystem";

export class BrowserFileSystem extends AbstractFileSystem<Worker> {
protected readonly baseDir: string;
protected readonly worker: Worker;

constructor(worker: Worker, baseDir?: string) {
super();
this.worker = worker;
this.baseDir = baseDir ?? "/app/";
}

public async prepareFile(parent: string, name: string, url: string): Promise<void> {
return await this.createLazyFile(parent, name, url);
}
}
23 changes: 23 additions & 0 deletions src/client/worker/filesystem/NodeFileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Worker } from "node:worker_threads";
import { isNode } from "../../utilities";
import { AbstractFileSystem } from "./AbstractFileSystem";

export class NodeFileSystem extends AbstractFileSystem<Worker> {
protected readonly baseDir: string;
protected readonly worker: Worker;

constructor(worker: Worker, baseDir?: string) {
super();
if (!isNode) {
throw new Error("NodeFileSystem is only valid in Node contexts.");
}
this.worker = worker;
this.baseDir = baseDir ?? "/app/";
}

public async prepareFile(parent: string, name: string, url: string): Promise<void> {
const { readFile } = await import("node:fs/promises");
const data = await readFile(url);
this.writeFile(this.join(parent, name), data);
}
}
3 changes: 3 additions & 0 deletions src/client/worker/filesystem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./AbstractFileSystem";
export * from "./BrowserFileSystem";
export * from "./NodeFileSystem";
4 changes: 2 additions & 2 deletions src/client/worker/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./client";
export * from "./files";
export * from "./filesystem";
export * from "./HowsoWorkerClient";
Binary file modified src/engine/howso.caml
Binary file not shown.
25 changes: 25 additions & 0 deletions src/tests/assets/NodeWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AmalgamWasmService, initRuntime } from "@howso/amalgam-lang";
import { createRequire } from "node:module";
import { parentPort } from "node:worker_threads";
const require = createRequire(import.meta.url);
const wasmDataUri = require.resolve("@howso/amalgam-lang/lib/amalgam-st.data");
const wasmUri = require.resolve("@howso/amalgam-lang/lib/amalgam-st.wasm");

(async function () {
const svc = new AmalgamWasmService((options) => {
return initRuntime(options, {
locateFile: (path) => {
if (path.endsWith("amalgam-st.wasm")) {
return wasmUri;
} else if (path.endsWith("amalgam-st.data")) {
return wasmDataUri;
}
return self.location.href + path;
},
});
});
parentPort.onmessage = async (ev) => {
svc.dispatch(ev);
};
parentPort.postMessage({ type: "event", event: "ready" });
})();
1 change: 1 addition & 0 deletions src/tests/assets/iris.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"columns":["sepal_length","sepal_width","petal_length","petal_width","class"],"data":[[5.1,3.5,1.4,0.2,"Iris-setosa"],[4.9,3.0,1.4,0.2,"Iris-setosa"],[4.7,3.2,1.3,0.2,"Iris-setosa"],[4.6,3.1,1.5,0.2,"Iris-setosa"],[5.0,3.6,1.4,0.2,"Iris-setosa"],[5.4,3.9,1.7,0.4,"Iris-setosa"],[4.6,3.4,1.4,0.3,"Iris-setosa"],[5.0,3.4,1.5,0.2,"Iris-setosa"],[4.4,2.9,1.4,0.2,"Iris-setosa"],[4.9,3.1,1.5,0.1,"Iris-setosa"],[5.4,3.7,1.5,0.2,"Iris-setosa"],[4.8,3.4,1.6,0.2,"Iris-setosa"],[4.8,3.0,1.4,0.1,"Iris-setosa"],[4.3,3.0,1.1,0.1,"Iris-setosa"],[5.8,4.0,1.2,0.2,"Iris-setosa"],[5.7,4.4,1.5,0.4,"Iris-setosa"],[5.4,3.9,1.3,0.4,"Iris-setosa"],[5.1,3.5,1.4,0.3,"Iris-setosa"],[5.7,3.8,1.7,0.3,"Iris-setosa"],[5.1,3.8,1.5,0.3,"Iris-setosa"],[5.4,3.4,1.7,0.2,"Iris-setosa"],[5.1,3.7,1.5,0.4,"Iris-setosa"],[4.6,3.6,1.0,0.2,"Iris-setosa"],[5.1,3.3,1.7,0.5,"Iris-setosa"],[4.8,3.4,1.9,0.2,"Iris-setosa"],[5.0,3.0,1.6,0.2,"Iris-setosa"],[5.0,3.4,1.6,0.4,"Iris-setosa"],[5.2,3.5,1.5,0.2,"Iris-setosa"],[5.2,3.4,1.4,0.2,"Iris-setosa"],[4.7,3.2,1.6,0.2,"Iris-setosa"],[4.8,3.1,1.6,0.2,"Iris-setosa"],[5.4,3.4,1.5,0.4,"Iris-setosa"],[5.2,4.1,1.5,0.1,"Iris-setosa"],[5.5,4.2,1.4,0.2,"Iris-setosa"],[4.9,3.1,1.5,0.1,"Iris-setosa"],[5.0,3.2,1.2,0.2,"Iris-setosa"],[5.5,3.5,1.3,0.2,"Iris-setosa"],[4.9,3.1,1.5,0.1,"Iris-setosa"],[4.4,3.0,1.3,0.2,"Iris-setosa"],[5.1,3.4,1.5,0.2,"Iris-setosa"],[5.0,3.5,1.3,0.3,"Iris-setosa"],[4.5,2.3,1.3,0.3,"Iris-setosa"],[4.4,3.2,1.3,0.2,"Iris-setosa"],[5.0,3.5,1.6,0.6,"Iris-setosa"],[5.1,3.8,1.9,0.4,"Iris-setosa"],[4.8,3.0,1.4,0.3,"Iris-setosa"],[5.1,3.8,1.6,0.2,"Iris-setosa"],[4.6,3.2,1.4,0.2,"Iris-setosa"],[5.3,3.7,1.5,0.2,"Iris-setosa"],[5.0,3.3,1.4,0.2,"Iris-setosa"],[7.0,3.2,4.7,1.4,"Iris-versicolor"],[6.4,3.2,4.5,1.5,"Iris-versicolor"],[6.9,3.1,4.9,1.5,"Iris-versicolor"],[5.5,2.3,4.0,1.3,"Iris-versicolor"],[6.5,2.8,4.6,1.5,"Iris-versicolor"],[5.7,2.8,4.5,1.3,"Iris-versicolor"],[6.3,3.3,4.7,1.6,"Iris-versicolor"],[4.9,2.4,3.3,1.0,"Iris-versicolor"],[6.6,2.9,4.6,1.3,"Iris-versicolor"],[5.2,2.7,3.9,1.4,"Iris-versicolor"],[5.0,2.0,3.5,1.0,"Iris-versicolor"],[5.9,3.0,4.2,1.5,"Iris-versicolor"],[6.0,2.2,4.0,1.0,"Iris-versicolor"],[6.1,2.9,4.7,1.4,"Iris-versicolor"],[5.6,2.9,3.6,1.3,"Iris-versicolor"],[6.7,3.1,4.4,1.4,"Iris-versicolor"],[5.6,3.0,4.5,1.5,"Iris-versicolor"],[5.8,2.7,4.1,1.0,"Iris-versicolor"],[6.2,2.2,4.5,1.5,"Iris-versicolor"],[5.6,2.5,3.9,1.1,"Iris-versicolor"],[5.9,3.2,4.8,1.8,"Iris-versicolor"],[6.1,2.8,4.0,1.3,"Iris-versicolor"],[6.3,2.5,4.9,1.5,"Iris-versicolor"],[6.1,2.8,4.7,1.2,"Iris-versicolor"],[6.4,2.9,4.3,1.3,"Iris-versicolor"],[6.6,3.0,4.4,1.4,"Iris-versicolor"],[6.8,2.8,4.8,1.4,"Iris-versicolor"],[6.7,3.0,5.0,1.7,"Iris-versicolor"],[6.0,2.9,4.5,1.5,"Iris-versicolor"],[5.7,2.6,3.5,1.0,"Iris-versicolor"],[5.5,2.4,3.8,1.1,"Iris-versicolor"],[5.5,2.4,3.7,1.0,"Iris-versicolor"],[5.8,2.7,3.9,1.2,"Iris-versicolor"],[6.0,2.7,5.1,1.6,"Iris-versicolor"],[5.4,3.0,4.5,1.5,"Iris-versicolor"],[6.0,3.4,4.5,1.6,"Iris-versicolor"],[6.7,3.1,4.7,1.5,"Iris-versicolor"],[6.3,2.3,4.4,1.3,"Iris-versicolor"],[5.6,3.0,4.1,1.3,"Iris-versicolor"],[5.5,2.5,4.0,1.3,"Iris-versicolor"],[5.5,2.6,4.4,1.2,"Iris-versicolor"],[6.1,3.0,4.6,1.4,"Iris-versicolor"],[5.8,2.6,4.0,1.2,"Iris-versicolor"],[5.0,2.3,3.3,1.0,"Iris-versicolor"],[5.6,2.7,4.2,1.3,"Iris-versicolor"],[5.7,3.0,4.2,1.2,"Iris-versicolor"],[5.7,2.9,4.2,1.3,"Iris-versicolor"],[6.2,2.9,4.3,1.3,"Iris-versicolor"],[5.1,2.5,3.0,1.1,"Iris-versicolor"],[5.7,2.8,4.1,1.3,"Iris-versicolor"],[6.3,3.3,6.0,2.5,"Iris-virginica"],[5.8,2.7,5.1,1.9,"Iris-virginica"],[7.1,3.0,5.9,2.1,"Iris-virginica"],[6.3,2.9,5.6,1.8,"Iris-virginica"],[6.5,3.0,5.8,2.2,"Iris-virginica"],[7.6,3.0,6.6,2.1,"Iris-virginica"],[4.9,2.5,4.5,1.7,"Iris-virginica"],[7.3,2.9,6.3,1.8,"Iris-virginica"],[6.7,2.5,5.8,1.8,"Iris-virginica"],[7.2,3.6,6.1,2.5,"Iris-virginica"],[6.5,3.2,5.1,2.0,"Iris-virginica"],[6.4,2.7,5.3,1.9,"Iris-virginica"],[6.8,3.0,5.5,2.1,"Iris-virginica"],[5.7,2.5,5.0,2.0,"Iris-virginica"],[5.8,2.8,5.1,2.4,"Iris-virginica"],[6.4,3.2,5.3,2.3,"Iris-virginica"],[6.5,3.0,5.5,1.8,"Iris-virginica"],[7.7,3.8,6.7,2.2,"Iris-virginica"],[7.7,2.6,6.9,2.3,"Iris-virginica"],[6.0,2.2,5.0,1.5,"Iris-virginica"],[6.9,3.2,5.7,2.3,"Iris-virginica"],[5.6,2.8,4.9,2.0,"Iris-virginica"],[7.7,2.8,6.7,2.0,"Iris-virginica"],[6.3,2.7,4.9,1.8,"Iris-virginica"],[6.7,3.3,5.7,2.1,"Iris-virginica"],[7.2,3.2,6.0,1.8,"Iris-virginica"],[6.2,2.8,4.8,1.8,"Iris-virginica"],[6.1,3.0,4.9,1.8,"Iris-virginica"],[6.4,2.8,5.6,2.1,"Iris-virginica"],[7.2,3.0,5.8,1.6,"Iris-virginica"],[7.4,2.8,6.1,1.9,"Iris-virginica"],[7.9,3.8,6.4,2.0,"Iris-virginica"],[6.4,2.8,5.6,2.2,"Iris-virginica"],[6.3,2.8,5.1,1.5,"Iris-virginica"],[6.1,2.6,5.6,1.4,"Iris-virginica"],[7.7,3.0,6.1,2.3,"Iris-virginica"],[6.3,3.4,5.6,2.4,"Iris-virginica"],[6.4,3.1,5.5,1.8,"Iris-virginica"],[6.0,3.0,4.8,1.8,"Iris-virginica"],[6.9,3.1,5.4,2.1,"Iris-virginica"],[6.7,3.1,5.6,2.4,"Iris-virginica"],[6.9,3.1,5.1,2.3,"Iris-virginica"],[5.8,2.7,5.1,1.9,"Iris-virginica"],[6.8,3.2,5.9,2.3,"Iris-virginica"],[6.7,3.3,5.7,2.5,"Iris-virginica"],[6.7,3.0,5.2,2.3,"Iris-virginica"],[6.3,2.5,5.0,1.9,"Iris-virginica"],[6.5,3.0,5.2,2.0,"Iris-virginica"],[6.2,3.4,5.4,2.3,"Iris-virginica"],[5.9,3.0,5.1,1.8,"Iris-virginica"]]}
18 changes: 0 additions & 18 deletions src/types/features.ts

This file was deleted.

Loading

0 comments on commit 3e64086

Please sign in to comment.