service-forge 0.1.0__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.
Files changed (64) hide show
  1. service_forge/api/http_api.py +138 -0
  2. service_forge/api/kafka_api.py +126 -0
  3. service_forge/api/task_manager.py +141 -0
  4. service_forge/api/websocket_api.py +86 -0
  5. service_forge/api/websocket_manager.py +425 -0
  6. service_forge/db/__init__.py +1 -0
  7. service_forge/db/database.py +119 -0
  8. service_forge/llm/__init__.py +62 -0
  9. service_forge/llm/llm.py +56 -0
  10. service_forge/main.py +121 -0
  11. service_forge/model/__init__.py +0 -0
  12. service_forge/model/websocket.py +13 -0
  13. service_forge/proto/foo_input.py +5 -0
  14. service_forge/service.py +111 -0
  15. service_forge/service_config.py +115 -0
  16. service_forge/sft/cli.py +91 -0
  17. service_forge/sft/cmd/config_command.py +67 -0
  18. service_forge/sft/cmd/deploy_service.py +124 -0
  19. service_forge/sft/cmd/list_tars.py +41 -0
  20. service_forge/sft/cmd/service_command.py +149 -0
  21. service_forge/sft/cmd/upload_service.py +36 -0
  22. service_forge/sft/config/injector.py +87 -0
  23. service_forge/sft/config/injector_default_files.py +97 -0
  24. service_forge/sft/config/sf_metadata.py +30 -0
  25. service_forge/sft/config/sft_config.py +125 -0
  26. service_forge/sft/file/__init__.py +0 -0
  27. service_forge/sft/file/ignore_pattern.py +80 -0
  28. service_forge/sft/file/sft_file_manager.py +107 -0
  29. service_forge/sft/kubernetes/kubernetes_manager.py +257 -0
  30. service_forge/sft/util/assert_util.py +25 -0
  31. service_forge/sft/util/logger.py +16 -0
  32. service_forge/sft/util/name_util.py +2 -0
  33. service_forge/utils/__init__.py +0 -0
  34. service_forge/utils/default_type_converter.py +12 -0
  35. service_forge/utils/register.py +39 -0
  36. service_forge/utils/type_converter.py +74 -0
  37. service_forge/workflow/__init__.py +1 -0
  38. service_forge/workflow/context.py +13 -0
  39. service_forge/workflow/edge.py +31 -0
  40. service_forge/workflow/node.py +179 -0
  41. service_forge/workflow/nodes/__init__.py +7 -0
  42. service_forge/workflow/nodes/control/if_node.py +29 -0
  43. service_forge/workflow/nodes/input/console_input_node.py +26 -0
  44. service_forge/workflow/nodes/llm/query_llm_node.py +41 -0
  45. service_forge/workflow/nodes/nested/workflow_node.py +28 -0
  46. service_forge/workflow/nodes/output/kafka_output_node.py +27 -0
  47. service_forge/workflow/nodes/output/print_node.py +29 -0
  48. service_forge/workflow/nodes/test/if_console_input_node.py +33 -0
  49. service_forge/workflow/nodes/test/time_consuming_node.py +61 -0
  50. service_forge/workflow/port.py +86 -0
  51. service_forge/workflow/trigger.py +20 -0
  52. service_forge/workflow/triggers/__init__.py +4 -0
  53. service_forge/workflow/triggers/fast_api_trigger.py +125 -0
  54. service_forge/workflow/triggers/kafka_api_trigger.py +44 -0
  55. service_forge/workflow/triggers/once_trigger.py +20 -0
  56. service_forge/workflow/triggers/period_trigger.py +26 -0
  57. service_forge/workflow/workflow.py +251 -0
  58. service_forge/workflow/workflow_factory.py +227 -0
  59. service_forge/workflow/workflow_group.py +23 -0
  60. service_forge/workflow/workflow_type.py +52 -0
  61. service_forge-0.1.0.dist-info/METADATA +93 -0
  62. service_forge-0.1.0.dist-info/RECORD +64 -0
  63. service_forge-0.1.0.dist-info/WHEEL +4 -0
  64. service_forge-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,149 @@
