From 7da17e8481a7e8cb2efb990de98720315be0aa2e Mon Sep 17 00:00:00 2001 From: Vitaly Kravtsov Date: Fri, 23 Aug 2024 18:40:45 +0600 Subject: [PATCH] feat: add python generation --- src/aidbox_sdk/converter.clj | 8 +- src/aidbox_sdk/generator/helpers.clj | 3 + src/aidbox_sdk/generator/python.clj | 156 ++++++++++++++++++++++ test/aidbox_sdk/generator/python_test.clj | 104 +++++++++++++++ 4 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 src/aidbox_sdk/generator/python.clj create mode 100644 test/aidbox_sdk/generator/python_test.clj diff --git a/src/aidbox_sdk/converter.clj b/src/aidbox_sdk/converter.clj index 3918477..509f602 100644 --- a/src/aidbox_sdk/converter.clj +++ b/src/aidbox_sdk/converter.clj @@ -93,7 +93,11 @@ :array (boolean (:array v)) :required (.contains required (name k)) :value (transform-element - (str (url->resource-name parent-name) "_" (uppercase-first-letter (name k))) v (.contains required (name k)))})) + (str (url->resource-name parent-name) "_" (uppercase-first-letter (name k))) + v + (.contains required (name k))) + + :original-type (:type v)})) (defn- get-typings-and-imports [parent-name required data] (reduce (fn [acc item] @@ -187,5 +191,5 @@ (compile-elements) (combine-elements) (map (fn [schema] (update schema - :backbone-elements #(resolve-choices (flatten-backbones % []))))) + :backbone-elements #(resolve-choices (flatten-backbones % []))))) (resolve-choices))) diff --git a/src/aidbox_sdk/generator/helpers.clj b/src/aidbox_sdk/generator/helpers.clj index 5b8edb6..943a0ab 100644 --- a/src/aidbox_sdk/generator/helpers.clj +++ b/src/aidbox_sdk/generator/helpers.clj @@ -15,6 +15,9 @@ (defn ->pascal-case [s] (str/join (map str/capitalize (words s)))) +(defn ->snake-case [s] + (str/join "_" (map str/lower-case (words s)))) + (defn uppercase-first-letter "NOTE: Do not confuse with `capitalize` and `->pascal-case` functions. Capitalize function lowercasing all letters after first. diff --git a/src/aidbox_sdk/generator/python.clj b/src/aidbox_sdk/generator/python.clj new file mode 100644 index 0000000..5e773d6 --- /dev/null +++ b/src/aidbox_sdk/generator/python.clj @@ -0,0 +1,156 @@ +(ns aidbox-sdk.generator.python + (:require + [aidbox-sdk.generator.helpers :refer [->pascal-case ->snake-case uppercase-first-letter]] + [aidbox-sdk.generator.utils :as u] + [clojure.java.io :as io] + [clojure.string :as str]) + (:import + [aidbox_sdk.generator CodeGenerator])) + +(defn fhir-type->lang-type [fhir-type] + (case fhir-type + ;; Primitive Types + "boolean" "bool" + "instant" "str" + "time" "str" + "date" "str" + "dateTime" "str" + "decimal" "str" + + "integer" "integer" + "unsignedInt" "integer" + "positiveInt" "integer" + + "integer64" "str" + "base64Binary" "str" + + "uri" "str" + "url" "str" + "canonical" "str" + "oid" "str" + "uuid" "str" + + "string" "str" + "code" "str" + "markdown" "str" + "id" "str" + + ;; else + fhir-type)) + +(defn url->resource-type [reference] + (last (str/split (str reference) #"/"))) + +(defn class-name + "Generate class name from schema url." + [url] + (uppercase-first-letter (url->resource-type url))) + +(defn generate-deps [deps] + (->> deps + (map (fn [{:keys [module members]}] + (if (seq members) + (str "from " module " import " (str/join ", " members)) + (str "import " module)))) + (str/join "\n"))) + +(defn package->directory + "Generate directory name from package name. + hl7.fhir.r4.core#4.0.1 -> hl7-fhir-r4-core" + [x] + (-> x + (str/replace #"[\.#]" "-"))) + +(defn resource-file-path [ir-schema] + (io/file (package->directory (:package ir-schema)) + (str (->pascal-case (:name ir-schema)) ".py"))) + +(defn generate-polymorphic-property [element] + "") + +(defn generate-property + "Generate class property from schema element." + [element] + (let [type (str + () + (fhir-type->lang-type + (:original-type element)) + (when (:array element) "[]") + (when (and (not (:required element)) + (not (:literal element))) "?")) + name (->snake-case (:name element))] + (if (contains? element :choices) + (generate-polymorphic-property element) + (str name ": " type (when-not (:required element) " = None"))))) + +(defn generate-class [schema & [inner-classes]] + (let [base-class (url->resource-type (:base schema)) + schema-name (or (:url schema) (:name schema)) + generic (when (= (:type schema) "Bundle") "") + class-name' (class-name (str schema-name generic)) + elements (->> (:elements schema) + (map #(if (and (= (:base %) "Bundle_Entry") + (= (:name %) "resource")) + (assoc % :value "T") + %))) + properties (->> elements + (map generate-property) + (map u/add-indent) + (str/join "\n")) + base-class (cond + (= base-class "DomainResource") "DomainResource, IResource" + :else base-class) + base-class-name (when-not (str/blank? base-class) + (uppercase-first-letter base-class))] + + (str "class " class-name' "(" base-class-name "):" + (when-not (str/blank? properties) + "\n") + properties + (when (and inner-classes + (seq inner-classes)) + "\n\n") + (str/join "\n\n" (map #(->> % str/split-lines (map u/add-indent) (str/join "\n")) inner-classes)) + "\n}"))) + +(defn generate-module + [& {:keys [deps classes] + :or {classes []}}] + (->> (conj [] + (generate-deps deps) + classes) + (flatten) + (str/join "\n"))) + +(defn generate-backbone-classes [ir-schema] + (->> (ir-schema :backbone-elements) + (map #(assoc % :base "BackboneElement")) + (map generate-class))) + +(defrecord PythonCodeGenerator [] + CodeGenerator + (generate-datatypes [_ ir-schemas] + (let [file-name "__init__.py" + file-path (io/file "base" file-name) + module (generate-module + :name "" + :deps [{:module "..base" :members []}] + :classes [(map (fn [ir-schema] + (generate-class ir-schema + (generate-backbone-classes ir-schema))) + ir-schemas)])] + + [:path file-path + :content module])) + + (generate-resource-module [_ ir-schema] + {:path (resource-file-path ir-schema) + :content (generate-module + :deps [{:module "..base" :members ["*"]}] + :classes [(generate-class ir-schema + (generate-backbone-classes ir-schema))])}) + (generate-search-params [_ search-schemas fhir-schemas]) + (generate-constraints [_ _schemas all-schemas] "") + (generate-sdk-files [this] "")) + +(def generator (->PythonCodeGenerator)) diff --git a/test/aidbox_sdk/generator/python_test.clj b/test/aidbox_sdk/generator/python_test.clj new file mode 100644 index 0000000..2f7dc59 --- /dev/null +++ b/test/aidbox_sdk/generator/python_test.clj @@ -0,0 +1,104 @@ +(ns aidbox-sdk.generator.python-test + (:require + [aidbox-sdk.fixtures.schemas :as fixtures] + [aidbox-sdk.generator :as sut] + [aidbox-sdk.generator.python :refer [generator] :as gen.python] + [clojure.java.io :as io] + [clojure.test :refer [deftest is testing]])) + +(deftest test-generate-deps + (testing "no members" + (is (= (gen.python/generate-deps [{:module "pydantic" :members []}]) + "import pydantic"))) + + (testing "with members" + (is (= (gen.python/generate-deps [{:module "pydantic" :members ["*"]}]) + "from pydantic import *"))) + + (testing "multiple deps" + (is (= (gen.python/generate-deps [{:module "pydantic" :members ["*"]} + {:module "typing" :members ["Optional" "List"]}]) + "from pydantic import *\nfrom typing import Optional, List")))) + +(deftest test-generate-property + (testing "simple case" + (is (= "active Optional[bool] = None" + (gen.python/generate-property {:name "active", + :base "Patient", + :array false, + :required false, + :value "bool"})))) + + (testing "required" + (is (= "type: str" + (gen.python/generate-property {:name "type", + :base "Patient_Link", + :array false, + :required true, + :value "string"})))) + + (testing "array optional" + (is (= "address: Optional[List[Address]] = None" + (gen.python/generate-property {:name "address", + :base "Patient", + :array true, + :required false, + :value "Address"})))) + + (testing "array required" + (is (= "extension: list[Extension] = []" + (gen.python/generate-property {:name "extension", + :base "Element", + :array true, + :required true, + :value "Extension"})))) + + (testing "element with literal" + ;; TODO + ) + + (testing "element with meta" + ;; TODO + ) + + (testing "element with choices" + ;; TODO + )) + +#_(deftest test-generate-class + + (testing "")) + +#_ +(deftest generate-datatypes + (is + (= (sut/generate-datatypes generator [fixtures/coding-ir-schema]) + + [{:path (io/file "base" "__init__.py"), + :content + "namespace Aidbox.FHIR.Base;\n\npublic class Coding : Element\n{\n public string? Code { get; set; }\n public string? System { get; set; }\n public string? Display { get; set; }\n public string? Version { get; set; }\n public bool? UserSelected { get; set; }\n}"}]))) + +(deftest test-generate-resources + (is + (= (sut/generate-resource-module generator fixtures/patient-ir-schema) + + {:path (io/file "hl7-fhir-r4-core/Patient.cs"), + :content + "using Aidbox.FHIR.Base;\nusing Aidbox.FHIR.Utils;\n\nnamespace Aidbox.FHIR.R4.Core;\n\npublic class Patient : DomainResource, IResource\n{\n public bool? MultipleBirthBoolean { get; set; }\n public Base.Address[]? Address { get; set; }\n public string? DeceasedDateTime { get; set; }\n public Base.ResourceReference? ManagingOrganization { get; set; }\n public bool? DeceasedBoolean { get; set; }\n public Base.HumanName[]? Name { get; set; }\n public string? BirthDate { get; set; }\n public int? MultipleBirthInteger { get; set; }\n public object? MultipleBirth \n {\n get\n {\n if (MultipleBirthBoolean is not null)\n {\n return MultipleBirthBoolean;\n }\n \n if (MultipleBirthInteger is not null)\n {\n return MultipleBirthInteger;\n }\n \n return null;\n }\n \n set\n {\n if (value?.GetType() == typeof(bool))\n {\n MultipleBirthBoolean = (bool)value;\n return;\n }\n \n if (value?.GetType() == typeof(int))\n {\n MultipleBirthInteger = (int)value;\n return;\n }\n \n throw new ArgumentException(\"Invalid type provided\");\n }\n }\n public object? Deceased \n {\n get\n {\n if (DeceasedDateTime is not null)\n {\n return DeceasedDateTime;\n }\n \n if (DeceasedBoolean is not null)\n {\n return DeceasedBoolean;\n }\n \n return null;\n }\n \n set\n {\n if (value?.GetType() == typeof(string))\n {\n DeceasedDateTime = (string)value;\n return;\n }\n \n if (value?.GetType() == typeof(bool))\n {\n DeceasedBoolean = (bool)value;\n return;\n }\n \n throw new ArgumentException(\"Invalid type provided\");\n }\n }\n public Base.Attachment[]? Photo { get; set; }\n public Patient_Link[]? Link { get; set; }\n public bool? Active { get; set; }\n public Patient_Communication[]? Communication { get; set; }\n public Base.Identifier[]? Identifier { get; set; }\n public Base.ContactPoint[]? Telecom { get; set; }\n public Base.ResourceReference[]? GeneralPractitioner { get; set; }\n public string? Gender { get; set; }\n public Base.CodeableConcept? MaritalStatus { get; set; }\n public Patient_Contact[]? Contact { get; set; }\n\n public class Patient_Link : BackboneElement\n {\n public required string Type { get; set; }\n public required Base.ResourceReference Other { get; set; }\n }\n\n public class Patient_Communication : BackboneElement\n {\n public required Base.CodeableConcept Language { get; set; }\n public bool? Preferred { get; set; }\n }\n\n public class Patient_Contact : BackboneElement\n {\n public Base.HumanName? Name { get; set; }\n public string? Gender { get; set; }\n public Base.Period? Period { get; set; }\n public Base.Address? Address { get; set; }\n public Base.ContactPoint[]? Telecom { get; set; }\n public Base.ResourceReference? Organization { get; set; }\n public Base.CodeableConcept[]? Relationship { get; set; }\n }\n}"}))) + +#_ +(deftest generate-search-params + (is + (= (sut/generate-search-params generator fixtures/patient-search-params-schemas + [fixtures/patient-fhir-schema]) + [{:path (io/file "search/PatientSearchParameters.cs"), + :content + "namespace Aidbox.FHIR.Search;\n\npublic class PatientSearchParameters : DomainResourceSearchParameters\n{\n public string? Active { get; set; }\n public string? Address { get; set; }\n public string? AddressCity { get; set; }\n public string? AddressCountry { get; set; }\n public string? AddressPostalcode { get; set; }\n public string? AddressState { get; set; }\n public string? AddressUse { get; set; }\n public string? Birthdate { get; set; }\n public string? DeathDate { get; set; }\n public string? Deceased { get; set; }\n public string? Email { get; set; }\n public string? Ethnicity { get; set; }\n public string? Family { get; set; }\n public string? Gender { get; set; }\n public string? GeneralPractitioner { get; set; }\n public string? Given { get; set; }\n public string? Id { get; set; }\n public string? Identifier { get; set; }\n public string? Language { get; set; }\n public string? Link { get; set; }\n public string? Name { get; set; }\n public string? Organization { get; set; }\n public string? PartAgree { get; set; }\n public string? Phone { get; set; }\n public string? Phonetic { get; set; }\n public string? Race { get; set; }\n public string? Telecom { get; set; }\n}"}]))) + +;; TODO +#_(deftest generate-constraints + (is + (= (sut/generate-constraints generator fixtures/some-sonstraint-ir-schema) + {:path (io/file "hl7-fhir-r4-core/Patient.cs"), + :content + "using Aidbox.FHIR.Base;\nusing Aidbox.FHIR.Utils;\n\nnamespace Aidbox.FHIR.R4.Core;\n\npublic class Patient : DomainResource, IResource\n{\n public bool? MultipleBirthBoolean { get; set; }\n public Base.Address[]? Address { get; set; }\n public string? DeceasedDateTime { get; set; }\n public Base.ResourceReference? ManagingOrganization { get; set; }\n public bool? DeceasedBoolean { get; set; }\n public Base.HumanName[]? Name { get; set; }\n public string? BirthDate { get; set; }\n public int? MultipleBirthInteger { get; set; }\n public object? MultipleBirth \n {\n get\n {\n if (MultipleBirthBoolean is not null)\n {\n return MultipleBirthBoolean;\n }\n \n if (MultipleBirthInteger is not null)\n {\n return MultipleBirthInteger;\n }\n \n return null;\n }\n \n set\n {\n if (value?.GetType() == typeof(bool))\n {\n MultipleBirthBoolean = (bool)value;return;\n }\n \n if (value?.GetType() == typeof(int))\n {\n MultipleBirthInteger = (int)value;return;\n }\n \n throw new ArgumentException(\"Invalid type provided\");\n }\n }\n public object? Deceased \n {\n get\n {\n if (DeceasedDateTime is not null)\n {\n return DeceasedDateTime;\n }\n \n if (DeceasedBoolean is not null)\n {\n return DeceasedBoolean;\n }\n \n return null;\n }\n \n set\n {\n if (value?.GetType() == typeof(string))\n {\n DeceasedDateTime = (string)value;return;\n }\n \n if (value?.GetType() == typeof(bool))\n {\n DeceasedBoolean = (bool)value;return;\n }\n \n throw new ArgumentException(\"Invalid type provided\");\n }\n }\n public Base.Attachment[]? Photo { get; set; }\n public Patient_Link[]? Link { get; set; }\n public bool? Active { get; set; }\n public Patient_Communication[]? Communication { get; set; }\n public Base.Identifier[]? Identifier { get; set; }\n public Base.ContactPoint[]? Telecom { get; set; }\n public Base.ResourceReference[]? GeneralPractitioner { get; set; }\n public string? Gender { get; set; }\n public Base.CodeableConcept? MaritalStatus { get; set; }\n public Patient_Contact[]? Contact { get; set; }\n\n public class Patient_Link : BackboneElement\n {\n public required string Type { get; set; }\n public required Base.ResourceReference Other { get; set; }\n }\n\n public class Patient_Communication : BackboneElement\n {\n public required Base.CodeableConcept Language { get; set; }\n public bool? Preferred { get; set; }\n }\n\n public class Patient_Contact : BackboneElement\n {\n public Base.HumanName? Name { get; set; }\n public string? Gender { get; set; }\n public Base.Period? Period { get; set; }\n public Base.Address? Address { get; set; }\n public Base.ContactPoint[]? Telecom { get; set; }\n public Base.ResourceReference? Organization { get; set; }\n public Base.CodeableConcept[]? Relationship { get; set; }\n }\n}"})))