geniespec 0.1.1.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: geniespec
3
+ Version: 0.1.1.dev2
4
+ Summary: Generate API code from an api.yaml spec
5
+ License-Expression: MIT
6
+ Requires-Dist: jinja2>=3.1.6
7
+ Requires-Dist: pyyaml>=6.0.3
8
+ Requires-Dist: jsonschema>=4.26.0
9
+ Requires-Dist: pydantic>=2.13.5
10
+ Requires-Dist: kiba-core>=0.5.3.dev81,<0.5.4
11
+ Requires-Python: >=3.13, <4
12
+ Project-URL: repository, https://github.com/kibalabs/genie
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Genie
16
+
17
+ Genie (`geniespec` on PyPI) generates API code from an `api.yaml` spec. The output is a package you install, not code you commit.
18
+
19
+ ```
20
+ uvx geniespec build --spec api.yaml [--output .genie]
21
+ uvx geniespec watch --spec api.yaml [--output .genie]
22
+ ```
23
+
24
+ - `build` writes `<output>/python`: a project with `pyproject.toml` and `src/<service>_api/v<major>/` (`api`, `endpoints`, `resources`, `internal`, `exceptions`). Only changed files are rewritten and files it no longer generates are removed.
25
+ - `watch` rebuilds whenever the spec changes. If the spec is invalid it logs the error and keeps the previous output.
26
+ - Consumers depend on the output as an editable path dependency, e.g. `my-service-api = { path = ".genie/python", editable = true }` in `[tool.uv.sources]`, and import it as `my_service_api.v1`.
27
+ - Generated code needs `kiba-core` (`core.api`, `core.util.typing_util`), `pydantic` and `starlette` from the consumer.
28
+
29
+ # Development
30
+
31
+ ```
32
+ make install
33
+ make lint-check type-check test
34
+ ```
35
+
36
+ Merges to `main` publish a dev version to PyPI. To release, bump `version` in `pyproject.toml` in a PR, then run the Release workflow on `main` (Actions → Release → Run workflow). It tags `vX.Y.Z` and the Publish workflow publishes it. Only the release workflow can push version tags.
37
+
38
+ # Things that need work
39
+
40
+ ## Typing of params and fields in general
41
+
42
+ Enum and OneOf not supported yet
43
+
44
+ I think we need to create an object for Type. what would it look like?
45
+
46
+
47
+ ```
48
+ class BaseFieldType:
49
+ isNullable: bool
50
+
51
+
52
+ class RawFieldType(BaseFieldType):
53
+ name: str
54
+
55
+
56
+ class ClassFieldType(BaseFieldType):
57
+ fieldType: RawFieldType
58
+
59
+
60
+ class ListFieldType:(BaseFieldType)
61
+ fieldType: RawFieldType
62
+ isNullable: bool
63
+
64
+
65
+ class MappingFieldType(BaseFieldType):
66
+ keyFieldType: RawFieldType
67
+ valueFieldType: RawFieldType
68
+
69
+
70
+ class OneOfFieldType(BaseFieldType):
71
+ fieldTypes: list[RawFieldType]
72
+
73
+
74
+ class EnumFieldType(BaseFieldType):
75
+ values: list[str]
76
+
77
+ ```
@@ -0,0 +1,63 @@
1
+ # Genie
2
+
3
+ Genie (`geniespec` on PyPI) generates API code from an `api.yaml` spec. The output is a package you install, not code you commit.
4
+
5
+ ```
6
+ uvx geniespec build --spec api.yaml [--output .genie]
7
+ uvx geniespec watch --spec api.yaml [--output .genie]
8
+ ```
9
+
10
+ - `build` writes `<output>/python`: a project with `pyproject.toml` and `src/<service>_api/v<major>/` (`api`, `endpoints`, `resources`, `internal`, `exceptions`). Only changed files are rewritten and files it no longer generates are removed.
11
+ - `watch` rebuilds whenever the spec changes. If the spec is invalid it logs the error and keeps the previous output.
12
+ - Consumers depend on the output as an editable path dependency, e.g. `my-service-api = { path = ".genie/python", editable = true }` in `[tool.uv.sources]`, and import it as `my_service_api.v1`.
13
+ - Generated code needs `kiba-core` (`core.api`, `core.util.typing_util`), `pydantic` and `starlette` from the consumer.
14
+
15
+ # Development
16
+
17
+ ```
18
+ make install
19
+ make lint-check type-check test
20
+ ```
21
+
22
+ Merges to `main` publish a dev version to PyPI. To release, bump `version` in `pyproject.toml` in a PR, then run the Release workflow on `main` (Actions → Release → Run workflow). It tags `vX.Y.Z` and the Publish workflow publishes it. Only the release workflow can push version tags.
23
+
24
+ # Things that need work
25
+
26
+ ## Typing of params and fields in general
27
+
28
+ Enum and OneOf not supported yet
29
+
30
+ I think we need to create an object for Type. what would it look like?
31
+
32
+
33
+ ```
34
+ class BaseFieldType:
35
+ isNullable: bool
36
+
37
+
38
+ class RawFieldType(BaseFieldType):
39
+ name: str
40
+
41
+
42
+ class ClassFieldType(BaseFieldType):
43
+ fieldType: RawFieldType
44
+
45
+
46
+ class ListFieldType:(BaseFieldType)
47
+ fieldType: RawFieldType
48
+ isNullable: bool
49
+
50
+
51
+ class MappingFieldType(BaseFieldType):
52
+ keyFieldType: RawFieldType
53
+ valueFieldType: RawFieldType
54
+
55
+
56
+ class OneOfFieldType(BaseFieldType):
57
+ fieldTypes: list[RawFieldType]
58
+
59
+
60
+ class EnumFieldType(BaseFieldType):
61
+ values: list[str]
62
+
63
+ ```
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "geniespec"
3
+ version = "0.1.1.dev2"
4
+ description = "Generate API code from an api.yaml spec"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13, <4"
8
+ dependencies = [
9
+ "jinja2>=3.1.6",
10
+ "pyyaml>=6.0.3",
11
+ "jsonschema>=4.26.0",
12
+ "pydantic>=2.13.5",
13
+ "kiba-core>=0.5.3.dev81,<0.5.4",
14
+ ]
15
+
16
+ [project.scripts]
17
+ geniespec = "geniespec.cli:main"
18
+
19
+ [project.urls]
20
+ repository = "https://github.com/kibalabs/genie"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "kiba-build==0.1.11.dev15",
25
+ "kiba-core[core-api,requester]>=0.5.3.dev81,<0.5.4",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.12,<0.13"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["src"]
34
+
35
+ [tool.bumpversion]
36
+ search = 'version = "{current_version}"'
37
+ replace = 'version = "{new_version}"'
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "geniespec"
3
+ version = "0.1.1.dev2"
4
+ description = "Generate API code from an api.yaml spec"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13, <4"
8
+ dependencies = [
9
+ "jinja2>=3.1.6",
10
+ "pyyaml>=6.0.3",
11
+ "jsonschema>=4.26.0",
12
+ "pydantic>=2.13.5",
13
+ "kiba-core>=0.5.3.dev81,<0.5.4",
14
+ ]
15
+
16
+ [project.scripts]
17
+ geniespec = "geniespec.cli:main"
18
+
19
+ [project.urls]
20
+ repository = "https://github.com/kibalabs/genie"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "kiba-build==0.1.11.dev15",
25
+ "kiba-core[core-api,requester]>=0.5.3.dev81,<0.5.4",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.12,<0.13"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["src"]
34
+
35
+ # NOTE(krishan711): without this, bumping the version also rewrites dependency pins that contain it (e.g. 0.1.1 inside kiba-build==0.1.11.dev15)
36
+ [tool.bumpversion]
37
+ search = "version = \"{current_version}\""
38
+ replace = "version = \"{new_version}\""
File without changes
@@ -0,0 +1,318 @@
1
+ $schema: 'http://json-schema.org/draft-07/schema#'
2
+ $id: kibaRestApiSchema
3
+ type: object
4
+ additionalProperties: false
5
+ required:
6
+ - version
7
+ - serviceName
8
+ - pythonClientPackageName
9
+ - typescriptClientPackageName
10
+ properties:
11
+ version:
12
+ type: string
13
+ pattern: '^([0-9]|[1-9][0-9]*)\.([0-9]|[1-9][0-9]*)\.([0-9]|[1-9][0-9]*)$'
14
+ serviceName:
15
+ type: string
16
+ pattern: '^[a-z][a-z-]*$'
17
+ pythonClientPackageName:
18
+ type: string
19
+ pattern: '^[a-z][-a-z]*$'
20
+ typescriptClientPackageName:
21
+ type: string
22
+ pattern: '[@/-a-z]*$'
23
+ transitions:
24
+ type: array
25
+ items:
26
+ $ref: '#/definitions/RootTransition'
27
+ resources:
28
+ type: array
29
+ items:
30
+ $ref: '#/definitions/Resource'
31
+ exceptions:
32
+ type: array
33
+ items:
34
+ $ref: '#/definitions/Exception'
35
+ imports:
36
+ type: array
37
+ items:
38
+ type: string
39
+ defaultAuthentication:
40
+ type:
41
+ - "null"
42
+ - string
43
+ pattern: '^[a-z][a-z0-9-]*$'
44
+ definitions:
45
+ Attribute:
46
+ type: object
47
+ additionalProperties: false
48
+ required:
49
+ - name
50
+ - type
51
+ properties:
52
+ name:
53
+ type: string
54
+ pattern: '^[a-z][A-Za-z0-9-]*$'
55
+ type:
56
+ type: string
57
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
58
+ listValueTypes:
59
+ type: array
60
+ minItems: 1
61
+ items:
62
+ type: string
63
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
64
+ isPrimary:
65
+ type: boolean
66
+ isUnique:
67
+ type: boolean
68
+ shouldDisableGetEndpoint:
69
+ type: boolean
70
+ isUnfilterable:
71
+ type: boolean
72
+ isUpdatable:
73
+ type: boolean
74
+ isNullable:
75
+ type: boolean
76
+ isDeprecated:
77
+ type: boolean
78
+ defaultValue:
79
+ type:
80
+ - string
81
+ - number
82
+ - boolean
83
+ - 'null'
84
+ isSettable:
85
+ type: boolean
86
+ isAccessibleIndependently:
87
+ type: boolean
88
+ mappingKeyType:
89
+ type: string
90
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
91
+ mappingValueTypes:
92
+ type: array
93
+ items:
94
+ type: string
95
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
96
+ Resource:
97
+ type: object
98
+ additionalProperties: false
99
+ required:
100
+ - singleName
101
+ - collectionName
102
+ properties:
103
+ singleName:
104
+ type: string
105
+ pattern: '^[A-Z][A-Za-z-]*$'
106
+ collectionName:
107
+ type: string
108
+ pattern: '^[A-Z][A-Za-z-]*$'
109
+ parentResource:
110
+ type: string
111
+ pattern: '^[A-Z][A-Za-z-]*$'
112
+ attributes:
113
+ type: array
114
+ items:
115
+ $ref: '#/definitions/Attribute'
116
+ transitions:
117
+ type: array
118
+ items:
119
+ $ref: '#/definitions/Transition'
120
+ accessibleFrom:
121
+ type: array
122
+ items:
123
+ type: string
124
+ BaseTransition:
125
+ type: object
126
+ additionalProperties: false
127
+ required:
128
+ - action
129
+ properties:
130
+ action:
131
+ type: string
132
+ pattern: '^[A-Z][A-Z-]*$'
133
+ method:
134
+ type: string
135
+ enum:
136
+ - GET
137
+ - POST
138
+ requestFields:
139
+ type: array
140
+ items:
141
+ $ref: '#/definitions/RequestField'
142
+ responseField:
143
+ $ref: '#/definitions/ResponseField'
144
+ authentication:
145
+ type:
146
+ - "null"
147
+ - string
148
+ pattern: '^[a-z][a-z0-9-]*$'
149
+ responseType:
150
+ type: string
151
+ enum:
152
+ - json
153
+ - stream
154
+ - redirect
155
+ Transition:
156
+ type: object
157
+ additionalProperties: false
158
+ required:
159
+ - action
160
+ properties:
161
+ action:
162
+ type: string
163
+ pattern: '^[A-Z][A-Z-]*$'
164
+ method:
165
+ type: string
166
+ enum:
167
+ - GET
168
+ - POST
169
+ requestFields:
170
+ type: array
171
+ items:
172
+ $ref: '#/definitions/RequestField'
173
+ responseField:
174
+ $ref: '#/definitions/ResponseField'
175
+ authentication:
176
+ type:
177
+ - "null"
178
+ - string
179
+ pattern: '^[a-z][a-z0-9-]*$'
180
+ responseType:
181
+ type: string
182
+ enum:
183
+ - json
184
+ - stream
185
+ - redirect
186
+ operatesOn:
187
+ type: string
188
+ enum:
189
+ - individual
190
+ - collection
191
+ isIndirectAction:
192
+ type: boolean
193
+ RootTransition:
194
+ type: object
195
+ allOf:
196
+ - $ref: '#/definitions/BaseTransition'
197
+ RequestField:
198
+ type: object
199
+ additionalProperties: false
200
+ required:
201
+ - name
202
+ - type
203
+ properties:
204
+ name:
205
+ type: string
206
+ pattern: '^[a-z][A-Za-z0-9-]*$'
207
+ type:
208
+ type: string
209
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
210
+ listValueTypes:
211
+ type: array
212
+ minItems: 1
213
+ items:
214
+ type: string
215
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
216
+ mappingKeyType:
217
+ type: string
218
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
219
+ mappingValueTypes:
220
+ type: array
221
+ items:
222
+ type: string
223
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
224
+ isRequired:
225
+ type: boolean
226
+ defaultValue:
227
+ type:
228
+ - string
229
+ - number
230
+ - boolean
231
+ - 'null'
232
+ isDeprecated:
233
+ type: boolean
234
+ maxValue:
235
+ type:
236
+ - string
237
+ - number
238
+ minValue:
239
+ type:
240
+ - string
241
+ - number
242
+ source:
243
+ type: string
244
+ enum:
245
+ - request
246
+ - query-parameters
247
+ ResponseField:
248
+ type: object
249
+ additionalProperties: false
250
+ required:
251
+ - name
252
+ - type
253
+ properties:
254
+ name:
255
+ type: string
256
+ pattern: '^[a-z][A-Za-z-]*$'
257
+ type:
258
+ type: string
259
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
260
+ listValueTypes:
261
+ type: array
262
+ minItems: 1
263
+ items:
264
+ type: string
265
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
266
+ mappingKeyType:
267
+ type: string
268
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
269
+ mappingValueTypes:
270
+ type: array
271
+ items:
272
+ type: string
273
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
274
+ isNullable:
275
+ type: boolean
276
+ isList:
277
+ type: boolean
278
+ Exception:
279
+ type: object
280
+ additionalProperties: false
281
+ required:
282
+ - name
283
+ properties:
284
+ name:
285
+ type: string
286
+ pattern: '^[A-Z][A-Za-z-]*Exception$'
287
+ message:
288
+ type: string
289
+ extends:
290
+ type: string
291
+ pattern: '^([a-z-]+.exceptions.[A-Za-z-]*)|([A-Za-z-]*)'
292
+ statusCode:
293
+ type: integer
294
+ fields:
295
+ type: array
296
+ items:
297
+ $ref: '#/definitions/ExceptionField'
298
+ ExceptionField:
299
+ type: object
300
+ additionalProperties: false
301
+ required:
302
+ - name
303
+ - type
304
+ properties:
305
+ name:
306
+ type: string
307
+ pattern: '^[a-z][A-Za-z-]*$'
308
+ type:
309
+ type: string
310
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
311
+ listValueTypes:
312
+ type: array
313
+ minItems: 1
314
+ items:
315
+ type: string
316
+ pattern: '^[A-Z][A-Za-z-\[\]_, ]*$'
317
+ isRequired:
318
+ type: boolean
@@ -0,0 +1,85 @@
1
+ import argparse
2
+ import pathlib
3
+ import tempfile
4
+ import time
5
+
6
+ import yaml # type: ignore[import-untyped]
7
+ from core import logging
8
+
9
+ from geniespec.genie import Renderer
10
+ from geniespec.genie import SyntacticApiValidator
11
+ from geniespec.types import KibaApi
12
+
13
+ WATCH_INTERVAL_SECONDS = 0.5
14
+
15
+
16
+ def _sync_directory(sourceDirectoryPath: pathlib.Path, targetDirectoryPath: pathlib.Path) -> list[pathlib.Path]:
17
+ changedPaths: list[pathlib.Path] = []
18
+ sourceRelativePaths = {path.relative_to(sourceDirectoryPath) for path in sourceDirectoryPath.rglob('*') if path.is_file()}
19
+ for relativePath in sorted(sourceRelativePaths):
20
+ content = (sourceDirectoryPath / relativePath).read_bytes()
21
+ targetPath = targetDirectoryPath / relativePath
22
+ if targetPath.is_file() and targetPath.read_bytes() == content:
23
+ continue
24
+ targetPath.parent.mkdir(parents=True, exist_ok=True)
25
+ temporaryPath = targetPath.with_name(f'.{targetPath.name}.tmp')
26
+ temporaryPath.write_bytes(content)
27
+ temporaryPath.replace(targetPath)
28
+ changedPaths.append(targetPath)
29
+ stalePaths = [path for path in targetDirectoryPath.rglob('*') if path.is_file() and '__pycache__' not in path.parts and path.relative_to(targetDirectoryPath) not in sourceRelativePaths]
30
+ for stalePath in stalePaths:
31
+ stalePath.unlink()
32
+ return changedPaths + stalePaths
33
+
34
+
35
+ def build(specPath: pathlib.Path, outputDirectoryPath: pathlib.Path) -> list[pathlib.Path]:
36
+ with open(specPath) as specFile:
37
+ apiYaml = yaml.safe_load(specFile)
38
+ SyntacticApiValidator().validate(apiYaml)
39
+ api = KibaApi(**apiYaml)
40
+ with tempfile.TemporaryDirectory() as renderDirectory:
41
+ Renderer().render_directory(api=api, outputDirectoryPath=renderDirectory)
42
+ return _sync_directory(sourceDirectoryPath=pathlib.Path(renderDirectory), targetDirectoryPath=outputDirectoryPath / 'python')
43
+
44
+
45
+ def _build_and_log(specPath: pathlib.Path, outputDirectoryPath: pathlib.Path) -> None:
46
+ changedPaths = build(specPath=specPath, outputDirectoryPath=outputDirectoryPath)
47
+ logging.info(f'geniespec: built {specPath} into {outputDirectoryPath} ({len(changedPaths)} files changed)')
48
+
49
+
50
+ def watch(specPath: pathlib.Path, outputDirectoryPath: pathlib.Path) -> None:
51
+ lastModifiedTime: int | None = None
52
+ while True:
53
+ try:
54
+ modifiedTime: int | None = specPath.stat().st_mtime_ns
55
+ except FileNotFoundError:
56
+ modifiedTime = lastModifiedTime
57
+ if modifiedTime != lastModifiedTime:
58
+ lastModifiedTime = modifiedTime
59
+ try:
60
+ _build_and_log(specPath=specPath, outputDirectoryPath=outputDirectoryPath)
61
+ except Exception as exception: # noqa: BLE001
62
+ logging.error(f'geniespec: failed to build {specPath}, keeping the previous output: {exception}')
63
+ time.sleep(WATCH_INTERVAL_SECONDS)
64
+
65
+
66
+ def main() -> None:
67
+ parser = argparse.ArgumentParser(prog='geniespec')
68
+ subparsers = parser.add_subparsers(dest='command', required=True)
69
+ for command in ('build', 'watch'):
70
+ subparser = subparsers.add_parser(command)
71
+ subparser.add_argument('--spec', type=pathlib.Path, required=True)
72
+ subparser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('.genie'))
73
+ args = parser.parse_args()
74
+ logging.basicConfig(level=logging.INFO)
75
+ if args.command == 'build':
76
+ _build_and_log(specPath=args.spec, outputDirectoryPath=args.output)
77
+ return
78
+ try:
79
+ watch(specPath=args.spec, outputDirectoryPath=args.output)
80
+ except KeyboardInterrupt:
81
+ return
82
+
83
+
84
+ if __name__ == '__main__':
85
+ main()