1
+ from typing import List, Optional
2
+ import typer
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from kubernetes import client, config
6
+ from kubernetes.client.rest import ApiException
7
+
8
+ from service_forge.sft.config.sft_config import sft_config
9
+ from service_forge.sft.util.logger import log_error, log_info, log_success, log_warning
10
+ from service_forge.sft.kubernetes.kubernetes_manager import KubernetesManager
11
+
12
+ def list_services() -> None:
13
+ namespace = sft_config.k8s_namespace
14
+ kubernetes_manager = KubernetesManager()
15
+ services = kubernetes_manager.get_services_in_namespace(namespace)
16
+
17
+ if not services:
18
+ log_warning(f"No services starting with 'sf-' found in namespace '{namespace}'")
19
+ return
20
+
21
+ console = Console()
22
+ table = Table(title=f"Services in namespace '{namespace}' (sf-*)", show_header=True, header_style="bold magenta")
23
+ table.add_column("Name", style="cyan", no_wrap=True)
24
+ table.add_column("Type", style="green")
25
+ table.add_column("Port", style="yellow")
26
+ table.add_column("Target Port", style="yellow")
27
+
28
+ for service_name in sorted(services):
29
+ details = kubernetes_manager.get_service_details(namespace, service_name)
30
+ table.add_row(
31
+ details.name,
32
+ details.type or "-",
33
+ str(details.port or "-"),
34
+ str(details.target_port or "-")
35
+ )
36
+
37
+ console.print(table)
38
+ log_info(f"Found {len(services)} service(s)")
39
+
40
+ def show_logs(
41
+ namespace: str,
42
+ pod_name: str,
43
+ container_name: str,
44
+ tail: int,
45
+ follow: bool,
46
+ previous: bool
47
+ ) -> None:
48
+ kubernetes_manager = KubernetesManager()
49
+ try:
50
+ if not follow:
51
+ log_info(f"Fetching logs from pod '{pod_name}' (container: {container_name})...")
52
+
53
+ logs = kubernetes_manager.get_pod_logs(
54
+ namespace=namespace,
55
+ pod_name=pod_name,
56
+ container_name=container_name,
57
+ tail=tail,
58
+ follow=follow,
59
+ previous=previous
60
+ )
61
+
62
+ if follow:
63
+ try:
64
+ for line in logs:
65
+ log_info(line, end="")
66
+ except KeyboardInterrupt:
67
+ log_warning("Log streaming interrupted")
68
+ raise typer.Exit(0)
69
+ else:
70
+ if logs:
71
+ log_info(logs)
72
+ else:
73
+ log_warning(f"No logs available for pod '{pod_name}' container '{container_name}'")
74
+
75
+ except ApiException as e:
76
+ if e.status == 404:
77
+ log_error(f"Pod '{pod_name}' or container '{container_name}' not found")
78
+ elif e.status == 400:
79
+ log_error(f"Bad request: {e.reason}")
80
+ if "previous" in str(e.body).lower():
81
+ log_info("Note: 'previous' flag only works for stopped containers")
82
+ else:
83
+ log_error(f"Failed to get logs: {e.reason}")
84
+ if e.body:
85
+ log_error(f"Error details: {e.body}")
86
+ except Exception as e:
87
+ log_error(f"Failed to get logs: {e}")
88
+
89
+
90
+ def show_service_logs(
91
+ service_name: str,
92
+ container: Optional[str] = None,
93
+ tail: int = 100,
94
+ follow: bool = False,
95
+ previous: bool = False
96
+ ) -> None:
97
+ namespace = sft_config.k8s_namespace
98
+ kubernetes_manager = KubernetesManager()
99
+
100
+ if not service_name.startswith("sf-"):
101
+ log_warning(f"Service name '{service_name}' does not start with 'sf-'. Proceeding anyway...")
102
+
103
+ services = kubernetes_manager.get_services_in_namespace(namespace)
104
+ if service_name not in services:
105
+ log_error(f"Service '{service_name}' not found in namespace '{namespace}'")
106
+ log_info(f"Available services: {', '.join(services) if services else 'None'}")
107
+ raise typer.Exit(1)
108
+
109
+ pod_names = kubernetes_manager.get_pods_for_service(namespace, service_name)
110
+ if not pod_names:
111
+ log_error(f"No pods found for service '{service_name}' in namespace '{namespace}'")
112
+ raise typer.Exit(1)
113
+
114
+ log_info(f"Found {len(pod_names)} pod(s) for service '{service_name}'")
115
+
116
+ for pod_name in pod_names:
117
+ containers = kubernetes_manager.get_pod_containers(namespace, pod_name)
118
+
119
+ if not containers:
120
+ log_warning(f"No containers found in pod '{pod_name}'")
121
+ continue
122
+
123
+ if container and container not in containers:
124
+ log_error(f"Container '{container}' not found in pod '{pod_name}'")
125
+ log_info(f"Available containers: {', '.join(containers)}")
126
+ continue
127
+
128
+ target_containers = [container] if container else containers
129
+
130
+ for container_name in target_containers:
131
+ show_logs(namespace, pod_name, container_name, tail, follow, previous)
132
+
133
+ def delete_service(service_name: str, force: bool = False) -> None:
134
+ namespace = sft_config.k8s_namespace
135
+
136
+ if not service_name.startswith("sf-"):
137
+ log_error(f"Service name '{service_name}' does not start with 'sf-'")
138
+ raise typer.Exit(1)
139
+
140
+ kubernetes_manager = KubernetesManager()
141
+
142
+ services = kubernetes_manager.get_services_in_namespace(namespace)
143
+ if service_name not in services:
144
+ log_warning(f"Service '{service_name}' not found in namespace '{namespace}'")
145
+ log_info(f"Available services: {', '.join(services) if services else 'None'}")
146
+
147
+ log_info(f"Deleting service '{service_name}' from namespace '{namespace}'...")
148
+ kubernetes_manager.delete_service(namespace, service_name, force)
149
+
@@ -0,0 +1,36 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from service_forge.sft.util.logger import log_error, log_success
4
+ from service_forge.sft.file.sft_file_manager import sft_file_manager
5
+ from service_forge.sft.util.assert_util import assert_dir_exists, assert_file_exists
6
+ from service_forge.sft.config.sf_metadata import load_metadata
7
+
8
+ def upload_service(project_path: str) -> None:
9
+ project_dir = Path(project_path).resolve()
10
+ assert_dir_exists(project_dir)
11
+
12
+ metadata_path = project_dir / "sf-meta.yaml"
13
+ assert_file_exists(metadata_path)
14
+
15
+ try:
16
+ meta_data = load_metadata(metadata_path)
17
+ except Exception as e:
18
+ log_error(f"Failed to read sf-meta.yaml: {e}")
19
+ raise typer.Exit(1)
20
+
21
+ try:
22
+ tar_file = sft_file_manager.create_tar(project_dir, meta_data.name, meta_data.version)
23
+ except Exception as e:
24
+ log_error(f"Failed to create tar file: {e}")
25
+ raise typer.Exit(1)
26
+
27
+ log_success(f"Packaging successful: {tar_file}")
28
+
29
+ # upload to the service
30
+ try:
31
+ sft_file_manager.upload_tar(tar_file)
32
+ except Exception as e:
33
+ log_error(f"Failed to upload tar file: {e}")
34
+ raise typer.Exit(1)
35
+
36
+ log_success(f"Upload successful: {tar_file}")
@@ -0,0 +1,87 @@
1
+ from omegaconf import OmegaConf
2
+ from pathlib import Path
3
+ from service_forge.sft.util.logger import log_info, log_error
4
+ from service_forge.sft.config.injector_default_files import *
5
+ from service_forge.sft.config.sf_metadata import load_metadata
6
+ from service_forge.sft.config.sft_config import sft_config
7
+ from service_forge.service_config import ServiceConfig
8
+ from service_forge.sft.util.name_util import get_service_name
9
+
10
+ class Injector:
11
+ def __init__(self, project_dir: Path):
12
+ self.project_dir = project_dir
13
+ self.deployment_yaml_path = project_dir / "deployment.yaml"
14
+ self.metadata_path = project_dir / "sf-meta.yaml"
15
+ self.ingress_yaml_path = project_dir / "ingress.yaml"
16
+ self.dockerfile_path = project_dir / "Dockerfile"
17
+ self.metadata = load_metadata(self.metadata_path)
18
+ self.name = self.metadata.name
19
+ self.version = self.metadata.version
20
+ self.namespace = sft_config.k8s_namespace
21
+
22
+ @property
23
+ def service_name(self) -> str:
24
+ return get_service_name(self.name, self.version)
25
+
26
+ def inject_deployment(self) -> None:
27
+ envs = {
28
+ "DEEPSEEK_API_KEY": sft_config.deepseek_api_key,
29
+ "DEEPSEEK_BASE_URL": sft_config.deepseek_base_url,
30
+ }
31
+
32
+ for env in self.metadata.env:
33
+ envs[env['name']] = env['value']
34
+
35
+ env_str = ""
36
+ for key, value in envs.items():
37
+ env_str += f" - name: {key}\n value: {value}\n"
38
+
39
+ deployment_yaml = DEFAULT_DEPLOYMENT_YAML.format(
40
+ service_name=self.service_name,
41
+ name=self.name,
42
+ version=self.version,
43
+ namespace=self.namespace,
44
+ env=env_str,
45
+ )
46
+ with open(self.deployment_yaml_path, "w") as f:
47
+ f.write(deployment_yaml)
48
+ print("deployment_yaml_path: ", self.deployment_yaml_path)
49
+
50
+ def inject_service_config(self) -> None:
51
+ service_config_path = self.project_dir / Path(self.metadata.service_config)
52
+ config = ServiceConfig.from_dict(OmegaConf.to_object(OmegaConf.load(self.project_dir / self.metadata.service_config)))
53
+
54
+ config.http_port = sft_config.inject_http_port
55
+ config.kafka_host = sft_config.inject_kafka_host
56
+ config.kafka_port = sft_config.inject_kafka_port
57
+ for database in config.databases:
58
+ database.postgres_host = sft_config.inject_postgres_host
59
+ database.postgres_port = sft_config.inject_postgres_port
60
+ database.postgres_user = sft_config.inject_postgres_user
61
+ database.postgres_password = sft_config.inject_postgres_password
62
+ database.postgres_db = self.service_name
63
+
64
+ with open(service_config_path, "w") as f:
65
+ f.write(OmegaConf.to_yaml(config.to_dict()))
66
+
67
+ def inject_ingress(self) -> None:
68
+ ingress_yaml = DEFAULT_TRAEFIK_INGRESS_YAML.format(
69
+ name=self.name,
70
+ version=self.version.replace(".", "-"),
71
+ namespace=self.namespace,
72
+ )
73
+ with open(self.ingress_yaml_path, "w") as f:
74
+ f.write(ingress_yaml)
75
+ print("ingress_yaml_path: ", self.ingress_yaml_path)
76
+
77
+ def inject_dockerfile(self) -> None:
78
+ dockerfile = DEFAULT_DOCKERFILE
79
+ with open(self.dockerfile_path, "w") as f:
80
+ f.write(dockerfile)
81
+ print("dockerfile_path: ", self.dockerfile_path)
82
+
83
+ def inject(self) -> None:
84
+ self.inject_deployment()
85
+ self.inject_service_config()
86
+ self.inject_ingress()
87
+ self.inject_dockerfile()
@@ -0,0 +1,97 @@
1
+ DEFAULT_DEPLOYMENT_YAML = """
2
+ apiVersion: apps/v1
3
+ kind: Deployment
4
+ metadata:
5
+ name: {service_name}
6
+ namespace: {namespace}
7
+ spec:
8
+ replicas: 1
9
+ selector:
10
+ matchLabels:
11
+ app: {service_name}
12
+ template:
13
+ metadata:
14
+ labels:
15
+ app: {service_name}
16
+ spec:
17
+ imagePullSecrets:
18
+ - name: aliyun-regcred
19
+ containers:
20
+ - name: {service_name}
21
+ image: crpi-cev6qq28wwgwwj0y.cn-beijing.personal.cr.aliyuncs.com/nexthci/sf-{name}:{version}
22
+ imagePullPolicy: Always
23
+ ports:
24
+ - containerPort: 8000
25
+ env:
26
+ {env}
27
+
28
+ ---
29
+ apiVersion: v1
30
+ kind: Service
31
+ metadata:
32
+ name: {service_name}
33
+ namespace: {namespace}
34
+ spec:
35
+ type: ClusterIP
36
+ selector:
37
+ app: {service_name}
38
+ ports:
39
+ - protocol: TCP
40
+ port: 80
41
+ targetPort: 8000
42
+ """
43
+
44
+ DEFAULT_TRAEFIK_INGRESS_YAML = """
45
+ apiVersion: traefik.io/v1alpha1
46
+ kind: Middleware
47
+ metadata:
48
+ name: strip-prefix-sf-{name}-{version}v
49
+ namespace: {namespace}
50
+ spec:
51
+ stripPrefix:
52
+ prefixes:
53
+ - /api/v1/{name}-{version}
54
+
55
+ ---
56
+
57
+ apiVersion: traefik.io/v1alpha1
58
+ kind: IngressRoute
59
+ metadata:
60
+ name: sf-{name}-{version}v
61
+ namespace: {namespace}
62
+ spec:
63
+ entryPoints:
64
+ - web
65
+ routes:
66
+ - match: PathPrefix(`/api/v1/{name}-{version}`)
67
+ kind: Rule
68
+ services:
69
+ - name: sf-{name}-{version}v
70
+ namespace: {namespace}
71
+ port: 80
72
+ middlewares:
73
+ - name: strip-prefix-sf-{name}-{version}v
74
+ namespace: {namespace}
75
+ - name: jwt-auth
76
+ namespace: {namespace}
77
+ """
78
+
79
+ DEFAULT_DOCKERFILE = """
80
+ FROM crpi-cev6qq28wwgwwj0y.cn-beijing.personal.cr.aliyuncs.com/nexthci/service-forge:latest
81
+
82
+ WORKDIR /app
83
+
84
+ COPY . ./service
85
+
86
+ WORKDIR /app
87
+ RUN uv sync
88
+
89
+ ENV PYTHONPATH=/app/service:/app:/app/src
90
+ ENV PATH="/app/.venv/bin:$PATH"
91
+
92
+ WORKDIR /app/service
93
+
94
+ RUN chmod +x start.sh
95
+
96
+ CMD ["./start.sh"]
97
+ """
@@ -0,0 +1,30 @@
1
+ from omegaconf import OmegaConf
2
+
3
+ class SfMetadata:
4
+ def __init__(
5
+ self,
6
+ name: str,
7
+ version: str,
8
+ description: str,
9
+ service_config: str,
10
+ config_only: bool,
11
+ env: list[dict],
12
+ ) -> None:
13
+ self.name = name
14
+ self.version = version
15
+ self.description = description
16
+ self.service_config = service_config
17
+ self.config_only = config_only
18
+ self.env = env
19
+
20
+ def load_metadata(path: str) -> SfMetadata:
21
+ with open(path, 'r') as file:
22
+ data = OmegaConf.load(file)
23
+ return SfMetadata(
24
+ name=data.get('name'),
25
+ version=data.get('version'),
26
+ description=data.get('description'),
27
+ service_config=data.get('service_config'),
28
+ config_only=data.get('config_only'),
29
+ env=data.get('env', []),
30
+ )
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from omegaconf import OmegaConf
7
+
8
+ class SftConfig:
9
+ CONFIG_ROOT = Path.home() / ".sft"
10
+
11
+ # Configuration descriptions mapping
12
+ CONFIG_DESCRIPTIONS = {
13
+ "sft_file_root": "SFT file storage root directory",
14
+ "service_center_address": "Service center address",
15
+ "k8s_namespace": "Kubernetes namespace",
16
+ "registry_address": "Registry address",
17
+ "inject_http_port": "HTTP port for services",
18
+ "inject_kafka_host": "Kafka host for services",
19
+ "inject_kafka_port": "Kafka port for services",
20
+ "inject_postgres_host": "Postgres host for services",
21
+ "inject_postgres_port": "Postgres port for services",
22
+ "inject_postgres_user": "Postgres user for services",
23
+ "inject_postgres_password": "Postgres password for services",
24
+ "deepseek_api_key": "DeepSeek API key",
25
+ "deepseek_base_url": "DeepSeek base URL",
26
+ }
27
+
28
+ def __init__(
29
+ self,
30
+ sft_file_root: str = "/tmp/sft",
31
+ service_center_address: str = "http://vps.shiweinan.com:37919/service_center",
32
+ k8s_namespace: str = "secondbrain",
33
+ registry_address: str = "crpi-cev6qq28wwgwwj0y.cn-beijing.personal.cr.aliyuncs.com/nexthci",
34
+ inject_http_port: int = 8000,
35
+ inject_kafka_host: str = "localhost",
36
+ inject_kafka_port: int = 9092,
37
+ inject_postgres_host: str = "second-brain-postgres-postgresql",
38
+ inject_postgres_port: int = 5432,
39
+ inject_postgres_user: str = "postgres",
40
+ inject_postgres_password: str = "gnBGWg7aL4",
41
+ deepseek_api_key: str = "82c9df22-f6ed-411e-90d7-c5255376b7ca",
42
+ deepseek_base_url: str = "https://ark.cn-beijing.volces.com/api/v3",
43
+ ):
44
+ self.sft_file_root = sft_file_root
45
+ self.service_center_address = service_center_address
46
+ self.k8s_namespace = k8s_namespace
47
+ self.registry_address = registry_address
48
+ self.inject_http_port = inject_http_port
49
+ self.inject_kafka_host = inject_kafka_host
50
+ self.inject_kafka_port = inject_kafka_port
51
+ self.inject_postgres_host = inject_postgres_host
52
+ self.inject_postgres_port = inject_postgres_port
53
+ self.inject_postgres_user = inject_postgres_user
54
+ self.inject_postgres_password = inject_postgres_password
55
+ self.deepseek_api_key = deepseek_api_key
56
+ self.deepseek_base_url = deepseek_base_url
57
+
58
+ @property
59
+ def server_url(self) -> str:
60
+ return self.service_center_address
61
+
62
+ @property
63
+ def upload_timeout(self) -> int:
64
+ return 300 # 5 minutes default timeout
65
+
66
+ @classmethod
67
+ def get_config_keys(cls) -> list[str]:
68
+ sig = inspect.signature(cls.__init__)
69
+ return [param for param in sig.parameters.keys() if param != 'self']
70
+
71
+ @property
72
+ def config_file_path(self) -> Path:
73
+ return self.CONFIG_ROOT / "config.yaml"
74
+
75
+ def ensure_config_dir(self) -> None:
76
+ self.CONFIG_ROOT.mkdir(parents=True, exist_ok=True)
77
+
78
+ def to_dict(self) -> dict:
79
+ config_keys = self.get_config_keys()
80
+ return {key: getattr(self, key) for key in config_keys}
81
+
82
+ def from_dict(self, data: dict) -> None:
83
+ config_keys = self.get_config_keys()
84
+ for key in config_keys:
85
+ if key in data:
86
+ setattr(self, key, data[key])
87
+
88
+ def save(self) -> None:
89
+ self.ensure_config_dir()
90
+ config_dict = self.to_dict()
91
+ OmegaConf.save(config_dict, self.config_file_path)
92
+
93
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
94
+ return getattr(self, key, default)
95
+
96
+ def set(self, key: str, value: str) -> None:
97
+ if key in ["config_root"]:
98
+ raise ValueError(f"{key} is read-only")
99
+ if hasattr(self, key):
100
+ setattr(self, key, value)
101
+ else:
102
+ raise ValueError(f"Unknown config key: {key}")
103
+
104
+ def update(self, updates: dict) -> None:
105
+ for key, value in updates.items():
106
+ self.set(key, value)
107
+
108
+
109
+ def load_config() -> SftConfig:
110
+ # config = SftConfig()
111
+ config = SftConfig()
112
+ config_file = config.config_file_path
113
+
114
+ if config_file.exists():
115
+ try:
116
+ data = OmegaConf.load(config_file)
117
+ config = SftConfig(**OmegaConf.to_container(data, resolve=True))
118
+ except Exception as e:
119
+ ...
120
+
121
+ config.save()
122
+
123
+ return config
124
+
125
+ sft_config = load_config()
File without changes
@@ -0,0 +1,80 @@
1
+ import fnmatch
2
+ import re
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+
7
+ class IgnorePattern:
8
+ def __init__(self, patterns: List[str], base_path: Path):
9
+ self.base_path = base_path.resolve()
10
+ self.patterns: List[tuple[str, bool]] = []
11
+
12
+ for pattern in patterns:
13
+ pattern = pattern.strip()
14
+ if not pattern or pattern.startswith('#'):
15
+ continue
16
+
17
+ is_negation = pattern.startswith('!')
18
+ if is_negation:
19
+ pattern = pattern[1:].strip()
20
+
21
+ pattern = pattern.replace('\\', '/')
22
+
23
+ if pattern.startswith('/'):
24
+ pattern = pattern[1:]
25
+
26
+ self.patterns.append((pattern, is_negation))
27
+
28
+ def should_ignore(self, file_path: Path) -> bool:
29
+ file_path = file_path.resolve()
30
+
31
+ try:
32
+ relative_path = file_path.relative_to(self.base_path)
33
+ except ValueError:
34
+ return False
35
+
36
+ path_str = str(relative_path).replace('\\', '/')
37
+
38
+ ignored = False
39
+ for pattern, is_negation in self.patterns:
40
+ if self._match_pattern(pattern, path_str, file_path):
41
+ if is_negation:
42
+ ignored = False
43
+ else:
44
+ ignored = True
45
+
46
+ return ignored
47
+
48
+ def _match_pattern(self, pattern: str, path_str: str, file_path: Path) -> bool:
49
+ if pattern.endswith('/'):
50
+ pattern = pattern[:-1]
51
+ if not file_path.is_dir():
52
+ return False
53
+
54
+ if '**' in pattern:
55
+ regex_pattern = pattern.replace('**', '.*')
56
+ regex_pattern = fnmatch.translate(regex_pattern)
57
+ return bool(re.match(regex_pattern, path_str))
58
+
59
+ path_parts = path_str.split('/')
60
+
61
+ if '/' in pattern:
62
+ return fnmatch.fnmatch(path_str, pattern)
63
+
64
+ return any(fnmatch.fnmatch(part, pattern) for part in path_parts)
65
+
66
+
67
+ def load_ignore_patterns(project_path: Path) -> IgnorePattern:
68
+ project_path = project_path.resolve()
69
+ ignore_file = project_path / '.sftignore'
70
+
71
+ if not ignore_file.exists():
72
+ return IgnorePattern([], project_path)
73
+
74
+ try:
75
+ with open(ignore_file, 'r', encoding='utf-8') as f:
76
+ patterns = f.readlines()
77
+ return IgnorePattern(patterns, project_path)
78
+ except Exception:
79
+ return IgnorePattern([], project_path)
80
+