fxn 0.0.42__py3-none-any.whl → 0.0.44__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fxn/client.py CHANGED
@@ -4,9 +4,9 @@
4
4
  #
5
5
 
6
6
  from json import loads, JSONDecodeError
7
- from pydantic import BaseModel
7
+ from pydantic import BaseModel, TypeAdapter
8
8
  from requests import request
9
- from typing import Any, Literal, Type, TypeVar
9
+ from typing import AsyncGenerator, Literal, Type, TypeVar
10
10
 
11
11
  T = TypeVar("T", bound=BaseModel)
12
12
 
@@ -19,11 +19,20 @@ class FunctionClient:
19
19
  def request (
20
20
  self,
21
21
  *,
22
- method: Literal["GET", "POST", "DELETE"],
22
+ method: Literal["GET", "POST", "PATCH", "DELETE"],
23
23
  path: str,
24
- body: dict[str, Any]=None,
24
+ body: dict[str, object]=None,
25
25
  response_type: Type[T]=None
26
26
  ) -> T:
27
+ """
28
+ Make a request to a REST endpoint.
29
+
30
+ Parameters:
31
+ method (str): Request method.
32
+ path (str): Endpoint path.
33
+ body (dict): Request JSON body.
34
+ response_type (Type): Response type.
35
+ """
27
36
  response = request(
28
37
  method=method,
29
38
  url=f"{self.api_url}{path}",
@@ -40,6 +49,53 @@ class FunctionClient:
40
49
  else:
41
50
  error = _ErrorResponse(**data).errors[0].message if isinstance(data, dict) else data
42
51
  raise FunctionAPIError(error, response.status_code)
52
+
53
+ async def stream (
54
+ self,
55
+ *,
56
+ method: Literal["GET", "POST", "PATCH", "DELETE"],
57
+ path: str,
58
+ body: dict[str, object]=None,
59
+ response_type: Type[T]=None
60
+ ) -> AsyncGenerator[T, None]:
61
+ """
62
+ Make a request to a REST endpoint and consume the response as a server-sent events stream.
63
+
64
+ Parameters:
65
+ method (str): Request method.
66
+ path (str): Endpoint path.
67
+ body (dict): Request JSON body.
68
+ response_type (Type): Response type.
69
+ """
70
+ response = request(
71
+ method=method,
72
+ url=f"{self.api_url}{path}",
73
+ json=body,
74
+ headers={
75
+ "Accept": "text/event-stream",
76
+ "Authorization": f"Bearer {self.access_key}"
77
+ },
78
+ stream=True
79
+ )
80
+ event = None
81
+ data: str = ""
82
+ for line in response.iter_lines(decode_unicode=True):
83
+ if line is None:
84
+ break
85
+ line: str = line.strip()
86
+ if line:
87
+ if line.startswith("event:"):
88
+ event = line[len("event:"):].strip()
89
+ elif line.startswith("data:"):
90
+ line_data = line[len("data:"):].strip()
91
+ data = f"{data}\n{line_data}"
92
+ continue
93
+ if event is not None:
94
+ yield _parse_sse_event(event, data, response_type)
95
+ event = None
96
+ data = ""
97
+ if event or data:
98
+ yield _parse_sse_event(event, data, response_type)
43
99
 
44
100
  class FunctionAPIError (Exception):
45
101
 
@@ -55,4 +111,9 @@ class _APIError (BaseModel):
55
111
  message: str
56
112
 
57
113
  class _ErrorResponse (BaseModel):
58
- errors: list[_APIError]
114
+ errors: list[_APIError]
115
+
116
+ def _parse_sse_event (event: str, data: str, type: Type[T]=None) -> T:
117
+ result = { "event": event, "data": loads(data) }
118
+ result = TypeAdapter(type).validate_python(result) if type is not None else result
119
+ return result
@@ -5,12 +5,16 @@
5
5
 
6
6
  from collections.abc import Callable
7
7
  from functools import wraps
8
+ from inspect import isasyncgenfunction, iscoroutinefunction
8
9
  from pathlib import Path
9
- from pydantic import BaseModel, Field
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+ from types import ModuleType
12
+ from typing import Literal
10
13
 
11
- from ..types import AccessMode, Signature
12
14
  from .sandbox import Sandbox
13
- from .signature import get_function_type, infer_function_signature, FunctionType
15
+ from .types import AccessMode
16
+
17
+ CompileTarget = Literal["android", "ios", "linux", "macos", "visionos", "wasm", "windows"]
14
18
 
15
19
  class PredictorSpec (BaseModel):
16
20
  """
@@ -19,21 +23,26 @@ class PredictorSpec (BaseModel):
19
23
  tag: str = Field(description="Predictor tag.")
20
24
  description: str = Field(description="Predictor description. MUST be less than 100 characters long.", min_length=4, max_length=100)
21
25
  sandbox: Sandbox = Field(description="Sandbox to compile the function.")
26
+ trace_modules: list[ModuleType] = Field(description="Modules to trace and compile.", exclude=True)
27
+ targets: list[str] | None = Field(description="Targets to compile this predictor for. Pass `None` to compile for our default targets.")
22
28
  access: AccessMode = Field(description="Predictor access.")
23
- signature: Signature = Field(description="Predictor signature.")
24
29
  card: str | None = Field(default=None, description="Predictor card (markdown).")
25
30
  media: str | None = Field(default=None, description="Predictor media URL.")
26
31
  license: str | None = Field(default=None, description="Predictor license URL. This is required for public predictors.")
32
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow", frozen=True)
27
33
 
28
34
  def compile (
29
35
  tag: str,
30
36
  *,
31
37
  description: str,
32
38
  sandbox: Sandbox=None,
39
+ trace_modules: list[ModuleType]=[],
40
+ targets: list[CompileTarget]=None,
33
41
  access: AccessMode=AccessMode.Private,
34
42
  card: str | Path=None,
35
43
  media: Path=None,
36
44
  license: str=None,
45
+ **kwargs
37
46
  ):
38
47
  """
39
48
  Create a predictor by compiling a stateless function.
@@ -42,6 +51,8 @@ def compile (
42
51
  tag (str): Predictor tag.
43
52
  description (str): Predictor description. MUST be less than 100 characters long.
44
53
  sandbox (Sandbox): Sandbox to compile the function.
54
+ trace_modules (list): Modules to trace and compile.
55
+ targets (list): Targets to compile this predictor for. Pass `None` to compile for our default targets.
45
56
  access (AccessMode): Predictor access.
46
57
  card (str | Path): Predictor card markdown string or path to card.
47
58
  media (Path): Predictor thumbnail image (jpeg or png) path.
@@ -51,25 +62,20 @@ def compile (
51
62
  # Check type
52
63
  if not callable(func):
53
64
  raise TypeError("Cannot compile non-function objects")
54
- func_type = get_function_type(func)
55
- if func_type not in { FunctionType.Function, FunctionType.Generator }:
56
- raise TypeError(f"Function '{func.__name__}' must be a regular function or generator")
65
+ if isasyncgenfunction(func) or iscoroutinefunction(func):
66
+ raise TypeError(f"Entrypoint function '{func.__name__}' must be a regular function or generator")
57
67
  # Gather metadata
58
- signature = infer_function_signature(func) # throws
59
- if isinstance(card, Path):
60
- with open(card_content, "r") as f:
61
- card_content = f.read()
62
- else:
63
- card_content = card
64
68
  spec = PredictorSpec(
65
69
  tag=tag,
66
70
  description=description,
67
71
  sandbox=sandbox if sandbox is not None else Sandbox(),
72
+ trace_modules=trace_modules,
73
+ targets=targets,
68
74
  access=access,
69
- signature=signature,
70
- card=card_content,
75
+ card=card.read_text() if isinstance(card, Path) else card,
71
76
  media=None, # INCOMPLETE
72
- license=license
77
+ license=license,
78
+ **kwargs
73
79
  )
74
80
  # Wrap
75
81
  @wraps(func)
fxn/function.py CHANGED
@@ -30,11 +30,16 @@ class Function:
30
30
  predictions: PredictionService
31
31
  beta: BetaClient
32
32
 
33
- def __init__ (self, access_key: str=None, api_url: str=None):
34
- access_key = access_key or environ.get("FXN_ACCESS_KEY", None)
33
+ def __init__ (
34
+ self,
35
+ access_key: str=None,
36
+ *,
37
+ api_url: str=None
38
+ ):
39
+ access_key = access_key or environ.get("FXN_ACCESS_KEY")
35
40
  api_url = api_url or environ.get("FXN_API_URL")
36
41
  self.client = FunctionClient(access_key, api_url)
37
42
  self.users = UserService(self.client)
38
43
  self.predictors = PredictorService(self.client)
39
44
  self.predictions = PredictionService(self.client)
40
- self.beta = BetaClient(self.client)
45
+ self.beta = BetaClient(self.client, predictions=self.predictions)
fxn/logging.py ADDED
@@ -0,0 +1,219 @@
1
+ #
2
+ # Function
3
+ # Copyright © 2025 NatML Inc. All Rights Reserved.
4
+ #
5
+
6
+ from contextvars import ContextVar
7
+ from rich.console import Console, ConsoleOptions, RenderResult
8
+ from rich.progress import Progress, ProgressColumn, SpinnerColumn, TextColumn
9
+ from rich.text import Text
10
+ from rich.traceback import Traceback
11
+ from types import MethodType
12
+
13
+ current_progress = ContextVar("current_progress", default=None)
14
+ progress_task_stack = ContextVar("progress_task_stack", default=[])
15
+
16
+ class CustomSpinnerColumn (SpinnerColumn):
17
+
18
+ def __init__ (
19
+ self,
20
+ spinner_name="dots",
21
+ success_text="[bold green]✔[/bold green]",
22
+ failure_text="[bright_red]✘[/bright_red]",
23
+ style="",
24
+ ):
25
+ super().__init__(spinner_name=spinner_name, style=style)
26
+ self.success_text = success_text
27
+ self.failure_text = failure_text
28
+
29
+ def render (self, task):
30
+ done_text = (
31
+ self.failure_text
32
+ if task.fields.get("status") == "error"
33
+ else self.success_text
34
+ )
35
+ return done_text if task.finished else self.spinner
36
+
37
+ class CustomTextColumn (TextColumn):
38
+ """Custom text column that changes color based on task status"""
39
+
40
+ def __init__ (self, text_format="{task.description}"):
41
+ super().__init__(text_format)
42
+
43
+ def render (self, task):
44
+ # Indent and color
45
+ description = task.description
46
+ indent_level = task.fields.get("indent_level", 0)
47
+ indent = self.__get_indent(indent_level)
48
+ task.description = f"{indent}{description}"
49
+ if task.fields.get("status") == "error":
50
+ task.description = f"[bright_red]{task.description}[/bright_red]"
51
+ # Render
52
+ text = super().render(task)
53
+ task.description = description
54
+ # Return
55
+ return text
56
+
57
+ def __get_indent (self, level: int) -> str:
58
+ if level == 0:
59
+ return ""
60
+ indicator = "└── "
61
+ return " " * len(indicator) * (level - 1) + indicator
62
+
63
+ class CustomProgress(Progress):
64
+
65
+ def __init__ (
66
+ self,
67
+ *columns: ProgressColumn,
68
+ console=None,
69
+ auto_refresh=True,
70
+ refresh_per_second = 10,
71
+ speed_estimate_period=30,
72
+ transient=False,
73
+ redirect_stdout=True,
74
+ redirect_stderr=True,
75
+ get_time=None,
76
+ disable=False,
77
+ expand=False
78
+ ):
79
+ default_columns = list(columns) if len(columns) > 0 else [
80
+ CustomSpinnerColumn(),
81
+ CustomTextColumn("[progress.description]{task.description}"),
82
+ ]
83
+ super().__init__(
84
+ *default_columns,
85
+ console=console,
86
+ auto_refresh=auto_refresh,
87
+ refresh_per_second=refresh_per_second,
88
+ speed_estimate_period=speed_estimate_period,
89
+ transient=transient,
90
+ redirect_stdout=redirect_stdout,
91
+ redirect_stderr=redirect_stderr,
92
+ get_time=get_time,
93
+ disable=disable,
94
+ expand=expand
95
+ )
96
+ self.default_columns = default_columns
97
+
98
+ def __enter__ (self):
99
+ self._token = current_progress.set(self)
100
+ self._stack_token = progress_task_stack.set([])
101
+ return super().__enter__()
102
+
103
+ def __exit__ (self, exc_type, exc_val, exc_tb):
104
+ current_progress.reset(self._token)
105
+ progress_task_stack.reset(self._stack_token)
106
+ return super().__exit__(exc_type, exc_val, exc_tb)
107
+
108
+ def get_renderables (self):
109
+ for task in self.tasks:
110
+ task_columns = task.fields.get("columns") or list()
111
+ self.columns = self.default_columns + task_columns
112
+ yield self.make_tasks_table([task])
113
+
114
+ class CustomProgressTask:
115
+
116
+ def __init__ (
117
+ self,
118
+ *,
119
+ loading_text: str,
120
+ done_text: str=None,
121
+ columns: list[ProgressColumn]=None
122
+ ):
123
+ self.loading_text = loading_text
124
+ self.done_text = done_text
125
+ self.task_id = None
126
+ self.columns = columns
127
+
128
+ def __enter__ (self):
129
+ progress = current_progress.get()
130
+ if progress is not None:
131
+ self.task_id = progress.add_task(
132
+ self.loading_text,
133
+ total=1,
134
+ columns=self.columns,
135
+ indent_level=len(progress_task_stack.get())
136
+ )
137
+ current_stack = progress_task_stack.get()
138
+ progress_task_stack.set(current_stack + [self.task_id])
139
+ return self
140
+
141
+ def __exit__ (self, exc_type, exc_val, exc_tb):
142
+ progress = current_progress.get()
143
+ if progress is not None and self.task_id is not None:
144
+ current_task = progress._tasks[self.task_id]
145
+ progress.update(
146
+ self.task_id,
147
+ description=self.done_text or current_task.description,
148
+ completed=current_task.total,
149
+ status="error" if exc_type is not None else current_task.fields.get("status")
150
+ )
151
+ current_stack = progress_task_stack.get()
152
+ if current_stack:
153
+ progress_task_stack.set(current_stack[:-1])
154
+ self.task_id = None
155
+ return False
156
+
157
+ def update (self, **kwargs):
158
+ progress = current_progress.get()
159
+ if progress is None or self.task_id is None:
160
+ return
161
+ progress.update(self.task_id, **kwargs)
162
+
163
+ def finish (self, message: str):
164
+ self.done_text = message
165
+
166
+ class TracebackMarkupConsole (Console):
167
+
168
+ def print(
169
+ self,
170
+ *objects,
171
+ sep = " ",
172
+ end = "\n",
173
+ style = None,
174
+ justify = None,
175
+ overflow = None,
176
+ no_wrap = None,
177
+ emoji = None,
178
+ markup = None,
179
+ highlight = None,
180
+ width = None,
181
+ height = None,
182
+ crop = True,
183
+ soft_wrap = None,
184
+ new_line_start = False
185
+ ):
186
+ traceback = objects[0]
187
+ if isinstance(traceback, Traceback):
188
+ stack = traceback.trace.stacks[0]
189
+ original_rich_console = traceback.__rich_console__
190
+ def __rich_console__ (self: Traceback, console: Console, options: ConsoleOptions) -> RenderResult:
191
+ for renderable in original_rich_console(console, options):
192
+ if (
193
+ isinstance(renderable, Text) and
194
+ any(part.startswith(f"{stack.exc_type}:") for part in renderable._text)
195
+ ):
196
+ yield Text.assemble(
197
+ (f"{stack.exc_type}: ", "traceback.exc_type"),
198
+ Text.from_markup(stack.exc_value)
199
+ )
200
+ else:
201
+ yield renderable
202
+ traceback.__rich_console__ = MethodType(__rich_console__, traceback)
203
+ return super().print(
204
+ *objects,
205
+ sep=sep,
206
+ end=end,
207
+ style=style,
208
+ justify=justify,
209
+ overflow=overflow,
210
+ no_wrap=no_wrap,
211
+ emoji=emoji,
212
+ markup=markup,
213
+ highlight=highlight,
214
+ width=width,
215
+ height=height,
216
+ crop=crop,
217
+ soft_wrap=soft_wrap,
218
+ new_line_start=new_line_start
219
+ )
@@ -4,13 +4,16 @@
4
4
  #
5
5
 
6
6
  from __future__ import annotations
7
+ from abc import ABC, abstractmethod
7
8
  from hashlib import sha256
8
9
  from pathlib import Path
9
10
  from pydantic import BaseModel
10
11
  from requests import put
12
+ from rich.progress import BarColumn, TextColumn
11
13
  from typing import Literal
12
14
 
13
- from ..function import Function
15
+ from .function import Function
16
+ from .logging import CustomProgressTask
14
17
 
15
18
  class WorkdirCommand (BaseModel):
16
19
  kind: Literal["workdir"] = "workdir"
@@ -20,17 +23,35 @@ class EnvCommand (BaseModel):
20
23
  kind: Literal["env"] = "env"
21
24
  env: dict[str, str]
22
25
 
23
- class UploadFileCommand (BaseModel):
24
- kind: Literal["upload_file"] = "upload_file"
26
+ class UploadableCommand (BaseModel, ABC):
25
27
  from_path: str
26
28
  to_path: str
27
29
  manifest: dict[str, str] | None = None
28
30
 
29
- class UploadDirectoryCommand (BaseModel):
31
+ @abstractmethod
32
+ def get_files (self) -> list[Path]:
33
+ pass
34
+
35
+ class UploadFileCommand (UploadableCommand):
36
+ kind: Literal["upload_file"] = "upload_file"
37
+
38
+ def get_files (self) -> list[Path]:
39
+ return [Path(self.from_path).resolve()]
40
+
41
+ class UploadDirectoryCommand (UploadableCommand):
30
42
  kind: Literal["upload_dir"] = "upload_dir"
31
- from_path: str
32
- to_path: str
33
- manifest: dict[str, str] | None = None
43
+
44
+ def get_files (self) -> list[Path]:
45
+ from_path = Path(self.from_path)
46
+ assert from_path.is_absolute(), "Cannot upload directory because directory path must be absolute"
47
+ return [file for file in from_path.rglob("*") if file.is_file()]
48
+
49
+ class EntrypointCommand (UploadableCommand):
50
+ kind: Literal["entrypoint"] = "entrypoint"
51
+ name: str
52
+
53
+ def get_files (self) -> list[Path]:
54
+ return [Path(self.from_path).resolve()]
34
55
 
35
56
  class PipInstallCommand (BaseModel):
36
57
  kind: Literal["pip_install"] = "pip_install"
@@ -40,10 +61,6 @@ class AptInstallCommand (BaseModel):
40
61
  kind: Literal["apt_install"] = "apt_install"
41
62
  packages: list[str]
42
63
 
43
- class EntrypointCommand (BaseModel):
44
- kind: Literal["entrypoint"] = "entrypoint"
45
- path: str
46
-
47
64
  Command = (
48
65
  WorkdirCommand |
49
66
  EnvCommand |
@@ -68,16 +85,14 @@ class Sandbox (BaseModel):
68
85
  path (str | Path): Path to change to.
69
86
  """
70
87
  command = WorkdirCommand(path=str(path))
71
- self.commands.append(command)
72
- return self
88
+ return Sandbox(commands=self.commands + [command])
73
89
 
74
90
  def env (self, **env: str) -> Sandbox:
75
91
  """
76
92
  Set environment variables in the sandbox.
77
93
  """
78
94
  command = EnvCommand(env=env)
79
- self.commands.append(command)
80
- return self
95
+ return Sandbox(commands=self.commands + [command])
81
96
 
82
97
  def upload_file (
83
98
  self,
@@ -92,8 +107,7 @@ class Sandbox (BaseModel):
92
107
  to_path (str | Path): Remote path to upload file to.
93
108
  """
94
109
  command = UploadFileCommand(from_path=str(from_path), to_path=str(to_path))
95
- self.commands.append(command)
96
- return self
110
+ return Sandbox(commands=self.commands + [command])
97
111
 
98
112
  def upload_directory (
99
113
  self,
@@ -108,8 +122,7 @@ class Sandbox (BaseModel):
108
122
  to_path (str | Path): Remote path to upload directory to.
109
123
  """
110
124
  command = UploadDirectoryCommand(from_path=str(from_path), to_path=str(to_path))
111
- self.commands.append(command)
112
- return self
125
+ return Sandbox(commands=self.commands + [command])
113
126
 
114
127
  def pip_install (self, *packages: str) -> Sandbox:
115
128
  """
@@ -119,8 +132,7 @@ class Sandbox (BaseModel):
119
132
  packages (list): Packages to install.
120
133
  """
121
134
  command = PipInstallCommand(packages=packages)
122
- self.commands.append(command)
123
- return self
135
+ return Sandbox(commands=self.commands + [command])
124
136
 
125
137
  def apt_install (self, *packages: str) -> Sandbox:
126
138
  """
@@ -130,24 +142,41 @@ class Sandbox (BaseModel):
130
142
  packages (list): Packages to install.
131
143
  """
132
144
  command = AptInstallCommand(packages=packages)
133
- self.commands.append(command)
134
- return self
135
-
136
- def populate (self, fxn: Function=None) -> Sandbox:
145
+ return Sandbox(commands=self.commands + [command])
146
+
147
+ def populate (self, fxn: Function=None) -> Sandbox: # CHECK # In place
137
148
  """
138
149
  Populate all metadata.
139
150
  """
140
151
  fxn = fxn if fxn is not None else Function()
152
+ entrypoint = next(cmd for cmd in self.commands if isinstance(cmd, EntrypointCommand))
153
+ entry_path = Path(entrypoint.from_path).resolve()
141
154
  for command in self.commands:
142
- if isinstance(command, UploadFileCommand):
143
- from_path = Path(command.from_path)
144
- to_path = Path(command.to_path)
145
- command.manifest = { str(to_path / from_path.name): self.__upload_file(from_path, fxn=fxn) }
146
- elif isinstance(command, UploadDirectoryCommand):
155
+ if isinstance(command, UploadableCommand):
156
+ cwd = Path.cwd()
147
157
  from_path = Path(command.from_path)
148
158
  to_path = Path(command.to_path)
149
- files = [file for file in from_path.rglob("*") if file.is_file()]
150
- command.manifest = { str(to_path / file.relative_to(from_path)): self.__upload_file(file, fxn=fxn) for file in files }
159
+ if not from_path.is_absolute():
160
+ from_path = (entry_path / from_path).resolve()
161
+ command.from_path = str(from_path)
162
+ files = command.get_files()
163
+ name = from_path.relative_to(cwd) if from_path.is_relative_to(cwd) else from_path.resolve()
164
+ with CustomProgressTask(
165
+ loading_text=f"Uploading [light_slate_blue]{name}[/light_slate_blue]...",
166
+ done_text=f"Uploaded [light_slate_blue]{name}[/light_slate_blue]",
167
+ columns=[
168
+ BarColumn(),
169
+ TextColumn("{task.completed}/{task.total}")
170
+ ]
171
+ ) as task:
172
+ manifest = { }
173
+ for idx, file in enumerate(files):
174
+ rel_file_path = file.relative_to(from_path) if from_path.is_dir() else file.name
175
+ dst_path = to_path / rel_file_path
176
+ checksum = self.__upload_file(file, fxn=fxn)
177
+ manifest[str(dst_path)] = checksum
178
+ task.update(total=len(files), completed=idx+1)
179
+ command.manifest = manifest
151
180
  return self
152
181
 
153
182
  def __upload_file (self, path: Path, fxn: Function) -> str:
@@ -172,6 +201,6 @@ class Sandbox (BaseModel):
172
201
  for chunk in iter(lambda: f.read(4096), b""):
173
202
  hash.update(chunk)
174
203
  return hash.hexdigest()
175
-
204
+
176
205
  class _Resource (BaseModel):
177
206
  url: str