diff --git a/.github/workflows/release-jac-splice-orc.yml b/.github/workflows/release-jac-splice-orc.yml new file mode 100644 index 0000000000..97b03aeb4b --- /dev/null +++ b/.github/workflows/release-jac-splice-orc.yml @@ -0,0 +1,34 @@ +name: Release jac-splice-orc to PyPI + +on: workflow_dispatch + +jobs: + release-splice-orc: + name: Release + runs-on: ubuntu-latest + defaults: + run: + working-directory: jac-splice-orc + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + + - name: Build package + run: python setup.py sdist bdist_wheel + + - name: Publish package to PyPI + env: + TWINE_USERNAME: '__token__' + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* diff --git a/jac-cloud/jac_cloud/core/architype.py b/jac-cloud/jac_cloud/core/architype.py index c893c7cbb5..997e1165ed 100644 --- a/jac-cloud/jac_cloud/core/architype.py +++ b/jac-cloud/jac_cloud/core/architype.py @@ -234,33 +234,33 @@ def has_operations(self) -> bool: def commit(session: ClientSession) -> None: """Commit current session.""" commit_retry = 0 - commit_max_retry = BulkWrite.SESSION_MAX_COMMIT_RETRY - while commit_retry <= commit_max_retry: + while True: try: session.commit_transaction() break except (ConnectionFailure, OperationFailure) as ex: - if ex.has_error_label("UnknownTransactionCommitResult"): + if ( + ex.has_error_label("UnknownTransactionCommitResult") + and commit_retry <= BulkWrite.SESSION_MAX_COMMIT_RETRY + ): commit_retry += 1 - logger.error( - "Error commiting bulk write! " - f"Retrying [{commit_retry}/{commit_max_retry}] ..." + logger.exception( + "Error commiting session! " + f"Retrying [{commit_retry}/{BulkWrite.SESSION_MAX_COMMIT_RETRY}] ..." ) continue - logger.error( - f"Error commiting bulk write after max retry [{commit_max_retry}] !" + logger.exception( + f"Error commiting session after max retry [{BulkWrite.SESSION_MAX_COMMIT_RETRY}] !" ) raise except Exception: - session.abort_transaction() - logger.error("Error commiting bulk write!") + logger.exception("Error commiting session!") raise def execute(self, session: ClientSession) -> None: """Execute all operations.""" transaction_retry = 0 - transaction_max_retry = self.SESSION_MAX_TRANSACTION_RETRY - while transaction_retry <= transaction_max_retry: + while True: try: if node_operation := self.operations[NodeAnchor]: NodeAnchor.Collection.bulk_write(node_operation, False, session) @@ -273,19 +273,22 @@ def execute(self, session: ClientSession) -> None: self.commit(session) break except (ConnectionFailure, OperationFailure) as ex: - if ex.has_error_label("TransientTransactionError"): + if ( + ex.has_error_label("TransientTransactionError") + and transaction_retry <= self.SESSION_MAX_TRANSACTION_RETRY + ): transaction_retry += 1 - logger.error( + logger.exception( "Error executing bulk write! " - f"Retrying [{transaction_retry}/{transaction_max_retry}] ..." + f"Retrying [{transaction_retry}/{self.SESSION_MAX_TRANSACTION_RETRY}] ..." ) continue - logger.error( - f"Error executing bulk write after max retry [{transaction_max_retry}] !" + logger.exception( + f"Error executing bulk write after max retry [{self.SESSION_MAX_TRANSACTION_RETRY}] !" ) raise except Exception: - logger.error("Error executing bulk write!") + logger.exception("Error executing bulk write!") raise @@ -557,7 +560,7 @@ def update(self, bulk_write: BulkWrite, propagate: bool = False) -> None: ############################################################ # POPULATE ADDED EDGES # ############################################################ - added_edges: set[BaseAnchor | Anchor] = ( + added_edges: set[BaseAnchor] = ( changes.get("$addToSet", {}).get("edges", {}).get("$each", []) ) if added_edges: @@ -575,7 +578,7 @@ def update(self, bulk_write: BulkWrite, propagate: bool = False) -> None: ############################################################ # POPULATE REMOVED EDGES # ############################################################ - pulled_edges: set[BaseAnchor | Anchor] = ( + pulled_edges: set[BaseAnchor] = ( changes.get("$pull", {}).get("edges", {}).get("$in", []) ) if pulled_edges: @@ -828,10 +831,10 @@ class WalkerAnchor(BaseAnchor, _WalkerAnchor): # type: ignore[misc] """Walker Anchor.""" architype: "WalkerArchitype" - path: list[Anchor] = field(default_factory=list) - next: list[Anchor] = field(default_factory=list) + path: list[NodeAnchor] = field(default_factory=list) # type: ignore[assignment] + next: list[NodeAnchor] = field(default_factory=list) # type: ignore[assignment] returns: list[Any] = field(default_factory=list) - ignores: list[Anchor] = field(default_factory=list) + ignores: list[NodeAnchor] = field(default_factory=list) # type: ignore[assignment] disengaged: bool = False class Collection(BaseCollection["WalkerAnchor"]): diff --git a/jac-cloud/jac_cloud/jaseci/__init__.py b/jac-cloud/jac_cloud/jaseci/__init__.py index e862465353..02e69e4964 100644 --- a/jac-cloud/jac_cloud/jaseci/__init__.py +++ b/jac-cloud/jac_cloud/jaseci/__init__.py @@ -70,10 +70,17 @@ async def lifespan(app: _FaststAPI) -> AsyncGenerator[None, _FaststAPI]: populate_yaml_specs(cls.__app__) - from .routers import healthz_router, sso_router, user_router - from ..plugin.jaseci import walker_router - - for router in [healthz_router, sso_router, user_router, walker_router]: + from .routers import healthz_router, sso_router, user_router, webhook_router + from ..plugin.jaseci import walker_router, webhook_walker_router + + for router in [ + healthz_router, + sso_router, + user_router, + webhook_router, + walker_router, + webhook_walker_router, + ]: cls.__app__.include_router(router) @cls.__app__.exception_handler(Exception) diff --git a/jac-cloud/jac_cloud/jaseci/datasources/redis.py b/jac-cloud/jac_cloud/jaseci/datasources/redis.py index e8022cab27..76e50601bb 100644 --- a/jac-cloud/jac_cloud/jaseci/datasources/redis.py +++ b/jac-cloud/jac_cloud/jaseci/datasources/redis.py @@ -74,7 +74,7 @@ def keys(cls) -> list[bytes]: return [] @classmethod - def set(cls, key: str, data: dict | bool) -> bool: + def set(cls, key: str, data: dict | bool | float) -> bool: """Push key value pair.""" try: redis = cls.get_rd() @@ -115,7 +115,7 @@ def hkeys(cls) -> list[str]: return [] @classmethod - def hset(cls, key: str, data: dict | bool) -> bool: + def hset(cls, key: str, data: dict | bool | float) -> bool: """Push key value pair to group.""" try: redis = cls.get_rd() @@ -169,6 +169,16 @@ class TokenRedis(Redis): __table__ = "token" +class WebhookRedis(Redis): + """Token Memory Interface. + + This interface is for Token Management. + You may override this if you wish to implement different structure + """ + + __table__ = "webhook" + + class AsyncRedis: """ Base Memory interface. @@ -225,7 +235,7 @@ async def keys(cls) -> list[bytes]: return [] @classmethod - async def set(cls, key: str, data: dict | bool) -> bool: + async def set(cls, key: str, data: dict | bool | float) -> bool: """Push key value pair.""" try: redis = cls.get_rd() @@ -266,7 +276,7 @@ async def hkeys(cls) -> list[str]: return [] @classmethod - async def hset(cls, key: str, data: dict | bool) -> bool: + async def hset(cls, key: str, data: dict | bool | float) -> bool: """Push key value pair to group.""" try: redis = cls.get_rd() diff --git a/jac-cloud/jac_cloud/jaseci/dtos/__init__.py b/jac-cloud/jac_cloud/jaseci/dtos/__init__.py index 7c0c5efa07..6184abaad7 100644 --- a/jac-cloud/jac_cloud/jaseci/dtos/__init__.py +++ b/jac-cloud/jac_cloud/jaseci/dtos/__init__.py @@ -8,6 +8,7 @@ UserResetPassword, UserVerification, ) +from .webhook import Expiration, GenerateKey, KeyIDs __all__ = [ @@ -18,4 +19,7 @@ "UserRequest", "UserResetPassword", "UserVerification", + "Expiration", + "GenerateKey", + "KeyIDs", ] diff --git a/jac-cloud/jac_cloud/jaseci/dtos/webhook.py b/jac-cloud/jac_cloud/jaseci/dtos/webhook.py new file mode 100644 index 0000000000..4677b14566 --- /dev/null +++ b/jac-cloud/jac_cloud/jaseci/dtos/webhook.py @@ -0,0 +1,31 @@ +"""Jaseci User DTOs.""" + +from typing import Literal + +from annotated_types import Len + +from pydantic import BaseModel, Field, StringConstraints + +from typing_extensions import Annotated + + +class Expiration(BaseModel): + """Key Expiration.""" + + count: Annotated[int, Field(strict=True, gt=0, default=60)] = 60 + interval: Literal["seconds", "minutes", "hours", "days"] = "days" + + +class GenerateKey(BaseModel): + """User Creation Request Model.""" + + name: Annotated[str, StringConstraints(min_length=1)] + walkers: list[str] = Field(default_factory=list) + nodes: list[str] = Field(default_factory=list) + expiration: Expiration = Field(default_factory=Expiration) + + +class KeyIDs(BaseModel): + """User Creation Request Model.""" + + ids: Annotated[list[str], Len(min_length=1)] diff --git a/jac-cloud/jac_cloud/jaseci/models/__init__.py b/jac-cloud/jac_cloud/jaseci/models/__init__.py index 6442cc1f1b..5177e9704d 100644 --- a/jac-cloud/jac_cloud/jaseci/models/__init__.py +++ b/jac-cloud/jac_cloud/jaseci/models/__init__.py @@ -1,5 +1,6 @@ """Jaseci Models.""" from .user import NO_PASSWORD, User +from .webhook import Webhook -__all__ = ["NO_PASSWORD", "User"] +__all__ = ["NO_PASSWORD", "User", "Webhook"] diff --git a/jac-cloud/jac_cloud/jaseci/models/webhook.py b/jac-cloud/jac_cloud/jaseci/models/webhook.py new file mode 100644 index 0000000000..275da663ec --- /dev/null +++ b/jac-cloud/jac_cloud/jaseci/models/webhook.py @@ -0,0 +1,63 @@ +"""Jaseci Models.""" + +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, Generator, Mapping, cast + +from bson import ObjectId + +from ..datasources.collection import Collection as BaseCollection + + +@dataclass(kw_only=True) +class Webhook: + """Webhook Base Model.""" + + id: ObjectId = field(default_factory=ObjectId) + name: str + root_id: ObjectId + walkers: list[str] + nodes: list[str] + expiration: datetime + key: str + + class Collection(BaseCollection["Webhook"]): + """ + Webhook collection interface. + + This interface is for Webhook Credentials Management. + You may override this if you wish to implement different structure + """ + + __collection__ = "webhook" + __indexes__ = [ + {"keys": ["name"], "unique": True}, + {"keys": ["key"], "unique": True}, + ] + + @classmethod + def __document__(cls, doc: Mapping[str, Any]) -> "Webhook": + """ + Return parsed Webhook from document. + + This the default Webhook parser after getting a single document. + You may override this to specify how/which class it will be casted/based. + """ + doc = cast(dict, doc) + return Webhook(id=doc.pop("_id"), **doc) + + @classmethod + def find_by_root_id(cls, root_id: ObjectId) -> Generator["Webhook", None, None]: + """Retrieve webhook via root_id.""" + return cls.find({"root_id": root_id}) + + @classmethod + def find_by_key(cls, key: str) -> "Webhook | None": + """Retrieve webhook via root_id.""" + return cls.find_one({"key": key}) + + def __serialize__(self) -> dict: + """Return serializable.""" + data = asdict(self) + data["_id"] = data.pop("id") + return data diff --git a/jac-cloud/jac_cloud/jaseci/routers/__init__.py b/jac-cloud/jac_cloud/jaseci/routers/__init__.py index 04dac026c5..8585b682d0 100644 --- a/jac-cloud/jac_cloud/jaseci/routers/__init__.py +++ b/jac-cloud/jac_cloud/jaseci/routers/__init__.py @@ -3,5 +3,6 @@ from .healthz import router as healthz_router from .sso import router as sso_router from .user import router as user_router +from .webhook import router as webhook_router -__all__ = ["healthz_router", "sso_router", "user_router"] +__all__ = ["healthz_router", "sso_router", "user_router", "webhook_router"] diff --git a/jac-cloud/jac_cloud/jaseci/routers/sso.py b/jac-cloud/jac_cloud/jaseci/routers/sso.py index a8f6ca5bd3..045eab2c92 100644 --- a/jac-cloud/jac_cloud/jaseci/routers/sso.py +++ b/jac-cloud/jac_cloud/jaseci/routers/sso.py @@ -25,7 +25,7 @@ from fastapi_sso.sso.twitter import TwitterSSO from fastapi_sso.sso.yandex import YandexSSO -from pymongo.errors import ConnectionFailure, OperationFailure +from pymongo.errors import ConnectionFailure, DuplicateKeyError, OperationFailure from ..dtos import AttachSSO, DetachSSO from ..models import NO_PASSWORD, User as BaseUser @@ -214,8 +214,7 @@ def register(platform: str, open_id: OpenID) -> Response: with User.Collection.get_session() as session, session.start_transaction(): retry = 0 - max_retry = BulkWrite.SESSION_MAX_TRANSACTION_RETRY - while retry <= max_retry: + while True: try: if not User.Collection.update_one( {"email": open_id.email}, @@ -229,7 +228,7 @@ def register(platform: str, open_id: OpenID) -> Response: } }, session=session, - ): + ).modified_count: root = Root().__jac__ ureq: dict[str, object] = User.register_type()( email=open_id.email, @@ -243,21 +242,26 @@ def register(platform: str, open_id: OpenID) -> Response: User.Collection.insert_one(ureq, session=session) BulkWrite.commit(session) return login(platform, open_id) + except DuplicateKeyError: + raise HTTPException(409, "Already Exists!") except (ConnectionFailure, OperationFailure) as ex: - if ex.has_error_label("TransientTransactionError"): + if ( + ex.has_error_label("TransientTransactionError") + and retry <= BulkWrite.SESSION_MAX_TRANSACTION_RETRY + ): retry += 1 - logger.error( + logger.exception( "Error executing bulk write! " - f"Retrying [{retry}/{max_retry}] ..." + f"Retrying [{retry}/{BulkWrite.SESSION_MAX_TRANSACTION_RETRY}] ..." ) continue - logger.exception("Error executing bulk write!") - session.abort_transaction() - break + logger.exception( + f"Error executing bulk write after max retry [{BulkWrite.SESSION_MAX_TRANSACTION_RETRY}] !" + ) + raise except Exception: logger.exception("Error executing bulk write!") - session.abort_transaction() - break + raise return ORJSONResponse({"message": "Registration Failed!"}, 409) diff --git a/jac-cloud/jac_cloud/jaseci/routers/user.py b/jac-cloud/jac_cloud/jaseci/routers/user.py index 6893747ada..6f12921876 100644 --- a/jac-cloud/jac_cloud/jaseci/routers/user.py +++ b/jac-cloud/jac_cloud/jaseci/routers/user.py @@ -8,7 +8,7 @@ from passlib.hash import pbkdf2_sha512 -from pymongo.errors import ConnectionFailure, OperationFailure +from pymongo.errors import ConnectionFailure, DuplicateKeyError, OperationFailure from ..dtos import ( UserChangePassword, @@ -49,8 +49,7 @@ def register(req: User.register_type()) -> ORJSONResponse: # type: ignore is_activated = req_obf["is_activated"] = not Emailer.has_client() retry = 0 - max_retry = BulkWrite.SESSION_MAX_TRANSACTION_RETRY - while retry <= max_retry: + while True: try: NodeAnchor.Collection.insert_one(root.serialize(), session) if id := ( @@ -62,21 +61,27 @@ def register(req: User.register_type()) -> ORJSONResponse: # type: ignore resp = {"message": "Successfully Registered!"} log_exit(resp, log) return ORJSONResponse(resp, 201) + raise SystemError("Can't create System Admin!") + except DuplicateKeyError: + raise HTTPException(409, "Already Exists!") except (ConnectionFailure, OperationFailure) as ex: - if ex.has_error_label("TransientTransactionError"): + if ( + ex.has_error_label("TransientTransactionError") + and retry <= BulkWrite.SESSION_MAX_TRANSACTION_RETRY + ): retry += 1 logger.error( "Error executing bulk write! " - f"Retrying [{retry}/{max_retry}] ..." + f"Retrying [{retry}/{BulkWrite.SESSION_MAX_TRANSACTION_RETRY}] ..." ) continue - logger.exception("Error executing bulk write!") - session.abort_transaction() - break + logger.exception( + f"Error executing bulk write after max retry [{BulkWrite.SESSION_MAX_TRANSACTION_RETRY}] !" + ) + raise except Exception: logger.exception("Error executing bulk write!") - session.abort_transaction() - break + raise resp = {"message": "Registration Failed!"} log_exit(resp, log) diff --git a/jac-cloud/jac_cloud/jaseci/routers/webhook.py b/jac-cloud/jac_cloud/jaseci/routers/webhook.py new file mode 100644 index 0000000000..21f383238b --- /dev/null +++ b/jac-cloud/jac_cloud/jaseci/routers/webhook.py @@ -0,0 +1,166 @@ +"""Webhook APIs.""" + +from datetime import timedelta + +from bson import ObjectId + +from fastapi import APIRouter, Request, status +from fastapi.responses import ORJSONResponse + +from pymongo.errors import ConnectionFailure, OperationFailure + +from ..datasources.redis import WebhookRedis +from ..dtos import Expiration, GenerateKey, KeyIDs +from ..models import Webhook +from ..security import authenticator +from ..utils import logger, random_string, utc_datetime, utc_timestamp +from ...core.architype import BulkWrite + +router = APIRouter(prefix="/webhook", tags=["webhook"]) + + +@router.get("", status_code=status.HTTP_200_OK, dependencies=authenticator) +def get(req: Request) -> ORJSONResponse: + """Get keys API.""" + root_id: ObjectId = req._user.root_id # type: ignore[attr-defined] + + return ORJSONResponse( + content={ + "keys": [ + { + "id": str(key.id), + "name": key.name, + "root_id": str(key.root_id), + "walkers": key.walkers, + "nodes": key.nodes, + "expiration": key.expiration, + "key": key.key, + } + for key in Webhook.Collection.find({"root_id": root_id}) + ] + } + ) + + +@router.post( + "/generate-key", status_code=status.HTTP_201_CREATED, dependencies=authenticator +) +def generate_key(req: Request, gen_key: GenerateKey) -> ORJSONResponse: + """Generate key API.""" + root_id: ObjectId = req._user.root_id # type: ignore[attr-defined] + + _exp: dict[str, int] = {gen_key.expiration.interval: gen_key.expiration.count} + exp = utc_datetime(**_exp) + + webhook = Webhook( + name=gen_key.name, + root_id=root_id, + walkers=gen_key.walkers, + nodes=gen_key.nodes, + expiration=exp, + key=f"{root_id}:{utc_timestamp()}:{random_string(32)}", + ) + + if ( + id := Webhook.Collection.insert_one(webhook.__serialize__()).inserted_id + ) and WebhookRedis.hset( + webhook.key, + { + "walkers": webhook.walkers, + "nodes": webhook.nodes, + "expiration": webhook.expiration.timestamp(), + }, + ): + return ORJSONResponse( + content={"id": str(id), "name": webhook.name, "key": webhook.key}, + status_code=201, + ) + + return ORJSONResponse( + content="Can't generate key at the moment. Please try again!", status_code=500 + ) + + +@router.patch( + "/extend/{id}", status_code=status.HTTP_201_CREATED, dependencies=authenticator +) +def extend(id: str, expiration: Expiration) -> ORJSONResponse: + """Generate key API.""" + with Webhook.Collection.get_session() as session, session.start_transaction(): + retry = 0 + max_retry = BulkWrite.SESSION_MAX_TRANSACTION_RETRY + while retry <= max_retry: + try: + _id = ObjectId(id) + if webhook := Webhook.Collection.find_by_id(_id, session=session): + _exp: dict[str, int] = {expiration.interval: expiration.count} + webhook.expiration += timedelta(**_exp) + + if Webhook.Collection.update_by_id( + _id, {"$set": {"expiration": webhook.expiration}}, session + ).modified_count: + WebhookRedis.hset( + webhook.key, + { + "walkers": webhook.walkers, + "nodes": webhook.nodes, + "expiration": webhook.expiration.timestamp(), + }, + ) + BulkWrite.commit(session) + return ORJSONResponse( + {"message": "Successfully Extended!"}, 200 + ) + break + except (ConnectionFailure, OperationFailure) as ex: + if ex.has_error_label("TransientTransactionError"): + retry += 1 + logger.error( + f"Error executing transaction! Retrying [{retry}/{max_retry}] ..." + ) + continue + logger.exception("Error executing transaction!") + session.abort_transaction() + break + except Exception: + logger.exception("Error executing transaction!") + session.abort_transaction() + break + + return ORJSONResponse( + content="Can't extend key at the moment. Please try again!", status_code=500 + ) + + +@router.delete("/delete", status_code=status.HTTP_200_OK, dependencies=authenticator) +def delete(key_ids: KeyIDs) -> ORJSONResponse: + """Delete keys API.""" + with Webhook.Collection.get_session() as session, session.start_transaction(): + retry = 0 + max_retry = BulkWrite.SESSION_MAX_TRANSACTION_RETRY + while retry <= max_retry: + try: + if Webhook.Collection.delete( + {"_id": {"$in": [ObjectId(id) for id in key_ids.ids]}}, session + ).deleted_count == len(key_ids.ids): + BulkWrite.commit(session) + return ORJSONResponse({"message": "Successfully Deleted!"}, 200) + break + except (ConnectionFailure, OperationFailure) as ex: + if ex.has_error_label("TransientTransactionError"): + retry += 1 + logger.error( + f"Error executing transaction! Retrying [{retry}/{max_retry}] ..." + ) + continue + logger.exception("Error executing transaction!") + session.abort_transaction() + break + except Exception: + logger.exception("Error executing transaction!") + session.abort_transaction() + break + + return ORJSONResponse( + content="Error occured during deletion. Please try again!", status_code=500 + ) diff --git a/jac-cloud/jac_cloud/jaseci/security/__init__.py b/jac-cloud/jac_cloud/jaseci/security/__init__.py index 64bfa642dd..b0b1f7c476 100644 --- a/jac-cloud/jac_cloud/jaseci/security/__init__.py +++ b/jac-cloud/jac_cloud/jaseci/security/__init__.py @@ -3,16 +3,18 @@ from os import getenv from typing import Any +from asyncer import syncify + from bson import ObjectId from fastapi import Depends, Request from fastapi.exceptions import HTTPException -from fastapi.security import HTTPBearer +from fastapi.security import APIKeyHeader, APIKeyQuery, HTTPBearer from jwt import decode, encode -from ..datasources.redis import CodeRedis, TokenRedis -from ..models.user import User as BaseUser +from ..datasources.redis import CodeRedis, TokenRedis, WebhookRedis +from ..models import User as BaseUser, Webhook from ..utils import logger, random_string, utc_timestamp from ...core.architype import NodeAnchor @@ -84,14 +86,21 @@ def invalidate_token(user_id: ObjectId) -> None: TokenRedis.hdelete_rgx(f"{user_id}:*") +def validate_request(request: Request, walker: str, node: str) -> None: + """Trigger initial validation for request.""" + if ((walkers := getattr(request, "_walkers", None)) and walker not in walkers) or ( + (nodes := getattr(request, "_nodes", None)) and node not in nodes + ): + raise HTTPException(status_code=403) + + def authenticate(request: Request) -> None: """Authenticate current request and attach authenticated user and their root.""" authorization = request.headers.get("Authorization") if authorization and authorization.lower().startswith("bearer"): token = authorization[7:] - decrypted = decrypt(token) if ( - decrypted + (decrypted := decrypt(token)) and decrypted["expiration"] > utc_timestamp() and TokenRedis.hget(f"{decrypted['id']}:{token}") and (user := User.Collection.find_by_id(decrypted["id"])) @@ -104,4 +113,69 @@ def authenticate(request: Request) -> None: raise HTTPException(status_code=401) +def generate_webhook_auth(webhook: dict) -> list: + """Authenticate current request and attach authenticated user and their root.""" + authenticators = [] + name = webhook.get("name", "X-API-KEY") + match webhook.get("type", "header").lower(): + case "query": + authenticators.append(Depends(APIKeyQuery(name=name))) + + def getter(request: Request) -> str | None: + return request.query_params.get(name) + + case "path": + + def getter(request: Request) -> str | None: + return request.path_params.get(name) + + case "body": + + def getter(request: Request) -> str | None: + return syncify(request.json)().get(name) + + case _: + + authenticators.append(Depends(APIKeyHeader(name=name))) + + def getter(request: Request) -> str | None: + return request.headers.get(name) + + def authenticate_webhook(request: Request) -> None: + try: + if ( + (key := getter(request)) + and (decrypted := key.split(":")) + and (root := NodeAnchor.Collection.find_by_id(ObjectId(decrypted[0]))) + ): + request._root = root # type: ignore[attr-defined] + if (cache := WebhookRedis.hget(key)) and cache[ + "expiration" + ] > utc_timestamp(): + request._walkers = cache["walkers"] # type: ignore[attr-defined] + request._nodes = cache["nodes"] # type: ignore[attr-defined] + return + elif ( + webhook := Webhook.Collection.find_by_key(key) + ) and WebhookRedis.hset( + key, + { + "walkers": webhook.walkers, + "nodes": webhook.nodes, + "expiration": webhook.expiration.timestamp(), + }, + ): + request._walkers = webhook.walkers # type: ignore[attr-defined] + request._nodes = webhook.nodes # type: ignore[attr-defined] + return + except Exception as e: + logger.exception(e) + + raise HTTPException(status_code=401) + + authenticators.append(Depends(authenticate_webhook)) + + return authenticators + + authenticator = [Depends(HTTPBearer()), Depends(authenticate)] diff --git a/jac-cloud/jac_cloud/plugin/cli.py b/jac-cloud/jac_cloud/plugin/cli.py index b78efccc77..c562a6f38a 100644 --- a/jac-cloud/jac_cloud/plugin/cli.py +++ b/jac-cloud/jac_cloud/plugin/cli.py @@ -135,8 +135,7 @@ def create_system_admin( ) retry = 0 - max_retry = BulkWrite.SESSION_MAX_TRANSACTION_RETRY - while retry <= max_retry: + while True: try: if not NodeAnchor.Collection.find_by_id( SUPER_ROOT_ID, session=session @@ -160,21 +159,24 @@ def create_system_admin( ).inserted_id: BulkWrite.commit(session) return f"System Admin created with id: {id}" - session.abort_transaction() + raise SystemError("Can't create System Admin!") except (ConnectionFailure, OperationFailure) as ex: - if ex.has_error_label("TransientTransactionError"): + if ( + ex.has_error_label("TransientTransactionError") + and retry <= BulkWrite.SESSION_MAX_TRANSACTION_RETRY + ): retry += 1 logger.error( "Error executing bulk write! " - f"Retrying [{retry}/{max_retry}] ..." + f"Retrying [{retry}/{BulkWrite.SESSION_MAX_TRANSACTION_RETRY}] ..." ) continue - logger.exception("Error executing bulk write!") - session.abort_transaction() + logger.exception( + f"Error executing bulk write after max retry [{BulkWrite.SESSION_MAX_TRANSACTION_RETRY}] !" + ) raise except Exception: logger.exception("Error executing bulk write!") - session.abort_transaction() raise raise Exception("Can't process registration. Please try again!") diff --git a/jac-cloud/jac_cloud/plugin/jaseci.py b/jac-cloud/jac_cloud/plugin/jaseci.py index 2b58283dce..53184c6768 100644 --- a/jac-cloud/jac_cloud/plugin/jaseci.py +++ b/jac-cloud/jac_cloud/plugin/jaseci.py @@ -30,6 +30,7 @@ ) from jaclang.plugin.feature import JacFeature as Jac from jaclang.runtimelib.architype import Architype, DSFunc +from jaclang.runtimelib.utils import all_issubclass from orjson import loads @@ -55,7 +56,7 @@ ) from ..core.context import ContextResponse, ExecutionContext, JaseciContext from ..jaseci import FastAPI -from ..jaseci.security import authenticator +from ..jaseci.security import authenticator, generate_webhook_auth, validate_request from ..jaseci.utils import log_entry, log_exit @@ -70,6 +71,7 @@ } walker_router = APIRouter(prefix="/walker", tags=["walker"]) +webhook_walker_router = APIRouter(prefix="/webhook/walker", tags=["webhook-walker"]) def get_specs(cls: type) -> Type["DefaultSpecs"] | None: @@ -106,6 +108,7 @@ def populate_apis(cls: Type[WalkerArchitype]) -> None: as_query: str | list[str] = specs.as_query or [] excluded: str | list[str] = specs.excluded or [] auth: bool = specs.auth or False + webhook: dict | None = specs.webhook query: dict[str, Any] = {} body: dict[str, Any] = {} @@ -197,8 +200,10 @@ def api_entry( jctx = JaseciContext.create(request, NodeAnchor.ref(node) if node else None) - wlk: WalkerAnchor = cls(**body, **pl["query"], **pl["files"]).__jac__ + validate_request(request, cls.__name__, jctx.entry_node.name or "root") + if Jac.check_read_access(jctx.entry_node): + wlk: WalkerAnchor = cls(**body, **pl["query"], **pl["files"]).__jac__ Jac.spawn_call(wlk.architype, jctx.entry_node.architype) jctx.close() @@ -211,7 +216,7 @@ def api_entry( return ORJSONResponse(resp, jctx.status) else: error = { - "error": f"You don't have access on target entry{cast(Anchor, jctx.entry_node).ref_id}!" + "error": f"You don't have access on target entry {cast(Anchor, jctx.entry_node).ref_id}!" } jctx.close() @@ -224,10 +229,17 @@ def api_root( ) -> Response: return api_entry(request, None, payload) + if webhook is None: + target_authenticator = authenticator + target_router = walker_router + else: + target_authenticator = generate_webhook_auth(webhook) + target_router = webhook_walker_router + for method in methods: method = method.lower() - walker_method = getattr(walker_router, method) + walker_method = getattr(target_router, method) raw_types: list[Type] = [ get_type_hints(jef.func).get("return", NoneType) @@ -243,11 +255,10 @@ def api_root( ret_types = NoneType # type: ignore[misc] settings: dict[str, Any] = { - "tags": ["walker"], "response_model": ContextResponse[ret_types] | Any, } if auth: - settings["dependencies"] = cast(list, authenticator) + settings["dependencies"] = cast(list, target_authenticator) walker_method(url := f"/{cls.__name__}{path}", summary=url, **settings)( api_root @@ -266,6 +277,7 @@ def specs( excluded: str | list[str] = [], # noqa: B006 auth: bool = True, private: bool = False, + webhook: dict | None = None, ) -> Callable: """Walker Decorator.""" @@ -277,6 +289,7 @@ def wrapper(cls: Type[WalkerArchitype]) -> Type[WalkerArchitype]: ex = excluded a = auth pv = private + w = webhook class __specs__(DefaultSpecs): # noqa: N801 path: str = p @@ -285,6 +298,7 @@ class __specs__(DefaultSpecs): # noqa: N801 excluded: str | list[str] = ex auth: bool = a private: bool = pv + webhook: dict | None = w cls.__specs__ = __specs__ # type: ignore[attr-defined] @@ -306,6 +320,7 @@ class DefaultSpecs: excluded: str | list[str] = [] auth: bool = True private: bool = False + webhook: dict | None = None class JacCallableImplementation: @@ -794,65 +809,85 @@ def spawn_call(op1: Architype, op2: Architype) -> WalkerArchitype: walker.path = [] walker.next = [node] walker.returns = [] + current_node = node.architype + + # walker entry + for i in warch._jac_entry_funcs_: + if i.func and not i.trigger: + walker.returns.append(i.func(warch, current_node)) + if walker.disengaged: + return warch - if walker.next: - current_node = walker.next[-1].architype - for i in warch._jac_entry_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger: - if i.func: - walker.returns.append(i.func(warch, current_node)) - else: - raise ValueError(f"No function {i.name} to call.") while len(walker.next): if current_node := walker.next.pop(0).architype: + # walker entry with + for i in warch._jac_entry_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, NodeArchitype) + and isinstance(current_node, i.trigger) + ): + walker.returns.append(i.func(warch, current_node)) + if walker.disengaged: + return warch + + # node entry for i in current_node._jac_entry_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(warch, trigger): - if i.func: - walker.returns.append(i.func(current_node, warch)) - else: - raise ValueError(f"No function {i.name} to call.") + if i.func and not i.trigger: + walker.returns.append(i.func(current_node, warch)) if walker.disengaged: return warch - for i in warch._jac_entry_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(current_node, trigger): - if i.func and trigger: - walker.returns.append(i.func(warch, current_node)) - elif not trigger: - continue - else: - raise ValueError(f"No function {i.name} to call.") + + # node entry with + for i in current_node._jac_entry_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, WalkerArchitype) + and isinstance(warch, i.trigger) + ): + walker.returns.append(i.func(current_node, warch)) if walker.disengaged: return warch - for i in warch._jac_exit_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(current_node, trigger): - if i.func and trigger: - walker.returns.append(i.func(warch, current_node)) - elif not trigger: - continue - else: - raise ValueError(f"No function {i.name} to call.") + + # node exit with + for i in current_node._jac_exit_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, WalkerArchitype) + and isinstance(warch, i.trigger) + ): + walker.returns.append(i.func(current_node, warch)) if walker.disengaged: return warch + + # node exit for i in current_node._jac_exit_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(warch, trigger): - if i.func: - walker.returns.append(i.func(current_node, warch)) - else: - raise ValueError(f"No function {i.name} to call.") + if i.func and not i.trigger: + walker.returns.append(i.func(current_node, warch)) if walker.disengaged: return warch + + # walker exit with + for i in warch._jac_exit_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, NodeArchitype) + and isinstance(current_node, i.trigger) + ): + walker.returns.append(i.func(warch, current_node)) + if walker.disengaged: + return warch + # walker exit for i in warch._jac_exit_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger: - if i.func: - walker.returns.append(i.func(warch, current_node)) - else: - raise ValueError(f"No function {i.name} to call.") + if i.func and not i.trigger: + walker.returns.append(i.func(warch, current_node)) + if walker.disengaged: + return warch + walker.ignores = [] return warch diff --git a/jac-cloud/jac_cloud/tests/openapi_specs.yaml b/jac-cloud/jac_cloud/tests/openapi_specs.yaml index 3f3bbe9c96..b45f78c029 100644 --- a/jac-cloud/jac_cloud/tests/openapi_specs.yaml +++ b/jac-cloud/jac_cloud/tests/openapi_specs.yaml @@ -216,6 +216,48 @@ components: \ for details." enum: [] title: Enum + Expiration: + description: Key Expiration. + properties: + count: + default: 60 + exclusiveMinimum: 0.0 + title: Count + type: integer + interval: + default: days + enum: + - seconds + - minutes + - hours + - days + title: Interval + type: string + title: Expiration + type: object + GenerateKey: + description: User Creation Request Model. + properties: + expiration: + $ref: '#/components/schemas/Expiration' + name: + minLength: 1 + title: Name + type: string + nodes: + items: + type: string + title: Nodes + type: array + walkers: + items: + type: string + title: Walkers + type: array + required: + - name + title: GenerateKey + type: object HTTPValidationError: properties: detail: @@ -225,6 +267,19 @@ components: type: array title: HTTPValidationError type: object + KeyIDs: + description: User Creation Request Model. + properties: + ids: + items: + type: string + minItems: 1 + title: Ids + type: array + required: + - ids + title: KeyIDs + type: object Parent: properties: arr: @@ -476,6 +531,14 @@ components: title: update_custom_object_body_model type: object securitySchemes: + APIKeyHeader: + in: header + name: test_key + type: apiKey + APIKeyQuery: + in: query + name: test_key + type: apiKey HTTPBearer: scheme: bearer type: http @@ -836,7 +899,6 @@ paths: summary: /allow_other_root_access tags: - walker - - walker /walker/allow_other_root_access/{node}: post: operationId: api_entry_walker_allow_other_root_access__node__post @@ -876,7 +938,6 @@ paths: summary: /allow_other_root_access/{node} tags: - walker - - walker /walker/check_memory_sync: post: operationId: api_root_walker_check_memory_sync_post @@ -907,7 +968,6 @@ paths: summary: /check_memory_sync tags: - walker - - walker /walker/check_memory_sync/{node}: post: operationId: api_entry_walker_check_memory_sync__node__post @@ -947,7 +1007,6 @@ paths: summary: /check_memory_sync/{node} tags: - walker - - walker /walker/check_populated_graph: post: operationId: api_root_walker_check_populated_graph_post @@ -966,7 +1025,6 @@ paths: summary: /check_populated_graph tags: - walker - - walker /walker/check_populated_graph/{node}: post: operationId: api_entry_walker_check_populated_graph__node__post @@ -1000,7 +1058,6 @@ paths: summary: /check_populated_graph/{node} tags: - walker - - walker /walker/combination1: get: operationId: api_root_walker_combination1_get @@ -1044,7 +1101,6 @@ paths: summary: /combination1 tags: - walker - - walker post: operationId: api_root_walker_combination1_post parameters: @@ -1087,7 +1143,6 @@ paths: summary: /combination1 tags: - walker - - walker /walker/combination1/{node}: get: operationId: api_entry_walker_combination1__node__get @@ -1139,7 +1194,6 @@ paths: summary: /combination1/{node} tags: - walker - - walker post: operationId: api_entry_walker_combination1__node__post parameters: @@ -1190,7 +1244,6 @@ paths: summary: /combination1/{node} tags: - walker - - walker /walker/combination2/{a}: delete: operationId: api_root_walker_combination2__a__delete @@ -1234,7 +1287,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker get: operationId: api_root_walker_combination2__a__get parameters: @@ -1277,7 +1329,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker head: operationId: api_root_walker_combination2__a__head parameters: @@ -1320,7 +1371,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker options: operationId: api_root_walker_combination2__a__options parameters: @@ -1357,7 +1407,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker patch: operationId: api_root_walker_combination2__a__patch parameters: @@ -1400,7 +1449,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker post: operationId: api_root_walker_combination2__a__post parameters: @@ -1443,7 +1491,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker put: operationId: api_root_walker_combination2__a__put parameters: @@ -1486,7 +1533,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker trace: operationId: api_root_walker_combination2__a__trace parameters: @@ -1523,7 +1569,6 @@ paths: summary: /combination2/{a} tags: - walker - - walker /walker/combination2/{node}/{a}: delete: operationId: api_entry_walker_combination2__node___a__delete @@ -1575,7 +1620,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker get: operationId: api_entry_walker_combination2__node___a__get parameters: @@ -1626,7 +1670,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker head: operationId: api_entry_walker_combination2__node___a__head parameters: @@ -1677,7 +1720,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker options: operationId: api_entry_walker_combination2__node___a__options parameters: @@ -1722,7 +1764,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker patch: operationId: api_entry_walker_combination2__node___a__patch parameters: @@ -1773,7 +1814,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker post: operationId: api_entry_walker_combination2__node___a__post parameters: @@ -1824,7 +1864,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker put: operationId: api_entry_walker_combination2__node___a__put parameters: @@ -1875,7 +1914,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker trace: operationId: api_entry_walker_combination2__node___a__trace parameters: @@ -1920,7 +1958,6 @@ paths: summary: /combination2/{node}/{a} tags: - walker - - walker /walker/create_custom_object: post: operationId: api_root_walker_create_custom_object_post @@ -1939,7 +1976,6 @@ paths: summary: /create_custom_object tags: - walker - - walker /walker/create_custom_object/{node}: post: operationId: api_entry_walker_create_custom_object__node__post @@ -1973,7 +2009,6 @@ paths: summary: /create_custom_object/{node} tags: - walker - - walker /walker/create_graph: post: operationId: api_root_walker_create_graph_post @@ -1992,7 +2027,6 @@ paths: summary: /create_graph tags: - walker - - walker /walker/create_graph/{node}: post: operationId: api_entry_walker_create_graph__node__post @@ -2026,7 +2060,6 @@ paths: summary: /create_graph/{node} tags: - walker - - walker /walker/create_nested_node: post: operationId: api_root_walker_create_nested_node_post @@ -2045,7 +2078,6 @@ paths: summary: /create_nested_node tags: - walker - - walker /walker/create_nested_node/{node}: post: operationId: api_entry_walker_create_nested_node__node__post @@ -2079,7 +2111,6 @@ paths: summary: /create_nested_node/{node} tags: - walker - - walker /walker/custom_report: post: operationId: api_root_walker_custom_report_post @@ -2096,7 +2127,6 @@ paths: summary: /custom_report tags: - walker - - walker /walker/custom_report/{node}: post: operationId: api_entry_walker_custom_report__node__post @@ -2128,7 +2158,6 @@ paths: summary: /custom_report/{node} tags: - walker - - walker /walker/custom_status_code: post: operationId: api_root_walker_custom_status_code_post @@ -2159,7 +2188,6 @@ paths: summary: /custom_status_code tags: - walker - - walker /walker/custom_status_code/{node}: post: operationId: api_entry_walker_custom_status_code__node__post @@ -2199,7 +2227,6 @@ paths: summary: /custom_status_code/{node} tags: - walker - - walker /walker/delete_custom_object: post: operationId: api_root_walker_delete_custom_object_post @@ -2230,7 +2257,6 @@ paths: summary: /delete_custom_object tags: - walker - - walker /walker/delete_custom_object/{node}: post: operationId: api_entry_walker_delete_custom_object__node__post @@ -2270,7 +2296,6 @@ paths: summary: /delete_custom_object/{node} tags: - walker - - walker /walker/delete_nested_edge: post: operationId: api_root_walker_delete_nested_edge_post @@ -2289,7 +2314,6 @@ paths: summary: /delete_nested_edge tags: - walker - - walker /walker/delete_nested_edge/{node}: post: operationId: api_entry_walker_delete_nested_edge__node__post @@ -2323,7 +2347,6 @@ paths: summary: /delete_nested_edge/{node} tags: - walker - - walker /walker/delete_nested_node: post: operationId: api_root_walker_delete_nested_node_post @@ -2342,7 +2365,6 @@ paths: summary: /delete_nested_node tags: - walker - - walker /walker/delete_nested_node/{node}: post: operationId: api_entry_walker_delete_nested_node__node__post @@ -2376,7 +2398,6 @@ paths: summary: /delete_nested_node/{node} tags: - walker - - walker /walker/detach_nested_node: post: operationId: api_root_walker_detach_nested_node_post @@ -2395,7 +2416,6 @@ paths: summary: /detach_nested_node tags: - walker - - walker /walker/detach_nested_node/{node}: post: operationId: api_entry_walker_detach_nested_node__node__post @@ -2429,7 +2449,6 @@ paths: summary: /detach_nested_node/{node} tags: - walker - - walker /walker/detach_node: post: operationId: api_root_walker_detach_node_post @@ -2448,7 +2467,6 @@ paths: summary: /detach_node tags: - walker - - walker /walker/detach_node/{node}: post: operationId: api_entry_walker_detach_node__node__post @@ -2482,7 +2500,6 @@ paths: summary: /detach_node/{node} tags: - walker - - walker /walker/different_return: post: operationId: api_root_walker_different_return_post @@ -2499,7 +2516,6 @@ paths: summary: /different_return tags: - walker - - walker /walker/different_return/{node}: post: operationId: api_entry_walker_different_return__node__post @@ -2531,7 +2547,6 @@ paths: summary: /different_return/{node} tags: - walker - - walker /walker/disallow_other_root_access: post: operationId: api_root_walker_disallow_other_root_access_post @@ -2562,7 +2577,6 @@ paths: summary: /disallow_other_root_access tags: - walker - - walker /walker/disallow_other_root_access/{node}: post: operationId: api_entry_walker_disallow_other_root_access__node__post @@ -2602,7 +2616,6 @@ paths: summary: /disallow_other_root_access/{node} tags: - walker - - walker /walker/get_all_query: get: operationId: api_root_walker_get_all_query_get @@ -2638,7 +2651,6 @@ paths: summary: /get_all_query tags: - walker - - walker /walker/get_all_query/{node}: get: operationId: api_entry_walker_get_all_query__node__get @@ -2682,7 +2694,6 @@ paths: summary: /get_all_query/{node} tags: - walker - - walker /walker/get_custom_object: post: operationId: api_root_walker_get_custom_object_post @@ -2713,7 +2724,6 @@ paths: summary: /get_custom_object tags: - walker - - walker /walker/get_custom_object/{node}: post: operationId: api_entry_walker_get_custom_object__node__post @@ -2753,7 +2763,6 @@ paths: summary: /get_custom_object/{node} tags: - walker - - walker /walker/get_no_body: get: operationId: api_root_walker_get_no_body_get @@ -2772,7 +2781,6 @@ paths: summary: /get_no_body tags: - walker - - walker /walker/get_no_body/{node}: get: operationId: api_entry_walker_get_no_body__node__get @@ -2806,7 +2814,6 @@ paths: summary: /get_no_body/{node} tags: - walker - - walker /walker/get_with_query: get: operationId: api_root_walker_get_with_query_get @@ -2838,7 +2845,6 @@ paths: summary: /get_with_query tags: - walker - - walker /walker/get_with_query/{node}: get: operationId: api_entry_walker_get_with_query__node__get @@ -2878,7 +2884,6 @@ paths: summary: /get_with_query/{node} tags: - walker - - walker /walker/manual_create_nested_node: post: operationId: api_root_walker_manual_create_nested_node_post @@ -2897,7 +2902,6 @@ paths: summary: /manual_create_nested_node tags: - walker - - walker /walker/manual_create_nested_node/{node}: post: operationId: api_entry_walker_manual_create_nested_node__node__post @@ -2931,7 +2935,6 @@ paths: summary: /manual_create_nested_node/{node} tags: - walker - - walker /walker/manual_delete_nested_edge: post: operationId: api_root_walker_manual_delete_nested_edge_post @@ -2950,7 +2953,6 @@ paths: summary: /manual_delete_nested_edge tags: - walker - - walker /walker/manual_delete_nested_edge/{node}: post: operationId: api_entry_walker_manual_delete_nested_edge__node__post @@ -2984,7 +2986,6 @@ paths: summary: /manual_delete_nested_edge/{node} tags: - walker - - walker /walker/manual_delete_nested_node: post: operationId: api_root_walker_manual_delete_nested_node_post @@ -3003,7 +3004,6 @@ paths: summary: /manual_delete_nested_node tags: - walker - - walker /walker/manual_delete_nested_node/{node}: post: operationId: api_entry_walker_manual_delete_nested_node__node__post @@ -3037,7 +3037,6 @@ paths: summary: /manual_delete_nested_node/{node} tags: - walker - - walker /walker/manual_detach_nested_node: post: operationId: api_root_walker_manual_detach_nested_node_post @@ -3056,7 +3055,6 @@ paths: summary: /manual_detach_nested_node tags: - walker - - walker /walker/manual_detach_nested_node/{node}: post: operationId: api_entry_walker_manual_detach_nested_node__node__post @@ -3090,7 +3088,6 @@ paths: summary: /manual_detach_nested_node/{node} tags: - walker - - walker /walker/manual_update_nested_node: post: operationId: api_root_walker_manual_update_nested_node_post @@ -3109,7 +3106,6 @@ paths: summary: /manual_update_nested_node tags: - walker - - walker /walker/manual_update_nested_node/{node}: post: operationId: api_entry_walker_manual_update_nested_node__node__post @@ -3143,7 +3139,6 @@ paths: summary: /manual_update_nested_node/{node} tags: - walker - - walker /walker/populate_graph: post: operationId: api_root_walker_populate_graph_post @@ -3162,7 +3157,6 @@ paths: summary: /populate_graph tags: - walker - - walker /walker/populate_graph/{node}: post: operationId: api_entry_walker_populate_graph__node__post @@ -3196,7 +3190,6 @@ paths: summary: /populate_graph/{node} tags: - walker - - walker /walker/post_all_excluded: post: operationId: api_root_walker_post_all_excluded_post @@ -3213,7 +3206,6 @@ paths: summary: /post_all_excluded tags: - walker - - walker /walker/post_all_excluded/{node}: post: operationId: api_entry_walker_post_all_excluded__node__post @@ -3245,7 +3237,6 @@ paths: summary: /post_all_excluded/{node} tags: - walker - - walker /walker/post_no_body: post: operationId: api_root_walker_post_no_body_post @@ -3264,7 +3255,6 @@ paths: summary: /post_no_body tags: - walker - - walker /walker/post_no_body/{node}: post: operationId: api_entry_walker_post_no_body__node__post @@ -3298,7 +3288,6 @@ paths: summary: /post_no_body/{node} tags: - walker - - walker /walker/post_partial_excluded: post: operationId: api_root_walker_post_partial_excluded_post @@ -3329,7 +3318,6 @@ paths: summary: /post_partial_excluded tags: - walker - - walker /walker/post_partial_excluded/{node}: post: operationId: api_entry_walker_post_partial_excluded__node__post @@ -3369,7 +3357,6 @@ paths: summary: /post_partial_excluded/{node} tags: - walker - - walker /walker/post_path_var/{a}: get: operationId: api_root_walker_post_path_var__a__get @@ -3401,7 +3388,6 @@ paths: summary: /post_path_var/{a} tags: - walker - - walker post: operationId: api_root_walker_post_path_var__a__post parameters: @@ -3432,7 +3418,6 @@ paths: summary: /post_path_var/{a} tags: - walker - - walker /walker/post_path_var/{node}/{a}: get: operationId: api_entry_walker_post_path_var__node___a__get @@ -3472,7 +3457,6 @@ paths: summary: /post_path_var/{node}/{a} tags: - walker - - walker post: operationId: api_entry_walker_post_path_var__node___a__post parameters: @@ -3511,7 +3495,6 @@ paths: summary: /post_path_var/{node}/{a} tags: - walker - - walker /walker/post_with_body: post: operationId: api_root_walker_post_with_body_post @@ -3542,7 +3525,6 @@ paths: summary: /post_with_body tags: - walker - - walker /walker/post_with_body/{node}: post: operationId: api_entry_walker_post_with_body__node__post @@ -3582,7 +3564,6 @@ paths: summary: /post_with_body/{node} tags: - walker - - walker /walker/post_with_body_and_file: post: operationId: api_root_walker_post_with_body_and_file_post @@ -3611,7 +3592,6 @@ paths: summary: /post_with_body_and_file tags: - walker - - walker /walker/post_with_body_and_file/{node}: post: operationId: api_entry_walker_post_with_body_and_file__node__post @@ -3649,7 +3629,6 @@ paths: summary: /post_with_body_and_file/{node} tags: - walker - - walker /walker/post_with_file: post: operationId: api_root_walker_post_with_file_post @@ -3680,7 +3659,6 @@ paths: summary: /post_with_file tags: - walker - - walker /walker/post_with_file/{node}: post: operationId: api_entry_walker_post_with_file__node__post @@ -3720,7 +3698,6 @@ paths: summary: /post_with_file/{node} tags: - walker - - walker /walker/purge_populated_graph: post: operationId: api_root_walker_purge_populated_graph_post @@ -3739,7 +3716,6 @@ paths: summary: /purge_populated_graph tags: - walker - - walker /walker/purge_populated_graph/{node}: post: operationId: api_entry_walker_purge_populated_graph__node__post @@ -3773,7 +3749,6 @@ paths: summary: /purge_populated_graph/{node} tags: - walker - - walker /walker/traverse_graph: post: operationId: api_root_walker_traverse_graph_post @@ -3792,7 +3767,6 @@ paths: summary: /traverse_graph tags: - walker - - walker /walker/traverse_graph/{node}: post: operationId: api_entry_walker_traverse_graph__node__post @@ -3826,7 +3800,6 @@ paths: summary: /traverse_graph/{node} tags: - walker - - walker /walker/traverse_populated_graph: post: operationId: api_root_walker_traverse_populated_graph_post @@ -3845,7 +3818,6 @@ paths: summary: /traverse_populated_graph tags: - walker - - walker /walker/traverse_populated_graph/{node}: post: operationId: api_entry_walker_traverse_populated_graph__node__post @@ -3879,7 +3851,6 @@ paths: summary: /traverse_populated_graph/{node} tags: - walker - - walker /walker/update_custom_object: post: operationId: api_root_walker_update_custom_object_post @@ -3910,7 +3881,6 @@ paths: summary: /update_custom_object tags: - walker - - walker /walker/update_custom_object/{node}: post: operationId: api_entry_walker_update_custom_object__node__post @@ -3950,7 +3920,6 @@ paths: summary: /update_custom_object/{node} tags: - walker - - walker /walker/update_graph: post: operationId: api_root_walker_update_graph_post @@ -3969,7 +3938,6 @@ paths: summary: /update_graph tags: - walker - - walker /walker/update_graph/{node}: post: operationId: api_entry_walker_update_graph__node__post @@ -4003,7 +3971,6 @@ paths: summary: /update_graph/{node} tags: - walker - - walker /walker/update_nested_node: post: operationId: api_root_walker_update_nested_node_post @@ -4022,7 +3989,6 @@ paths: summary: /update_nested_node tags: - walker - - walker /walker/update_nested_node/{node}: post: operationId: api_entry_walker_update_nested_node__node__post @@ -4056,7 +4022,6 @@ paths: summary: /update_nested_node/{node} tags: - walker - - walker /walker/visit_nested_node: post: operationId: api_root_walker_visit_nested_node_post @@ -4075,7 +4040,6 @@ paths: summary: /visit_nested_node tags: - walker - - walker /walker/visit_nested_node/{node}: post: operationId: api_entry_walker_visit_nested_node__node__post @@ -4109,4 +4073,350 @@ paths: summary: /visit_nested_node/{node} tags: - walker - - walker \ No newline at end of file + /walker/visit_sequence: + post: + operationId: api_root_walker_visit_sequence_post + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Root Walker Visit Sequence Post + description: Successful Response + summary: /visit_sequence + tags: + - walker + /walker/visit_sequence/{node}: + post: + operationId: api_entry_walker_visit_sequence__node__post + parameters: + - in: path + name: node + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Node + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Entry Walker Visit Sequence Node Post + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: /visit_sequence/{node} + tags: + - walker + /webhook: + get: + description: Get keys API. + operationId: get_webhook_get + responses: + '200': + content: + application/json: + schema: {} + description: Successful Response + security: + - HTTPBearer: [] + summary: Get + tags: + - webhook + /webhook/delete: + delete: + description: Delete keys API. + operationId: delete_webhook_delete_delete + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KeyIDs' + required: true + responses: + '200': + content: + application/json: + schema: {} + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + security: + - HTTPBearer: [] + summary: Delete + tags: + - webhook + /webhook/extend/{id}: + patch: + description: Generate key API. + operationId: extend_webhook_extend__id__patch + parameters: + - in: path + name: id + required: true + schema: + title: Id + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Expiration' + required: true + responses: + '201': + content: + application/json: + schema: {} + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + security: + - HTTPBearer: [] + summary: Extend + tags: + - webhook + /webhook/generate-key: + post: + description: Generate key API. + operationId: generate_key_webhook_generate_key_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateKey' + required: true + responses: + '201': + content: + application/json: + schema: {} + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + security: + - HTTPBearer: [] + summary: Generate Key + tags: + - webhook + /webhook/walker/webhook_by_body: + post: + operationId: api_root_webhook_walker_webhook_by_body_post + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Root Webhook Walker Webhook By Body Post + description: Successful Response + summary: /webhook_by_body + tags: + - webhook-walker + /webhook/walker/webhook_by_body/{node}: + post: + operationId: api_entry_webhook_walker_webhook_by_body__node__post + parameters: + - in: path + name: node + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Node + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Entry Webhook Walker Webhook By Body Node Post + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: /webhook_by_body/{node} + tags: + - webhook-walker + /webhook/walker/webhook_by_header: + post: + operationId: api_root_webhook_walker_webhook_by_header_post + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Root Webhook Walker Webhook By Header Post + description: Successful Response + security: + - APIKeyHeader: [] + summary: /webhook_by_header + tags: + - webhook-walker + /webhook/walker/webhook_by_header/{node}: + post: + operationId: api_entry_webhook_walker_webhook_by_header__node__post + parameters: + - in: path + name: node + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Node + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Entry Webhook Walker Webhook By Header Node Post + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + security: + - APIKeyHeader: [] + summary: /webhook_by_header/{node} + tags: + - webhook-walker + /webhook/walker/webhook_by_path/{node}/{test_key}: + post: + operationId: api_entry_webhook_walker_webhook_by_path__node___test_key__post + parameters: + - in: path + name: node + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Node + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Entry Webhook Walker Webhook By Path Node Test + Key Post + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: /webhook_by_path/{node}/{test_key} + tags: + - webhook-walker + /webhook/walker/webhook_by_path/{test_key}: + post: + operationId: api_root_webhook_walker_webhook_by_path__test_key__post + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Root Webhook Walker Webhook By Path Test Key Post + description: Successful Response + summary: /webhook_by_path/{test_key} + tags: + - webhook-walker + /webhook/walker/webhook_by_query: + post: + operationId: api_root_webhook_walker_webhook_by_query_post + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Root Webhook Walker Webhook By Query Post + description: Successful Response + security: + - APIKeyQuery: [] + summary: /webhook_by_query + tags: + - webhook-walker + /webhook/walker/webhook_by_query/{node}: + post: + operationId: api_entry_webhook_walker_webhook_by_query__node__post + parameters: + - in: path + name: node + required: true + schema: + anyOf: + - type: string + - type: 'null' + title: Node + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ContextResponse_NoneType_' + - {} + title: Response Api Entry Webhook Walker Webhook By Query Node Post + description: Successful Response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + security: + - APIKeyQuery: [] + summary: /webhook_by_query/{node} + tags: + - webhook-walker \ No newline at end of file diff --git a/jac-cloud/jac_cloud/tests/simple.json b/jac-cloud/jac_cloud/tests/simple.json new file mode 100644 index 0000000000..823aeae850 --- /dev/null +++ b/jac-cloud/jac_cloud/tests/simple.json @@ -0,0 +1,3 @@ +{ + "test": "success" +} \ No newline at end of file diff --git a/jac-cloud/jac_cloud/tests/simple_graph.jac b/jac-cloud/jac_cloud/tests/simple_graph.jac index 62437e93ed..ba89a6080d 100644 --- a/jac-cloud/jac_cloud/tests/simple_graph.jac +++ b/jac-cloud/jac_cloud/tests/simple_graph.jac @@ -803,4 +803,114 @@ walker delete_custom_object { # The only difference is BaseAnchor.ref doesn't # load the actual object and just use it as reference } +} + +################################################################## +# FOR SPAWN CALL SEQUENCE # +################################################################## + +node Node { + has val: str; + + can entry1 with entry { + return f"{self.val}-2"; + } + + can entry3 with visit_sequence entry { + return f"{self.val}-3"; + } + + can exit1 with visit_sequence exit { + return f"{self.val}-4"; + } + + can exit2 with exit { + return f"{self.val}-5"; + } +} + +walker visit_sequence { + can entry1 with entry { + return "walker entry"; + } + + can entry2 with `root entry { + here ++> Node(val = "a"); + here ++> Node(val = "b"); + here ++> Node(val = "c"); + visit [-->]; + return "walker enter to root"; + } + + can entry3 with Node entry { + return f"{here.val}-1"; + } + + can exit1 with Node exit { + return f"{here.val}-6"; + } + + can exit2 with exit { + return "walker exit"; + } + + class __specs__ { + has auth: bool = False; + } +} + +################################################################# +# WEBHOOKS # +################################################################# + +walker webhook_by_header { + can enter1 with `root entry { + report True; + } + + class __specs__ { + has webhook: dict = { + "type": "header", + "name": "test_key" + }; + } +} + +walker webhook_by_query { + can enter1 with `root entry { + report True; + } + + class __specs__ { + has webhook: dict = { + "type": "query", + "name": "test_key" + }; + } +} + +walker webhook_by_path { + can enter1 with `root entry { + report True; + } + + class __specs__ { + has webhook: dict = { + "type": "path", + "name": "test_key" + }, path: str = "/{test_key}"; + } +} + +walker webhook_by_body { + can enter1 with `root entry { + report True; + } + + class __specs__ { + has webhook: dict = { + "type": "body", + "name": "test_key" + }; + } } \ No newline at end of file diff --git a/jac-cloud/jac_cloud/tests/test_simple_graph.py b/jac-cloud/jac_cloud/tests/test_simple_graph.py index 2ed04f080f..1983986f1e 100644 --- a/jac-cloud/jac_cloud/tests/test_simple_graph.py +++ b/jac-cloud/jac_cloud/tests/test_simple_graph.py @@ -537,11 +537,11 @@ def trigger_custom_report(self) -> None: def trigger_upload_file(self) -> None: """Test upload file.""" - with open("jac_cloud/tests/simple_graph.jac", mode="br") as s: + with open("jac_cloud/tests/simple.json", mode="br") as s: files = [ - ("single", ("simple_graph.jac", s)), - ("multiple", ("simple_graph.jac", s)), - ("multiple", ("simple_graph.jac", s)), + ("single", ("simple.json", s)), + ("multiple", ("simple.json", s)), + ("multiple", ("simple.json", s)), ] res = post( f"{self.host}/walker/post_with_file", @@ -558,21 +558,21 @@ def trigger_upload_file(self) -> None: { "single": { "single": { - "name": "simple_graph.jac", - "content_type": "application/octet-stream", - "size": 17658, + "name": "simple.json", + "content_type": "application/json", + "size": 25, } }, "multiple": [ { - "name": "simple_graph.jac", - "content_type": "application/octet-stream", - "size": 17658, + "name": "simple.json", + "content_type": "application/json", + "size": 25, }, { - "name": "simple_graph.jac", - "content_type": "application/octet-stream", - "size": 17658, + "name": "simple.json", + "content_type": "application/json", + "size": 25, }, ], "singleOptional": None, @@ -761,6 +761,74 @@ def trigger_delete_custom_object_test(self, obj_id: str) -> None: self.assertEqual(200, res["status"]) self.assertIsNone(obj) + def trigger_visit_sequence(self) -> None: + """Test visit sequence.""" + res = self.post_api("visit_sequence") + + self.assertEqual(200, res["status"]) + self.assertEqual( + [ + "walker entry", + "walker enter to root", + "a-1", + "a-2", + "a-3", + "a-4", + "a-5", + "a-6", + "b-1", + "b-2", + "b-3", + "b-4", + "b-5", + "b-6", + "c-1", + "c-2", + "c-3", + "c-4", + "c-5", + "c-6", + "walker exit", + ], + res["returns"], + ) + + def trigger_webhook_test(self) -> None: + """Test webhook.""" + res = post( + f"{self.host}/webhook/generate-key", + json={ + "name": "test", + "walkers": [], + "nodes": [], + "expiration": {"count": 60, "interval": "days"}, + }, + headers=self.users[0]["headers"], + ) + + res.raise_for_status() + key = res.json()["key"] + + self.assertEqual( + {"status": 200, "reports": [True], "returns": [None]}, + self.post_webhook("webhook_by_header", headers={"test_key": key}), + ) + + self.assertEqual( + {"status": 200, "reports": [True], "returns": [None]}, + self.post_webhook(f"webhook_by_query?test_key={key}"), + ) + + self.assertEqual( + {"status": 200, "reports": [True], "returns": [None]}, + self.post_webhook(f"webhook_by_path/{key}"), + ) + + self.assertEqual( + {"status": 200, "reports": [True], "returns": [None]}, + self.post_webhook("webhook_by_body", {"test_key": key}), + ) + def test_all_features(self) -> None: """Test Full Features.""" self.trigger_openapi_specs_test() @@ -864,10 +932,22 @@ def test_all_features(self) -> None: self.trigger_memory_sync() - ################################################## + ################################################### # SAVABLE OBJECT # ################################################### obj_id = self.trigger_create_custom_object_test() self.trigger_update_custom_object_test(obj_id) self.trigger_delete_custom_object_test(obj_id) + + ################################################### + # VISIT SEQUENCE # + ################################################### + + self.trigger_visit_sequence() + + ################################################### + # WEBHOOK # + ################################################### + + self.trigger_webhook_test() diff --git a/jac-cloud/jac_cloud/tests/test_utils.py b/jac-cloud/jac_cloud/tests/test_utils.py index 6f50ecff2f..fb29f28cb8 100644 --- a/jac-cloud/jac_cloud/tests/test_utils.py +++ b/jac-cloud/jac_cloud/tests/test_utils.py @@ -112,3 +112,12 @@ def post_api( return res.json() else: return res.status_code + + def post_webhook( + self, api: str, json: dict | None = None, headers: dict | None = None + ) -> dict: + """Call walker post API.""" + res = post(f"{self.host}/webhook/walker/{api}", json=json, headers=headers) + + res.raise_for_status() + return res.json() diff --git a/jac-cloud/setup.py b/jac-cloud/setup.py index de95f815da..2b5c4e56bd 100644 --- a/jac-cloud/setup.py +++ b/jac-cloud/setup.py @@ -2,7 +2,7 @@ from setuptools import find_packages, setup -VERSION = "0.1.15" +VERSION = "0.1.17" setup( name="jac-cloud", diff --git a/jac-mtllm/mtllm/aott.py b/jac-mtllm/mtllm/aott.py index f39c5d6e4c..b3ae7f54b3 100644 --- a/jac-mtllm/mtllm/aott.py +++ b/jac-mtllm/mtllm/aott.py @@ -131,7 +131,27 @@ def aott_raise( if not (contains_media and not is_custom) else meaning_typed_input_list ) - return model(meaning_typed_input, media=media, **model_params) # type: ignore + if is_custom: + try: + # This is a temporary solution to enable passing in custom + # parameters to custom models + # custom model should override the __call__ method to + # accept function_inputs parameter + return model( + meaning_typed_input, # type: ignore + media=media, # type: ignore + function_inputs=inputs_information, # type: ignore + **model_params, + ) + except TypeError: + # this is for backward compatibility, + # for any existing custom models that do not have the + # function_inputs parameter + return model( + meaning_typed_input, media=media, **model_params # type: ignore + ) + else: + return model(meaning_typed_input, media=media, **model_params) # type: ignore def execute_react( diff --git a/jac-splice-orc/ReadMe.md b/jac-splice-orc/README.md similarity index 79% rename from jac-splice-orc/ReadMe.md rename to jac-splice-orc/README.md index fea42303e9..ae33efd34c 100644 --- a/jac-splice-orc/ReadMe.md +++ b/jac-splice-orc/README.md @@ -18,7 +18,8 @@ JAC Cloud Orchestrator (`jac-splice-orc`) is a system designed to dynamically im - [1. Clone the Repository](#1-clone-the-repository) - [2. Install Dependencies](#2-install-dependencies) - [3. Configure the System](#3-configure-the-system) - - [4. Initialize the System](#4-initialize-the-system) + - [4. Recreate the Kind Cluster with Port Mappings](#4-recreate-the-kind-cluster-with-port-mappings) + - [5. Initialize the System](#5-initialize-the-system) - [Docker Usage](#docker-usage) - [Usage](#usage) - [Client Application](#client-application) @@ -31,8 +32,6 @@ JAC Cloud Orchestrator (`jac-splice-orc`) is a system designed to dynamically im - [Troubleshooting](#troubleshooting) - [Flow Diagram](#flow-diagram) - [Notes](#notes) -- [Contributing](#contributing) -- [License](#license) --- @@ -129,25 +128,15 @@ jac-splice-orc/ Before you begin, ensure that you have the following installed and configured: -- **Python** (version 3.9 or later): [Install Python](https://www.python.org/downloads/) -- **Docker** (version 20.10 or later): [Install Docker](https://docs.docker.com/get-docker/) -- **Kubernetes** (version 1.21 or later): [Install Kubernetes](https://kubernetes.io/docs/setup/) -- **kubectl** command-line tool: [Install kubectl](https://kubernetes.io/docs/tasks/tools/) -- **Jac**: [Install Jaclang](https://github.com/Jaseci-Labs/jasecii) +- **Python** (version 3.11 or later) +- **Docker** (version 20.10 or later) +- **Kubernetes** (version 1.21 or later) +- **kubectl** command-line tool - **Kubernetes Cluster**: Ensure you have access to a Kubernetes cluster (local or remote). Ensure that your Kubernetes cluster is up and running, and that you can connect to it using `kubectl`. -### 1. Clone the Repository - -Clone the `jac-splice-orc` repository to your local machine: - -```bash -git clone https://github.com/Jaseci-Labs/jac-splice-orc.git -cd jac-splice-orc -``` - -### 2. Install Dependencies +### 1. Install Dependencies Create a virtual environment and install the required Python packages: @@ -156,9 +145,15 @@ python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate pip install -r requirements.txt ``` - **Note**: The `requirements.txt` file includes all necessary dependencies, such as `kubernetes`, `grpcio`, `PyYAML`, and others. +### 2. Install via pip + +You can install `jac-splice-orc` directly from PyPI: +```bash +pip install jac-splice-orc +``` + ### 3. Configure the System The application uses a `config.json` file located in the `jac_splice_orc/config/` directory for all configurations. @@ -217,11 +212,97 @@ Edit `jac_splice_orc/config/config.json` to match your environment. Here's an ex - Replace `jaseci/jac-splice-orc:latest` with your own image if you have customized it. - Adjust resource requests and limits according to your environment. -### 4. Initialize the System +### 4. Recreate the Kind Cluster with Port Mappings + +To ensure that your Kubernetes cluster can expose services correctly, especially when using **Kind** (Kubernetes IN Docker), you need to recreate the Kind cluster with specific port mappings. This allows services like the Pod Manager to be accessible from your host machine without relying solely on port-forwarding. + +**Why Recreate the Kind Cluster?** + +- **Port Accessibility**: By mapping container ports to host ports, you can access Kubernetes services directly via `localhost:` on your machine. +- **Simplified Access**: Eliminates the need for manual port-forwarding or additional networking configurations. + +**Steps to Recreate the Kind Cluster with Port Mappings:** + +1. **Delete the Existing Kind Cluster** + + If you already have a Kind cluster running, delete it to allow recreation with new configurations. + + ```bash + kind delete cluster --name little-x-kind + ``` + + **Note**: Replace `jac-splice-orc with your cluster name if different. + +2. **Create a Kind Configuration File** + + Create a YAML configuration file named `kind-config.yaml` with the desired port mappings. This file instructs Kind to map specific container ports to host ports. + + ```yaml + kind: Cluster + apiVersion: kind.x-k8s.io/v1alpha4 + nodes: + - role: control-plane + extraPortMappings: + - containerPort: 30080 + hostPort: 30080 + protocol: TCP + ``` + + **Explanation:** + + - **containerPort**: The port inside the Kubernetes cluster (i.e., the port your service listens on). + - **hostPort**: The port on your local machine that maps to the `containerPort`. + - **protocol**: The network protocol (`TCP` or `UDP`). + +3. **Create the New Kind Cluster with Port Mappings** + + Use the `kind-config.yaml` to create a new Kind cluster with the specified port mappings. + + ```bash + kind create cluster --name little-x-kind --config kind-config.yaml + ``` + + **Output Example:** + + ``` + Creating cluster "little-x-kind" ... + ✓ Ensuring node image (kindest/node:v1.21.1) đŸ–ŧ + ✓ Preparing nodes đŸ“Ļ + ✓ Writing configuration 📜 + ✓ Starting control-plane node kind-control-plane 🕹ī¸ + ✓ Installing CNI 🔌 + ✓ Installing StorageClass 💾 + Set kubectl context to "kind-little-x-kind" + You can now use your cluster with: + + kubectl cluster-info --context kind-little-x-kind + + Thanks for using Kind! 🎉 + ``` + + +### Summary of Steps: + +1. **Delete Existing Cluster**: `kind delete cluster --name jac-splice-orc +2. **Create Config File**: Define `kind-config.yaml` with desired port mappings. +3. **Create New Cluster**: `kind create cluster --name little-x-kind --config kind-config.yaml` +4. **Verify Mappings**: Ensure ports are correctly mapped using `kubectl` and `docker` commands. + +**Important Considerations:** + +- **Port Conflicts**: Ensure that the `hostPort` values you choose are not already in use on your host machine. +- **Cluster Name**: Adjust the cluster name (`jac-splice-orc) as per your preference or organizational standards. +- **Security**: Exposing ports directly to `localhost` can have security implications. Ensure that only necessary ports are exposed and consider implementing authentication or network policies if needed. + +--- + +### 5. Initialize the System + +Once the cluster is set up with the appropriate port mappings, proceed to initialize the Pod Manager and Kubernetes resources. Use the provided CLI command to initialize the Pod Manager and Kubernetes resources: -```bash +```jac jac orc_initialize jac-splice-orc ``` diff --git a/jac-splice-orc/setup.py b/jac-splice-orc/setup.py index 2d39b0813d..e761fdb0bc 100644 --- a/jac-splice-orc/setup.py +++ b/jac-splice-orc/setup.py @@ -1,26 +1,35 @@ +import os from setuptools import setup, find_packages +# Read the long description from README +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, "README.md"), encoding="utf-8") as f: + long_description = f.read() + setup( name="jac-splice-orc", - version="0.1.0", + version="0.1.4", description="JAC Splice-Orchestrator: Kubernetes-based dynamic remote module management for JacLang", author="Jason Mars", author_email="jason@jaseci.org", + long_description=long_description, + long_description_content_type="text/markdown", packages=find_packages(), include_package_data=True, package_data={ "jac_splice_orc.config": ["config.json"], }, install_requires=[ - "fastapi", - "uvicorn", - "grpcio", - "grpcio-tools", - "kubernetes", - "pydantic", - "requests", - "python-dotenv", - "numpy", + "fastapi~=0.111.0", + "uvicorn~=0.30.1", + "grpcio~=1.68.1", + "grpcio-tools~=1.67.1", + "kubernetes~=31.0.0", + "pydantic~=2.8.2", + "requests~=2.32.3", + "python-dotenv~=1.0.1", + "numpy~=2.0.1", + "jaclang~=0.7.27", ], entry_points={ "jac": [ @@ -28,4 +37,9 @@ ], }, python_requires=">=3.11", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], ) diff --git a/jac/jaclang/cli/cli.py b/jac/jaclang/cli/cli.py index a16c288443..3f2782e4df 100644 --- a/jac/jaclang/cli/cli.py +++ b/jac/jaclang/cli/cli.py @@ -307,6 +307,7 @@ def enter( @cmd_registry.register def test( filepath: str, + test_name: str = "", filter: str = "", xit: bool = False, maxfail: int = None, # type:ignore @@ -316,6 +317,7 @@ def test( """Run the test suite in the specified .jac file. :param filepath: Path/to/file.jac + :param test_name: Run a specific test. :param filter: Filter the files using Unix shell style conventions. :param xit(exit): Stop(exit) running tests as soon as finds an error. :param maxfail: Stop running tests after n failures. @@ -328,6 +330,7 @@ def test( failcount = Jac.run_test( filepath=filepath, + func_name=("test_" + test_name) if test_name else None, filter=filter, xit=xit, maxfail=maxfail, diff --git a/jac/jaclang/compiler/absyntree.py b/jac/jaclang/compiler/absyntree.py index bda8833dec..9617950fca 100644 --- a/jac/jaclang/compiler/absyntree.py +++ b/jac/jaclang/compiler/absyntree.py @@ -697,14 +697,16 @@ def unparse(self) -> str: def get_href_path(node: AstNode) -> str: """Return the full path of the module that contains this node.""" parent = node.find_parent_of_type(Module) - mod_list = [] - if isinstance(node, Module): + mod_list: list[Module | Architype] = [] + if isinstance(node, (Module, Architype)): mod_list.append(node) while parent is not None: mod_list.append(parent) parent = parent.find_parent_of_type(Module) mod_list.reverse() - return ".".join(p.name for p in mod_list) + return ".".join( + p.name if isinstance(p, Module) else p.name.sym_name for p in mod_list + ) class GlobalVars(ElementStmt, AstAccessNode): diff --git a/jac/jaclang/compiler/passes/main/import_pass.py b/jac/jaclang/compiler/passes/main/import_pass.py index 6471440dac..618f987a22 100644 --- a/jac/jaclang/compiler/passes/main/import_pass.py +++ b/jac/jaclang/compiler/passes/main/import_pass.py @@ -16,6 +16,7 @@ from jaclang.compiler.passes import Pass from jaclang.compiler.passes.main import DefUsePass, SubNodeTabPass, SymTabBuildPass from jaclang.compiler.passes.main.sym_tab_build_pass import PyInspectSymTabBuildPass +from jaclang.compiler.symtable import Symbol, SymbolTable from jaclang.settings import settings from jaclang.utils.log import logging @@ -295,7 +296,6 @@ def __import_from_symbol_table_build(self) -> None: is_symbol_tabled_refreshed: list[str] = [] self.import_from_build_list.reverse() for imp_node, imported_mod in self.import_from_build_list: - # Need to build the symbol tables again to make sure that the # complete symbol table is built. # @@ -319,35 +319,47 @@ def __import_from_symbol_table_build(self) -> None: sym_tab = imported_mod.sym_tab parent_sym_tab = imp_node.parent_of_type(ast.Module).sym_tab - for i in imp_node.items.items: - assert isinstance(i, ast.ModuleItem) - needed_sym = sym_tab.lookup(i.name.sym_name) - - if needed_sym and needed_sym.defn[0].parent: - self.__debug_print( - f"\tAdding {needed_sym.sym_type}:{needed_sym.sym_name} into {parent_sym_tab.name}" - ) - assert isinstance(needed_sym.defn[0], ast.AstSymbolNode) - parent_sym_tab.def_insert( - node=needed_sym.defn[0], - access_spec=needed_sym.access, - force_overwrite=True, - ) + if imp_node.is_absorb: + for symbol in sym_tab.tab.values(): + if symbol.sym_type == SymbolType.MODULE: + continue + self.__import_from_sym_table_add_symbols(symbol, parent_sym_tab) + else: + for i in imp_node.items.items: + assert isinstance(i, ast.ModuleItem) + needed_sym = sym_tab.lookup(i.name.sym_name) - if needed_sym.fetch_sym_tab: - msg = f"\tAdding SymbolTable:{needed_sym.fetch_sym_tab.name} into " - msg += f"SymbolTable:{parent_sym_tab.name} kids" - self.__debug_print(msg) - parent_sym_tab.kid.append(needed_sym.fetch_sym_tab) - elif needed_sym.sym_type != SymbolType.VAR: - raise AssertionError( - "Unexpected symbol type that doesn't have a symbl table" + if needed_sym and needed_sym.defn[0].parent: + self.__import_from_sym_table_add_symbols( + needed_sym, parent_sym_tab + ) + else: + self.__debug_print( + f"Can't find a symbol matching {i.name.sym_name} in {sym_tab.name}" ) - else: - self.__debug_print( - f"Can't find a symbol matching {i.name.sym_name} in {sym_tab.name}" - ) + def __import_from_sym_table_add_symbols( + self, sym: Symbol, sym_table: SymbolTable + ) -> None: + self.__debug_print( + f"\tAdding {sym.sym_type}:{sym.sym_name} into {sym_table.name}" + ) + assert isinstance(sym.defn[0], ast.AstSymbolNode) + sym_table.def_insert( + node=sym.defn[0], + access_spec=sym.access, + force_overwrite=True, + ) + + if sym.fetch_sym_tab: + msg = f"\tAdding SymbolTable:{sym.fetch_sym_tab.name} into " + msg += f"SymbolTable:{sym_table.name} kids" + self.__debug_print(msg) + sym_table.kid.append(sym.fetch_sym_tab) + elif sym.sym_type not in (SymbolType.VAR, SymbolType.MOD_VAR): + raise AssertionError( + f"Unexpected symbol type '{sym.sym_type}' that doesn't have a symbl table" + ) def __process_import(self, imp_node: ast.Import) -> None: """Process the imports in form of `import X`.""" @@ -379,14 +391,34 @@ def __process_import(self, imp_node: ast.Import) -> None: f"\tIgnoring attaching builtins {imp_node.loc.mod_path} {imp_node.loc}" ) return + self.__debug_print( f"\tAttaching {imported_mod.name} into {ast.Module.get_href_path(imp_node)}" ) self.attach_mod_to_node(imported_item, imported_mod) - self.__debug_print( - f"\tBuilding symbol table for module:{ast.Module.get_href_path(imported_mod)}" - ) - SymTabBuildPass(input_ir=imported_mod, prior=self) + + if imp_node.is_absorb: + msg = f"\tRegistering module:{imported_mod.name} to " + msg += f"import_from (import all) handling with {imp_node.loc.mod_path}:{imp_node.loc}" + self.__debug_print(msg) + + self.import_from_build_list.append((imp_node, imported_mod)) + if imported_mod._sym_tab is None: + self.__debug_print( + f"\tBuilding symbol table for module:{ast.Module.get_href_path(imported_mod)}" + ) + else: + self.__debug_print( + f"\tRefreshing symbol table for module:{ast.Module.get_href_path(imported_mod)}" + ) + PyInspectSymTabBuildPass(input_ir=imported_mod, prior=self) + DefUsePass(input_ir=imported_mod, prior=self) + + else: + self.__debug_print( + f"\tBuilding symbol table for module:{ast.Module.get_href_path(imported_mod)}" + ) + SymTabBuildPass(input_ir=imported_mod, prior=self) def __import_py_module( self, diff --git a/jac/jaclang/compiler/passes/main/inheritance_pass.py b/jac/jaclang/compiler/passes/main/inheritance_pass.py new file mode 100644 index 0000000000..8f4ba95877 --- /dev/null +++ b/jac/jaclang/compiler/passes/main/inheritance_pass.py @@ -0,0 +1,103 @@ +"""Pass used to add the inherited symbols for architypes.""" + +from __future__ import annotations + +from typing import Optional + +import jaclang.compiler.absyntree as ast +from jaclang.compiler.passes import Pass +from jaclang.compiler.symtable import Symbol, SymbolTable +from jaclang.settings import settings + + +class InheritancePass(Pass): + """Add inherited abilities in the target symbol tables.""" + + def __debug_print(self, msg: str) -> None: + if settings.inherit_pass_debug: + self.log_info("[PyImportPass] " + msg) + + def __lookup(self, name: str, sym_table: SymbolTable) -> Optional[Symbol]: + symbol = sym_table.lookup(name) + if symbol is None: + # Check if the needed symbol in builtins + builtins_symtable = self.ir.sym_tab.find_scope("builtins") + assert builtins_symtable is not None + symbol = builtins_symtable.lookup(name) + return symbol + + def enter_architype(self, node: ast.Architype) -> None: + """Fill architype symbol tables with abilities from parent architypes.""" + if node.base_classes is None: + return + + for item in node.base_classes.items: + # The assumption is that the base class can only be a name node + # or an atom trailer only. + assert isinstance(item, (ast.Name, ast.AtomTrailer)) + + # In case of name node, then get the symbol table that contains + # the current class and lookup for that name after that use the + # symbol to get the symbol table of the base class + if isinstance(item, ast.Name): + assert node.sym_tab.parent is not None + base_class_symbol = self.__lookup(item.sym_name, node.sym_tab.parent) + if base_class_symbol is None: + msg = "Missing symbol for base class " + msg += f"{ast.Module.get_href_path(item)}.{item.sym_name}" + msg += f" needed for {ast.Module.get_href_path(node)}" + self.__debug_print(msg) + continue + base_class_symbol_table = base_class_symbol.fetch_sym_tab + if ( + base_class_symbol_table is None + and base_class_symbol.defn[0] + .parent_of_type(ast.Module) + .py_info.is_raised_from_py + ): + msg = "Missing symbol table for python base class " + msg += f"{ast.Module.get_href_path(item)}.{item.sym_name}" + msg += f" needed for {ast.Module.get_href_path(node)}" + self.__debug_print(msg) + continue + assert base_class_symbol_table is not None + node.sym_tab.inherit_sym_tab(base_class_symbol_table) + + # In case of atom trailer, unwind it and use each name node to + # as the code above to lookup for the base class + elif isinstance(item, ast.AtomTrailer): + current_sym_table = node.sym_tab.parent + not_found: bool = False + assert current_sym_table is not None + for name in item.as_attr_list: + sym = self.__lookup(name.sym_name, current_sym_table) + if sym is None: + msg = "Missing symbol for base class " + msg += f"{ast.Module.get_href_path(name)}.{name.sym_name}" + msg += f" needed for {ast.Module.get_href_path(node)}" + self.__debug_print(msg) + not_found = True + break + current_sym_table = sym.fetch_sym_tab + + # In case of python nodes, the base class may not be + # raised so ignore these classes for now + # TODO Do we need to import these classes? + if ( + sym.defn[0].parent_of_type(ast.Module).py_info.is_raised_from_py + and current_sym_table is None + ): + msg = "Missing symbol table for python base class " + msg += f"{ast.Module.get_href_path(name)}.{name.sym_name}" + msg += f" needed for {ast.Module.get_href_path(node)}" + self.__debug_print(msg) + not_found = True + break + + assert current_sym_table is not None + + if not_found: + continue + + assert current_sym_table is not None + node.sym_tab.inherit_sym_tab(current_sym_table) diff --git a/jac/jaclang/compiler/passes/main/schedules.py b/jac/jaclang/compiler/passes/main/schedules.py index 0ac604fa9b..1274e43d89 100644 --- a/jac/jaclang/compiler/passes/main/schedules.py +++ b/jac/jaclang/compiler/passes/main/schedules.py @@ -20,6 +20,7 @@ from .registry_pass import RegistryPass # noqa: I100 from .access_modifier_pass import AccessCheckPass # noqa: I100 from .py_collect_dep_pass import PyCollectDepsPass # noqa: I100 +from .inheritance_pass import InheritancePass # noqa: I100 py_code_gen = [ SubNodeTabPass, @@ -38,6 +39,7 @@ PyCollectDepsPass, PyImportPass, DefUsePass, + InheritancePass, FuseTypeInfoPass, AccessCheckPass, ] diff --git a/jac/jaclang/langserve/tests/test_server.py b/jac/jaclang/langserve/tests/test_server.py index 375b8317c5..82c776ff87 100644 --- a/jac/jaclang/langserve/tests/test_server.py +++ b/jac/jaclang/langserve/tests/test_server.py @@ -330,7 +330,7 @@ def test_completion(self) -> None: "doubleinner", "apply_red", ], - 8, + 11, ), ( lspt.Position(65, 23), @@ -359,7 +359,7 @@ def test_completion(self) -> None: "doubleinner", "apply_red", ], - 8, + 11, ), ( lspt.Position(73, 22), diff --git a/jac/jaclang/plugin/default.py b/jac/jaclang/plugin/default.py index 76fa0df859..7f43232de2 100644 --- a/jac/jaclang/plugin/default.py +++ b/jac/jaclang/plugin/default.py @@ -5,6 +5,7 @@ import ast as ast3 import fnmatch import html +import inspect import os import types from collections import OrderedDict @@ -42,8 +43,11 @@ from jaclang.runtimelib.importer import ImportPathSpec, JacImporter, PythonImporter from jaclang.runtimelib.machine import JacMachine, JacProgram from jaclang.runtimelib.memory import Shelf, ShelfStorage -from jaclang.runtimelib.utils import collect_node_connections, traverse_graph - +from jaclang.runtimelib.utils import ( + all_issubclass, + collect_node_connections, + traverse_graph, +) import pluggy @@ -398,64 +402,85 @@ def spawn_call(op1: Architype, op2: Architype) -> WalkerArchitype: walker.path = [] walker.next = [node] - if walker.next: - current_node = walker.next[-1].architype - for i in warch._jac_entry_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger: - if i.func: - i.func(warch, current_node) - else: - raise ValueError(f"No function {i.name} to call.") + current_node = node.architype + + # walker entry + for i in warch._jac_entry_funcs_: + if i.func and not i.trigger: + i.func(warch, current_node) + if walker.disengaged: + return warch + while len(walker.next): if current_node := walker.next.pop(0).architype: + # walker entry with + for i in warch._jac_entry_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, NodeArchitype) + and isinstance(current_node, i.trigger) + ): + i.func(warch, current_node) + if walker.disengaged: + return warch + + # node entry for i in current_node._jac_entry_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(warch, trigger): - if i.func: - i.func(current_node, warch) - else: - raise ValueError(f"No function {i.name} to call.") + if i.func and not i.trigger: + i.func(current_node, warch) if walker.disengaged: return warch - for i in warch._jac_entry_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(current_node, trigger): - if i.func and trigger: - i.func(warch, current_node) - elif not trigger: - continue - else: - raise ValueError(f"No function {i.name} to call.") + + # node entry with + for i in current_node._jac_entry_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, WalkerArchitype) + and isinstance(warch, i.trigger) + ): + i.func(current_node, warch) if walker.disengaged: return warch - for i in warch._jac_exit_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(current_node, trigger): - if i.func and trigger: - i.func(warch, current_node) - elif not trigger: - continue - else: - raise ValueError(f"No function {i.name} to call.") + + # node exit with + for i in current_node._jac_exit_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, WalkerArchitype) + and isinstance(warch, i.trigger) + ): + i.func(current_node, warch) if walker.disengaged: return warch + + # node exit for i in current_node._jac_exit_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger or isinstance(warch, trigger): - if i.func: - i.func(current_node, warch) - else: - raise ValueError(f"No function {i.name} to call.") + if i.func and not i.trigger: + i.func(current_node, warch) if walker.disengaged: return warch + + # walker exit with + for i in warch._jac_exit_funcs_: + if ( + i.func + and i.trigger + and all_issubclass(i.trigger, NodeArchitype) + and isinstance(current_node, i.trigger) + ): + i.func(warch, current_node) + if walker.disengaged: + return warch + # walker exit for i in warch._jac_exit_funcs_: - trigger = i.get_funcparam_annotations(i.func) - if not trigger: - if i.func: - i.func(warch, current_node) - else: - raise ValueError(f"No function {i.name} to call.") + if i.func and not i.trigger: + i.func(warch, current_node) + if walker.disengaged: + return warch + walker.ignores = [] return warch @@ -818,12 +843,14 @@ def jac_import( @hookimpl def create_test(test_fun: Callable) -> Callable: """Create a new test.""" + file_path = inspect.getfile(test_fun) + func_name = test_fun.__name__ def test_deco() -> None: test_fun(JacTestCheck()) test_deco.__name__ = test_fun.__name__ - JacTestCheck.add_test(test_deco) + JacTestCheck.add_test(file_path, func_name, test_deco) return test_deco @@ -831,6 +858,7 @@ def test_deco() -> None: @hookimpl def run_test( filepath: str, + func_name: Optional[str], filter: Optional[str], xit: bool, maxfail: Optional[int], @@ -849,7 +877,9 @@ def run_test( mod_name = mod_name[:-5] JacTestCheck.reset() Jac.jac_import(target=mod_name, base_path=base, cachable=False) - JacTestCheck.run_test(xit, maxfail, verbose) + JacTestCheck.run_test( + xit, maxfail, verbose, os.path.abspath(filepath), func_name + ) ret_count = JacTestCheck.failcount else: print("Not a .jac file.") @@ -875,7 +905,9 @@ def run_test( print(f"\n\n\t\t* Inside {root_dir}" + "/" + f"{file} *") JacTestCheck.reset() Jac.jac_import(target=file[:-4], base_path=root_dir) - JacTestCheck.run_test(xit, maxfail, verbose) + JacTestCheck.run_test( + xit, maxfail, verbose, os.path.abspath(file), func_name + ) if JacTestCheck.breaker and (xit or maxfail): break diff --git a/jac/jaclang/plugin/feature.py b/jac/jaclang/plugin/feature.py index 808b254ae9..a2fb585fed 100644 --- a/jac/jaclang/plugin/feature.py +++ b/jac/jaclang/plugin/feature.py @@ -359,6 +359,7 @@ def create_test(test_fun: Callable) -> Callable: @staticmethod def run_test( filepath: str, + func_name: Optional[str] = None, filter: Optional[str] = None, xit: bool = False, maxfail: Optional[int] = None, @@ -368,6 +369,7 @@ def run_test( """Run the test suite in the specified .jac file.""" return plugin_manager.hook.run_test( filepath=filepath, + func_name=func_name, filter=filter, xit=xit, maxfail=maxfail, diff --git a/jac/jaclang/plugin/spec.py b/jac/jaclang/plugin/spec.py index bc1bf01259..aa7371cc85 100644 --- a/jac/jaclang/plugin/spec.py +++ b/jac/jaclang/plugin/spec.py @@ -345,6 +345,7 @@ def create_test(test_fun: Callable) -> Callable: @hookspec(firstresult=True) def run_test( filepath: str, + func_name: Optional[str], filter: Optional[str], xit: bool, maxfail: Optional[int], diff --git a/jac/jaclang/runtimelib/architype.py b/jac/jaclang/runtimelib/architype.py index 382bd41945..25c19a7d88 100644 --- a/jac/jaclang/runtimelib/architype.py +++ b/jac/jaclang/runtimelib/architype.py @@ -5,6 +5,7 @@ import inspect from dataclasses import asdict, dataclass, field, fields, is_dataclass from enum import IntEnum +from functools import cached_property from logging import getLogger from pickle import dumps from types import UnionType @@ -220,9 +221,9 @@ class WalkerAnchor(Anchor): """Walker Anchor.""" architype: WalkerArchitype - path: list[Anchor] = field(default_factory=list) - next: list[Anchor] = field(default_factory=list) - ignores: list[Anchor] = field(default_factory=list) + path: list[NodeAnchor] = field(default_factory=list) + next: list[NodeAnchor] = field(default_factory=list) + ignores: list[NodeAnchor] = field(default_factory=list) disengaged: bool = False @@ -311,17 +312,20 @@ class DSFunc: name: str func: Callable[[Any, Any], Any] | None = None + @cached_property + def trigger(self) -> type | UnionType | tuple[type | UnionType, ...] | None: + """Get function parameter annotations.""" + t = ( + ( + inspect.signature(self.func, eval_str=True) + .parameters["_jac_here_"] + .annotation + ) + if self.func + else None + ) + return None if t is inspect._empty else t + def resolve(self, cls: type) -> None: """Resolve the function.""" self.func = getattr(cls, self.name) - - def get_funcparam_annotations( - self, func: Callable[[Any, Any], Any] | None - ) -> type | UnionType | tuple[type | UnionType, ...] | None: - """Get function parameter annotations.""" - if not func: - return None - annotation = ( - inspect.signature(func, eval_str=True).parameters["_jac_here_"].annotation - ) - return annotation if annotation != inspect._empty else None diff --git a/jac/jaclang/runtimelib/test.py b/jac/jaclang/runtimelib/test.py index f453b74c07..64a878d037 100644 --- a/jac/jaclang/runtimelib/test.py +++ b/jac/jaclang/runtimelib/test.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest +from dataclasses import dataclass from typing import Callable, Optional @@ -56,6 +57,16 @@ class JacTestCheck: test_case = unittest.TestCase() test_suite = unittest.TestSuite() + + @dataclass + class TestSuite: + """Test Suite.""" + + test_case: unittest.FunctionTestCase + func_name: str + + test_suite_path: dict[str, list[TestSuite]] = {} + breaker = False failcount = 0 @@ -64,13 +75,45 @@ def reset() -> None: """Clear the test suite.""" JacTestCheck.test_case = unittest.TestCase() JacTestCheck.test_suite = unittest.TestSuite() + JacTestCheck.test_suite_path = {} @staticmethod - def run_test(xit: bool, maxfail: int | None, verbose: bool) -> None: + def run_test( + xit: bool, + maxfail: int | None, + verbose: bool, + filepath: str | None, + func_name: str | None, + ) -> None: """Run the test suite.""" verb = 2 if verbose else 1 + test_suite = JacTestCheck.test_suite + + if filepath and filepath.endswith(".test.jac"): + filepath = filepath[:-9] + elif filepath and filepath.endswith(".jac"): + filepath = filepath[:-4] + + if filepath: + test_cases = JacTestCheck.test_suite_path.get(filepath) + if test_cases is not None: + test_suite = unittest.TestSuite() + for test_case in test_cases: + if func_name: + if test_case.func_name == func_name: + test_suite.addTest(test_case.test_case) + else: + test_suite.addTest(test_case.test_case) + + elif func_name: + test_suite = unittest.TestSuite() + for test_cases in JacTestCheck.test_suite_path.values(): + for test_case in test_cases: + if test_case.func_name == func_name: + test_suite.addTest(test_case.test_case) + runner = JacTextTestRunner(max_failures=maxfail, failfast=xit, verbosity=verb) - result = runner.run(JacTestCheck.test_suite) + result = runner.run(test_suite) if result.wasSuccessful(): print("Passed successfully.") else: @@ -81,9 +124,21 @@ def run_test(xit: bool, maxfail: int | None, verbose: bool) -> None: ) @staticmethod - def add_test(test_fun: Callable) -> None: + def add_test(filepath: str, func_name: str, test_func: Callable) -> None: """Create a new test.""" - JacTestCheck.test_suite.addTest(unittest.FunctionTestCase(test_fun)) + if filepath and filepath.endswith(".test.jac"): + filepath = filepath[:-9] + elif filepath and filepath.endswith(".jac"): + filepath = filepath[:-4] + + if filepath not in JacTestCheck.test_suite_path: + JacTestCheck.test_suite_path[filepath] = [] + + test_case = unittest.FunctionTestCase(test_func) + JacTestCheck.test_suite_path[filepath].append( + JacTestCheck.TestSuite(test_case=test_case, func_name=func_name) + ) + JacTestCheck.test_suite.addTest(test_case) def __getattr__(self, name: str) -> object: """Make convenient check.Equal(...) etc.""" diff --git a/jac/jaclang/runtimelib/utils.py b/jac/jaclang/runtimelib/utils.py index 83e35ab2cf..738eec2f36 100644 --- a/jac/jaclang/runtimelib/utils.py +++ b/jac/jaclang/runtimelib/utils.py @@ -231,3 +231,18 @@ def is_instance( return isinstance(obj, target) case _: return False + + +def all_issubclass( + classes: type | UnionType | tuple[type | UnionType, ...], target: type +) -> bool: + """Check if all classes is subclass of target type.""" + match classes: + case type(): + return issubclass(classes, target) + case UnionType(): + return all((all_issubclass(cls, target) for cls in classes.__args__)) + case tuple(): + return all((all_issubclass(cls, target) for cls in classes)) + case _: + return False diff --git a/jac/jaclang/settings.py b/jac/jaclang/settings.py index 2c1b1291d7..431a4218c3 100644 --- a/jac/jaclang/settings.py +++ b/jac/jaclang/settings.py @@ -17,6 +17,7 @@ class Settings: collect_py_dep_debug: bool = False print_py_raised_ast: bool = False py_import_pass_debug: bool = False + inherit_pass_debug: bool = False # Compiler configuration disable_mtllm: bool = False diff --git a/jac/jaclang/tests/fixtures/base_class1.jac b/jac/jaclang/tests/fixtures/base_class1.jac new file mode 100644 index 0000000000..288f57e8c1 --- /dev/null +++ b/jac/jaclang/tests/fixtures/base_class1.jac @@ -0,0 +1,11 @@ +import:py test_py; + +class B :test_py.A: {} + +with entry { + a = test_py.A(); + b = B(); + + a.start(); + b.start(); +} diff --git a/jac/jaclang/tests/fixtures/base_class2.jac b/jac/jaclang/tests/fixtures/base_class2.jac new file mode 100644 index 0000000000..110d822906 --- /dev/null +++ b/jac/jaclang/tests/fixtures/base_class2.jac @@ -0,0 +1,11 @@ +import:py from test_py { A } + +class B :A: {} + +with entry { + a = A(); + b = B(); + + a.start(); + b.start(); +} diff --git a/jac/jaclang/tests/fixtures/import_all.jac b/jac/jaclang/tests/fixtures/import_all.jac new file mode 100644 index 0000000000..e1b6706e10 --- /dev/null +++ b/jac/jaclang/tests/fixtures/import_all.jac @@ -0,0 +1,7 @@ +import:py import_all_py; + +with entry { + print(import_all_py.custom_func(11)); + print(import_all_py.pi); + print(import_all_py.floor(0.5)); +} diff --git a/jac/jaclang/tests/fixtures/import_all_py.py b/jac/jaclang/tests/fixtures/import_all_py.py new file mode 100644 index 0000000000..cd2fd44609 --- /dev/null +++ b/jac/jaclang/tests/fixtures/import_all_py.py @@ -0,0 +1,8 @@ +"""Module used for testing from x import * in jac.""" + +from math import * # noqa + + +def custom_func(x: int) -> str: + """Dummy custom function for testing purposes.""" # noqa + return str(x) diff --git a/jac/jaclang/tests/fixtures/jactest_imported.jac b/jac/jaclang/tests/fixtures/jactest_imported.jac new file mode 100644 index 0000000000..f9695e7962 --- /dev/null +++ b/jac/jaclang/tests/fixtures/jactest_imported.jac @@ -0,0 +1,6 @@ + + +test this_should_not_run { + print("This test should not run after import."); + assert False; +} diff --git a/jac/jaclang/tests/fixtures/jactest_main.jac b/jac/jaclang/tests/fixtures/jactest_main.jac new file mode 100644 index 0000000000..dc174dd898 --- /dev/null +++ b/jac/jaclang/tests/fixtures/jactest_main.jac @@ -0,0 +1,22 @@ +import jactest_imported; + +can fib(n: int) -> int { + if n <= 1 { + return n; + } + return fib(n - 1) + fib(n - 2); +} + + +test first_two { + print("Testing first 2 fibonacci numbers."); + assert fib(0) == 0; + assert fib(1) == 0; +} + +test from_2_to_10 { + print("Testing fibonacci numbers from 2 to 10."); + for i in range(2, 10) { + assert fib(i) == fib(i - 1) + fib(i - 2); + } +} diff --git a/jac/jaclang/tests/fixtures/test_py.py b/jac/jaclang/tests/fixtures/test_py.py new file mode 100644 index 0000000000..ba3e4f804e --- /dev/null +++ b/jac/jaclang/tests/fixtures/test_py.py @@ -0,0 +1,12 @@ +"""Test file for subclass issue.""" + +p = 5 +g = 6 + + +class A: + """Dummy class to test the base class issue.""" + + def start(self) -> int: + """Return 0.""" + return 0 diff --git a/jac/jaclang/tests/fixtures/visit_sequence.jac b/jac/jaclang/tests/fixtures/visit_sequence.jac new file mode 100644 index 0000000000..54ea0c9b15 --- /dev/null +++ b/jac/jaclang/tests/fixtures/visit_sequence.jac @@ -0,0 +1,50 @@ +node Node { + has val: str; + + can entry1 with entry { + print(f"{self.val}-2"); + } + + can entry2 with Walker entry { + print(f"{self.val}-3"); + } + + can exit1 with Walker exit { + print(f"{self.val}-4"); + } + + can exit2 with exit { + print(f"{self.val}-5"); + } +} + +walker Walker { + can entry1 with entry { + print("walker entry"); + } + + can entry2 with `root entry { + print("walker enter to root"); + visit [-->]; + } + + can entry3 with Node entry { + print(f"{here.val}-1"); + } + + can exit1 with Node exit { + print(f"{here.val}-6"); + } + + can exit2 with exit { + print("walker exit"); + } +} + +with entry{ + root ++> Node(val = "a"); + root ++> Node(val = "b"); + root ++> Node(val = "c"); + + Walker() spawn root; +} \ No newline at end of file diff --git a/jac/jaclang/tests/test_cli.py b/jac/jaclang/tests/test_cli.py index cf9b1cd183..f75a5989a9 100644 --- a/jac/jaclang/tests/test_cli.py +++ b/jac/jaclang/tests/test_cli.py @@ -233,6 +233,67 @@ def test_builtins_loading(self) -> None: r"13\:12 \- 13\:18.*Name - append - .*SymbolPath: builtins_test.builtins.list.append", ) + def test_import_all(self) -> None: + """Testing for print AstTool.""" + from jaclang.settings import settings + + settings.ast_symbol_info_detailed = True + captured_output = io.StringIO() + sys.stdout = captured_output + + cli.tool("ir", ["ast", f"{self.fixture_abs_path('import_all.jac')}"]) + + sys.stdout = sys.__stdout__ + stdout_value = captured_output.getvalue() + settings.ast_symbol_info_detailed = False + + self.assertRegex( + stdout_value, + r"6\:25 - 6\:30.*Name - floor -.*SymbolPath: import_all.import_all_py.floor", + ) + self.assertRegex( + stdout_value, + r"5\:25 - 5\:27.*Name - pi -.*SymbolPath: import_all.import_all_py.pi", + ) + + def test_sub_class_symbol_table_fix_1(self) -> None: + """Testing for print AstTool.""" + from jaclang.settings import settings + + settings.ast_symbol_info_detailed = True + captured_output = io.StringIO() + sys.stdout = captured_output + + cli.tool("ir", ["ast", f"{self.fixture_abs_path('base_class1.jac')}"]) + + sys.stdout = sys.__stdout__ + stdout_value = captured_output.getvalue() + settings.ast_symbol_info_detailed = False + + self.assertRegex( + stdout_value, + r"10:7 - 10:12.*Name - start - Type.*SymbolPath: base_class1.B.start", + ) + + def test_sub_class_symbol_table_fix_2(self) -> None: + """Testing for print AstTool.""" + from jaclang.settings import settings + + settings.ast_symbol_info_detailed = True + captured_output = io.StringIO() + sys.stdout = captured_output + + cli.tool("ir", ["ast", f"{self.fixture_abs_path('base_class2.jac')}"]) + + sys.stdout = sys.__stdout__ + stdout_value = captured_output.getvalue() + settings.ast_symbol_info_detailed = False + + self.assertRegex( + stdout_value, + r"10:7 - 10:12.*Name - start - Type.*SymbolPath: base_class2.B.start", + ) + def test_expr_types(self) -> None: """Testing for print AstTool.""" captured_output = io.StringIO() @@ -393,6 +454,27 @@ def test_run_test(self) -> None: self.assertIn("...F", stderr) self.assertIn("F.F", stderr) + def test_run_specific_test_only(self) -> None: + """Test a specific test case.""" + process = subprocess.Popen( + [ + "jac", + "test", + "-t", + "from_2_to_10", + self.fixture_abs_path("jactest_main.jac"), + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = process.communicate() + self.assertIn("Ran 1 test", stderr) + self.assertIn("Testing fibonacci numbers from 2 to 10.", stdout) + self.assertNotIn("Testing first 2 fibonacci numbers.", stdout) + self.assertNotIn("This test should not run after import.", stdout) + def test_graph_coverage(self) -> None: """Test for coverage of graph cmd.""" graph_params = set(inspect.signature(cli.dot).parameters.keys()) diff --git a/jac/jaclang/tests/test_language.py b/jac/jaclang/tests/test_language.py index 0bf7b6e09e..15b28d10d1 100644 --- a/jac/jaclang/tests/test_language.py +++ b/jac/jaclang/tests/test_language.py @@ -1230,5 +1230,20 @@ def test_architype_def(self) -> None: jac_import("architype_def_bug", base_path=self.fixture_abs_path("./")) sys.stdout = sys.__stdout__ stdout_value = captured_output.getvalue().split("\n") - self.assertIn("MyNode", stdout_value[0]) - self.assertIn("MyWalker", stdout_value[1]) + self.assertIn("MyWalker", stdout_value[0]) + self.assertIn("MyNode", stdout_value[1]) + + def test_visit_sequence(self) -> None: + """Test conn assign on edges.""" + captured_output = io.StringIO() + sys.stdout = captured_output + jac_import("visit_sequence", base_path=self.fixture_abs_path("./")) + sys.stdout = sys.__stdout__ + self.assertEqual( + "walker entry\nwalker enter to root\n" + "a-1\na-2\na-3\na-4\na-5\na-6\n" + "b-1\nb-2\nb-3\nb-4\nb-5\nb-6\n" + "c-1\nc-2\nc-3\nc-4\nc-5\nc-6\n" + "walker exit\n", + captured_output.getvalue(), + ) diff --git a/jac/jaclang/utils/helpers.py b/jac/jaclang/utils/helpers.py index a79a6f83c5..e91becacc1 100644 --- a/jac/jaclang/utils/helpers.py +++ b/jac/jaclang/utils/helpers.py @@ -165,7 +165,15 @@ def byte_offset_to_char_offset(string: str, offset: int) -> int: (frame.lineno is not None) and frame.line and frame.line.strip() != "" ): - line_o = frame._original_line.rstrip() # type: ignore [attr-defined] + # Note: This is CPython internals we're trying to get since python doesn't provide + # the frames original line but the stripped version so we had to do this. + line_o = frame.line # Fallback line. + if hasattr(frame, "_original_line"): + line_o = frame._original_line.rstrip() # type: ignore [attr-defined] + elif hasattr(frame, "_original_lines"): + # https://github.com/python/cpython/issues/106922 + line_o = frame._original_lines.split("\n")[0].rstrip() # type: ignore [attr-defined] + if frame.colno is not None and frame.end_colno is not None: off_start = byte_offset_to_char_offset(line_o, frame.colno) - 1 off_end = byte_offset_to_char_offset(line_o, frame.end_colno) - 1 diff --git a/jac/pyproject.toml b/jac/pyproject.toml index ed1ab41fab..1d2ead0580 100644 --- a/jac/pyproject.toml +++ b/jac/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jaclang" -version = "0.7.27" +version = "0.7.29" description = "Jac is a unique and powerful programming language that runs on top of Python, offering an unprecedented level of intelligence and intuitive understanding." authors = ["Jason Mars "] maintainers = ["Jason Mars "] diff --git a/jac/support/jac-lang.org/docs/learn/coders/jac-cloud/docs/jac_cloud_webhook.md b/jac/support/jac-lang.org/docs/learn/coders/jac-cloud/docs/jac_cloud_webhook.md new file mode 100644 index 0000000000..13c3d10618 --- /dev/null +++ b/jac/support/jac-lang.org/docs/learn/coders/jac-cloud/docs/jac_cloud_webhook.md @@ -0,0 +1,196 @@ +# WEBHOOK +- webhook walker is similar to normal authenticated walker however, normal authenticated walker is associated to a user while webhook is directly to root. +- webhook api keys are manage by user +- supports different HTTP components as API key holder + - header (default): + - query + - path + - body +- name of the api-key can be change to any string (default: `X-API-KEY`) + +## CREATE WEBHOOK +```python +walker webhook { + can enter1 with `root entry { + report here; + } + + class __specs__ { + has webhook: dict = { + "type": "header | query | path | body", # optional: defaults to header + "name": "any string" # optional: defaults to X-API-KEY + }; + } +} +``` +![image](https://github.com/user-attachments/assets/75cceb2d-5618-4f68-97e2-31a4270e70b1) + +# WEBHOOK MANAGEMENT APIs +![image](https://github.com/user-attachments/assets/3a01ab35-06b0-4942-8f1f-0c4ae794ce21) + +## GENERATE API KEY +#### `REQUEST` +> **POST** /webhook/generate-key +```python +{ + # unique name of webhook + "name": "webhook1", + + # names of allowed webhook walkers. Not set or empty list means all webhook walkers is allowed. + "walkers": ["webhook"], + + # names of allowed nodes. Not set or empty list means all nodes is allowed. + "nodes": ["root"], + + # date now + timedelta( {{interval}}: {{count}} ) + "expiration": { + "count": 60, + + # seconds | minutes | hours | days + "interval": "days" + } +} +``` +#### `RESPONSE` +```python +{ + "id": "672203ee093fd3d208a4b6d4", + "name": "webhook1", + "key": "6721f000ee301e1d54c3de3d:1730282478:P4Nrs3DOLIkaw5aYsbIWNzWZZAwEyb20" +} +``` + +## GET API KEY +#### `REQUEST` +> **GET** /webhook +#### `RESPONSE` +```python +{ + "keys": [ + { + "id": "672203ee093fd3d208a4b6d4", + "name": "test", + "root_id": "6721f000ee301e1d54c3de3d", + "walkers": ["webhook"], + "nodes": ["root"], + "expiration": "2025-12-24T10:01:18.206000", + "key": "6721f000ee301e1d54c3de3d:1730282478:P4Nrs3DOLIkaw5aYsbIWNzWZZAwEyb20" + } + ] +} +``` + +## EXTEND API KEY +#### `REQUEST` +> **PATCH** /webhook/extend/`{id}` +```python +{ + "count": 60, + + # seconds | minutes | hours | days + "interval": "days" +} +``` +#### `RESPONSE` +```python +{ + "message": "Successfully Extended!" +} +``` + +## DELETE API KEY +#### `REQUEST` +> **DELETE** /webhook/delete +```python +{ + # list of id to be deleted + "ids": ["672203ee093fd3d208a4b6d4"] +} +``` +#### `RESPONSE` +```python +{ + "message": "Successfully Deleted!" +} +``` +## WEBHOOK SAMPLES + +### `BY HEADER` (default) +```python +walker webhook_by_header { + can enter1 with `root entry { + report here; + } + + class __specs__ { + has webhook: dict = { + "type": "header", + "name": "test-key" + }; + } +} +``` +#### REQUEST +```bash +curl -X 'POST' 'http://localhost:8001/webhook/walker/webhook_by_header' \ + -H 'test-key: YOUR-GENERATED-KEY' +``` + +### `BY QUERY` +```python +walker webhook_by_query { + can enter1 with `root entry { + report here; + } + + class __specs__ { + has webhook: dict = { + "type": "query", + "name": "test_key" + }; + } +} +``` +#### REQUEST +```bash +curl -X 'POST' 'http://localhost:8001/webhook/walker/webhook_by_query?test_key=YOUR-GENERATED-KEY' +``` + +### `BY PATH` +```python +walker webhook_by_path { + can enter1 with `root entry { + report here; + } + + class __specs__ { + has webhook: dict = { + "type": "path", + "name": "test_key" # name and the path var should be the same + }, path: str = "/{test_key}"; + } +} +``` +#### REQUEST +```bash +curl -X 'POST' 'http://localhost:8001/webhook/walker/webhook_by_path/YOUR-GENERATED-KEY' +``` +### `BY BODY` +```python +walker webhook_by_body { + can enter1 with `root entry { + report here; + } + + class __specs__ { + has webhook: dict = { + "type": "body", + "name": "test_key" + }; + } +} +``` +#### REQUEST +```bash +curl -X 'POST' 'http://localhost:8001/webhook/walker/webhook_by_body' -d '{"test_key": "YOUR-GENERATED-KEY"}' +``` \ No newline at end of file diff --git a/jac/support/jac-lang.org/docs/learn/data_spatial/sequence.md b/jac/support/jac-lang.org/docs/learn/data_spatial/sequence.md new file mode 100644 index 0000000000..fddf662de4 --- /dev/null +++ b/jac/support/jac-lang.org/docs/learn/data_spatial/sequence.md @@ -0,0 +1,115 @@ +# **`SPAWN CALL SEQUENCE`** +``` +node Node { + has val: str; + + can entry1 with entry { + print(f"{self.val}-2"); + } + + can entry2 with Walker entry { + print(f"{self.val}-3"); + } + + can exit1 with Walker exit { + print(f"{self.val}-4"); + } + + can exit2 with exit { + print(f"{self.val}-5"); + } +} + +walker Walker { + can entry1 with entry { + print("walker entry"); + } + + can entry2 with `root entry { + print("walker enter to root"); + visit [-->]; + } + + can entry3 with Node entry { + print(f"{here.val}-1"); + } + + can exit1 with Node exit { + print(f"{here.val}-6"); + } + + can exit2 with exit { + print("walker exit"); + } +} + +with entry{ + root ++> Node(val = "a"); + root ++> Node(val = "b"); + root ++> Node(val = "c"); + + Walker() spawn root; +} +``` +## Current triggering sequence (jaclang version >= 0.7.29) +> **walker generic entry** => \ +> **walker typed entry** => \ +> **node generic entry** => \ +> **node typed entry** => \ +> **node typed exit** => \ +> **node generic exit** => \ +> **back to walker typed exit** => \ +> **walker generic exit** +``` +walker entry +walker enter to root +a-1 +a-2 +a-3 +a-4 +a-5 +a-6 +b-1 +b-2 +b-3 +b-4 +b-5 +b-6 +c-1 +c-2 +c-3 +c-4 +c-5 +c-6 +walker exit +``` +## For jaclang version <= 0.7.28 +> **walker generic entry** => \ +> **node entries (generic/typed)** => \ +> **back to walker typed entry** => \ +> **walker typed exit** => \ +> **back to node exits (generic/typed)** => \ +> **back to walker generic exit** +``` +walker entry +walker enter to root +a-2 +a-3 +a-1 +a-6 +a-4 +a-5 +b-2 +b-3 +b-1 +b-6 +b-4 +b-5 +c-2 +c-3 +c-1 +c-6 +c-4 +c-5 +walker exit +``` diff --git a/jac/support/jac-lang.org/mkdocs.yml b/jac/support/jac-lang.org/mkdocs.yml index f877c4ee66..39dde91bc8 100644 --- a/jac/support/jac-lang.org/mkdocs.yml +++ b/jac/support/jac-lang.org/mkdocs.yml @@ -14,12 +14,14 @@ nav: - "learn/impl_docs.md" - Data Spatial: - "learn/data_spatial/examples.md" + - "learn/data_spatial/sequence.md" - "learn/data_spatial/FAQ.md" - Jac Cloud: - "learn/coders/jac-cloud/docs/jac_cloud.md" - "learn/coders/jac-cloud/docs/permission.md" - "learn/coders/jac-cloud/docs/jac_cloud_logging.md" - "learn/coders/jac-cloud/docs/jac_cloud_env_vars.md" + - "learn/coders/jac-cloud/docs/jac_cloud_webhook.md" - MTLLM: - "learn/with_llm.md" - Quick Start: