Skip to content

Commit bb93ae9

Browse files
author
Shammu Meenakshi Sundaram
committed
Python DSC Adapter and Test Resource implementation
1 parent d528ef6 commit bb93ae9

27 files changed

Lines changed: 2341 additions & 1 deletion

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ target/
44
bin/
55
.DS_Store
66
*.msix
7+
.vs/
78

89
# Generated files for tree-sitter
910
grammars/**/bindings/
1011
grammars/**/src/
1112
grammars/**/parser.*
1213
tree-sitter-ssh-server-config/
1314
tree-sitter-dscexpression/
15+
/adapters/__pycache__
16+
/adapters/python/__pycache__
17+
/adapters/python/pyDscAdapter/__pycache__
18+
/adapters/python/tests/__pycache__
19+
/adapters/python/tests/src/__pycache__

adapters/__init__.py

Whitespace-only changes.

adapters/powershell/Tests/powershellgroup.resource.tests.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ Describe 'PowerShell adapter resource tests' {
277277
$adapterPath = Join-Path $PSScriptRoot 'TestAdapter'
278278
$env:PATH += [System.IO.Path]::PathSeparator + $adapterPath
279279

280-
$r = '{"TestCaseId": 1}' | dsc resource test -r 'Test/TestCase' -f -
280+
$r = '{"TestCaseId": 1}' | dsc resource test -r 'Test/TestCase' -f - 2> $TestDrive/tracing.txt
281281
$LASTEXITCODE | Should -Be 0
282282
$resources = $r | ConvertFrom-Json
283283
$resources.actualState.TestCaseId | Should -Be 1

adapters/python/.project.data.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"Name": "python-adapter",
3+
"Kind": "Adapter",
4+
"CopyFiles": {
5+
"All": [
6+
"pyDscAdapter",
7+
"pythonadapter.dsc.resource.json"
8+
]
9+
}
10+
}

adapters/python/__init__.py

Whitespace-only changes.

adapters/python/pyDscAdapter/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
from cli import main
3+
4+
if __name__ == "__main__":
5+
sys.exit(main())
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import argparse
2+
import sys
3+
import json
4+
import cProfile
5+
import pstats
6+
import time
7+
import io
8+
import os
9+
from contextlib import contextmanager
10+
from datetime import datetime, timezone
11+
import inspect
12+
from pathlib import Path
13+
from typing import Any, Dict, List, Optional, Tuple
14+
from dsc_logging import setup_dsc_logging, operation_context
15+
from utils import parse_json
16+
from discovery import get_class_map_from_pyproject, import_class_from_file
17+
18+
#----------------------------------------------------------------------------
19+
# ResourceAdapter - main adapter class with registry, profiling, and logging
20+
#----------------------------------------------------------------------------
21+
class ResourceAdapter:
22+
"""
23+
Routes adapter operations to Python resource classes discovered from a
24+
pyproject.toml file near the provided resource path.
25+
26+
The adapter also provides:
27+
- profile_block for optional timing and cProfile instrumentation
28+
- log(level, message, target, **kwargs) for structured adapter logging
29+
- direct resource-type to class resolution from pyproject manifest data
30+
"""
31+
32+
def __init__(self) -> None:
33+
# Normalize DSC trace level to standard Python logging levels
34+
# Supported inputs: trace, debug, info, warning, error, critical
35+
dsc_level = (os.getenv("DSC_TRACE_LEVEL", "info") or "info").strip().lower()
36+
37+
self.logger = setup_dsc_logging(dsc_level)
38+
39+
# Enable Profiling based on DSC trace level
40+
self.ENABLE_PROFILING = dsc_level in ("trace", "debug")
41+
42+
self.logger.debug(f"Trace level: '{dsc_level}', profiling: {self.ENABLE_PROFILING}")
43+
self.logger.info("Adapter initialization complete")
44+
45+
46+
def _resolve_pyproject_path(self, resource_path: str = "") -> Optional[Path]:
47+
"""Resolve the nearest pyproject.toml containing [tool.dsc.resources] starting from resource_path."""
48+
self.logger.debug(f"Resolving pyproject.toml for resource_path='{resource_path}'")
49+
candidates: List[Path] = []
50+
51+
if resource_path:
52+
resolved_resource_path = Path(resource_path).resolve()
53+
candidates.append(resolved_resource_path.parent / "pyproject.toml")
54+
candidates.extend(parent / "pyproject.toml" for parent in resolved_resource_path.parents)
55+
56+
seen_candidates = set()
57+
for candidate in candidates:
58+
candidate_key = str(candidate).casefold()
59+
if candidate_key in seen_candidates:
60+
continue
61+
seen_candidates.add(candidate_key)
62+
63+
self.logger.debug(f"Checking pyproject candidate: '{candidate}'")
64+
if candidate.exists() and get_class_map_from_pyproject(candidate):
65+
self.logger.debug(f"Found pyproject.toml with DSC resources at '{candidate}'")
66+
return candidate.resolve()
67+
68+
self.logger.warning(f"No pyproject.toml with [tool.dsc.resources] found for resource_path='{resource_path}'")
69+
return None
70+
71+
@contextmanager
72+
def profile_block(self, label):
73+
"""Context manager for optional profiling of code blocks."""
74+
if self.ENABLE_PROFILING:
75+
start_time = time.perf_counter()
76+
profiler = None
77+
try:
78+
profiler = cProfile.Profile()
79+
profiler.enable()
80+
except Exception:
81+
# Another profiler may already be active; fall back to timing only
82+
profiler = None
83+
try:
84+
yield
85+
finally:
86+
end_time = time.perf_counter()
87+
if profiler:
88+
try:
89+
profiler.disable()
90+
s = io.StringIO()
91+
ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
92+
ps.print_stats(10)
93+
self.logger.info(f"[PROFILE] {label} took {end_time - start_time:.4f}s")
94+
self.logger.debug(f"[PROFILE DETAILS] {label}:\n{s.getvalue()}")
95+
except Exception:
96+
# If profiling teardown fails, still log duration
97+
self.logger.info(f"[PROFILE] {label} took {end_time - start_time:.4f}s")
98+
else:
99+
self.logger.info(f"[PROFILE] {label} took {end_time - start_time:.4f}s")
100+
else:
101+
yield
102+
103+
def log(self, level: str, message: str, target: str = None, **kwargs) -> None:
104+
"""Structured logging method for adapter code."""
105+
lvl = level.lower()
106+
method = kwargs.get("method", "?")
107+
core_msg = f"{target} - {method} - {message}" if target else f"{method} - {message}"
108+
109+
if lvl == "trace": # and hasattr(self.logger, "trace"):
110+
self.logger.debug(f"[TRACE] {core_msg}")
111+
return
112+
113+
log_fn = getattr(self.logger, lvl, self.logger.info)
114+
log_fn(core_msg)
115+
116+
117+
def _load_manifest(self, resource_path: str = "") -> Dict[str, str]:
118+
"""
119+
Resolve the nearest pyproject.toml for the supplied resource path and
120+
return the [tool.dsc.resources] class mapping.
121+
"""
122+
if not resource_path:
123+
self.logger.debug("_load_manifest called with empty resource_path; returning empty class map")
124+
return {}
125+
126+
self.logger.debug(f"Loading manifest class map for resource_path='{resource_path}'")
127+
128+
pyproject_path = self._resolve_pyproject_path(resource_path)
129+
if not pyproject_path:
130+
self.logger.warning(f"No pyproject.toml found for '{resource_path}'; class map will be empty")
131+
class_map = get_class_map_from_pyproject(pyproject_path) if pyproject_path else {}
132+
self.logger.debug(f"Class map loaded: {class_map}")
133+
return class_map
134+
135+
136+
def _resolve_resource_class(self, resource_type: str, resource_path: str = "") -> type:
137+
"""Resolve the resource class for a given resource type and path using the manifest mapping."""
138+
self.logger.debug(f"Resolving class for resource_type='{resource_type}', resource_path='{resource_path}'")
139+
if not resource_type.strip():
140+
raise ValueError("resource-type must be provided")
141+
142+
class_map = self._load_manifest(resource_path)
143+
class_name = class_map.get(resource_type)
144+
if not class_name:
145+
lowered = {k.lower(): v for k, v in class_map.items()}
146+
class_name = lowered.get(resource_type.lower())
147+
if class_name:
148+
self.logger.debug(f"Exact lookup missed; using case-insensitive match for '{resource_type}'")
149+
150+
if not class_name:
151+
supported = sorted(set(class_map.keys()))
152+
self.logger.error(f"No class mapping found for '{resource_type}'. Supported: {supported}")
153+
raise ValueError(f"Unsupported resource-type '{resource_type}'. Supported: {supported}")
154+
155+
self.logger.debug(f"Class '{class_name}' found for '{resource_type}'; importing class")
156+
return import_class_from_file(resource_path, resource_type, class_name)
157+
158+
159+
def _instantiate_resource(self, cls: type, json_input: str, operation: Optional[str]) -> Any:
160+
"""Instantiate a resource class from JSON input."""
161+
# Resource classes may expect operation-aware validation
162+
if hasattr(cls, "from_json"):
163+
return cls.from_json(json_input, operation=operation)
164+
# Fallback: direct init from dict if needed
165+
data = json.loads(json_input or "{}")
166+
return cls(**data)
167+
168+
# -----------------
169+
# Operation routing
170+
# -----------------
171+
172+
def run_operation(self, operation: str, json_input: str, resource_type: str, resource_path: str = "") -> Tuple[int, Dict[str, Any]]:
173+
"""
174+
Execute a single adapter operation for one resource instance.
175+
176+
Returns a tuple of (exit_code, result_dict). Most operations return a
177+
JSON-serializable result dictionary for the caller to print. The set
178+
and test operations are exceptions: they write their state and diff
179+
payloads directly to stdout and return a marker dictionary indicating
180+
that stdout has already been emitted.
181+
"""
182+
op = (operation or "").strip().lower()
183+
self.logger.info(f"Operation '{op}' requested for resource_type='{resource_type}'")
184+
185+
with operation_context(op, resource_type):
186+
if op == "list":
187+
self.logger.debug("List operation: returning empty resource list")
188+
return 0, {"resources": []}
189+
if op == "validate":
190+
self.logger.debug(f"Validate operation: returning valid=True for '{resource_type}'")
191+
return 0, {"valid": True}
192+
193+
# Resolve resource class
194+
try:
195+
self.logger.debug(f"Resolving resource_type='{resource_type}', resource_path='{resource_path}'")
196+
resolved_type = (resource_type or "").strip() or os.getenv("DSC_RESOURCE_TYPE", "").strip()
197+
if resolved_type != resource_type:
198+
self.logger.debug(f"resource_type resolved from env to '{resolved_type}'")
199+
cls = self._resolve_resource_class(resolved_type, resource_path)
200+
self.logger.debug(f"Resolved class '{cls.__name__}' for '{resolved_type}'")
201+
except Exception as e:
202+
self.log("error", str(e), "Adapter", operation=op)
203+
return 2, {"error": str(e)}
204+
205+
try:
206+
if op == "get":
207+
self.logger.info(f"Executing GET on '{resolved_type}'")
208+
with self.profile_block("DSC Get Operation"):
209+
instance = self._instantiate_resource(cls, json_input, operation="get")
210+
data = instance.get()
211+
self.logger.debug(f"GET returned: {data}")
212+
213+
try:
214+
resource_name = json.loads(json_input or "{}").get("name", "") or resource_type
215+
except Exception:
216+
resource_name = resource_type or ""
217+
218+
full = {
219+
"metadata": {"Microsoft.DSC": {"operation": "Get"}},
220+
"name": resource_name,
221+
"type": "Microsoft.DSC.Adapters/Python",
222+
"result": [
223+
{
224+
"name": resource_name,
225+
"type": resource_type,
226+
"result": {
227+
"actualState": data
228+
}
229+
}
230+
]
231+
}
232+
return (0, full)
233+
234+
elif op == "set":
235+
self.logger.info(f"Executing SET on '{resolved_type}'")
236+
with self.profile_block("DSC Set Operation"):
237+
instance = self._instantiate_resource(cls, json_input, operation="set")
238+
state, diffs = instance.set()
239+
self.logger.debug(f"SET completed. diffs={diffs}")
240+
241+
sys.stdout.write(json.dumps(state, ensure_ascii=False) + "\n")
242+
sys.stdout.write(json.dumps(diffs, ensure_ascii=False) + "\n")
243+
244+
# Signal to caller that we've already printed the required stdout
245+
return (0, {"_stdout_emitted": True})
246+
247+
elif op == "test":
248+
self.logger.info(f"Executing TEST on '{resolved_type}'")
249+
with self.profile_block("DSC Test Operation"):
250+
instance = self._instantiate_resource(cls, json_input, operation="test")
251+
actual_state, diffs = instance.test()
252+
self.logger.debug(f"TEST completed. in_desired_state={len(diffs) == 0}, diffs={diffs}")
253+
254+
sys.stdout.write(json.dumps(actual_state if isinstance(actual_state, dict) else {}, ensure_ascii=False) + "\n")
255+
sys.stdout.write(json.dumps(diffs if isinstance(diffs, list) else [], ensure_ascii=False) + "\n")
256+
257+
# Signal stdout already emitted so main() doesn't print a wrapper
258+
return (0, {"_stdout_emitted": True})
259+
260+
elif op == "export":
261+
self.logger.info(f"Executing EXPORT on '{resolved_type}'")
262+
# If your resource supports filtered export with provided input, pass instance; else pass None for full export
263+
with self.profile_block("DSC Export Operation"):
264+
# Determine if filters are provided; otherwise export all (None)
265+
as_obj = parse_json(json_input)
266+
has_filters = any(k in as_obj for k in ("name", "version", "source", "dependencies"))
267+
self.logger.debug(f"Export has_filters={has_filters}")
268+
instance = self._instantiate_resource(cls, json_input, operation="export") if has_filters else None
269+
data = cls.export(instance)
270+
self.logger.debug("Export completed")
271+
# If export returns None (prints only), still return an empty dict for adapter contract
272+
return (0, data if isinstance(data, dict) else {})
273+
274+
else:
275+
msg = f"Unsupported operation '{operation}'. Expected one of: list, get, set, test, export, validate"
276+
self.log("error", msg, "Adapter")
277+
return 2, {"error": msg}
278+
279+
except SystemExit as se:
280+
# Resource may call sys.exit(1) on error paths (e.g., export). Normalize.
281+
code = int(getattr(se, "code", 1) or 1)
282+
self.logger.error(f"Operation '{op}' on '{resolved_type}' terminated with sys.exit({code})")
283+
return code, {"error": f"Resource terminated with exit {code}"}
284+
except Exception as err:
285+
self.logger.error(f"Operation '{op}' on '{resolved_type}' failed: {err}", exc_info=True)
286+
return 1, {"error": str(err)}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import sys
2+
import json
3+
import argparse
4+
from typing import Optional
5+
from adapter import ResourceAdapter
6+
from dsc_logging import setup_dsc_logging
7+
8+
# --------------------
9+
# CLI / entrypoint API
10+
# --------------------
11+
def _build_parser() -> argparse.ArgumentParser:
12+
"""Construct the argument parser for the DSC adapter CLI."""
13+
parser = argparse.ArgumentParser(
14+
prog="dsctest",
15+
description="DSC v3 Python adapter CLI compatible with manifest."
16+
)
17+
sub = parser.add_subparsers(dest="command", required=True)
18+
19+
adapter = sub.add_parser("adapter", help="Adapter operations")
20+
adapter.add_argument("--operation", required=True, choices=["list", "get", "set", "test", "export", "validate"],
21+
help="Adapter operation to execute.")
22+
adapter.add_argument("--input", default="{}", help="JSON string with resource configuration (single input).")
23+
adapter.add_argument("--resource", dest="ResourceType", default="", help="Resource type selector (e.g., Microsoft.Linux.Apt/Package).")
24+
adapter.add_argument("--resource-path", dest="ResourcePath", default="", help="Optional resource module file path.")
25+
return parser
26+
27+
28+
def main(argv: Optional[list] = None) -> int:
29+
"""Main entry point for the DSC adapter CLI."""
30+
parser = _build_parser()
31+
args = parser.parse_args(argv)
32+
33+
if args.command != "adapter":
34+
print(json.dumps({"error": "Unsupported command"}))
35+
return 2
36+
37+
adapter = ResourceAdapter()
38+
39+
40+
# 1. Start with --input as the authoritative source
41+
input_str = args.input
42+
43+
# 2. If stdin has data, it overrides --input (DSC convention)
44+
stdin_data = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
45+
if stdin_data:
46+
input_str = stdin_data
47+
48+
# 3. Call operation handler
49+
exit_code, result = adapter.run_operation(
50+
args.operation,
51+
input_str,
52+
args.ResourceType,
53+
getattr(args, "ResourcePath", "")
54+
)
55+
56+
# If set branch (or similar) already wrote to stdout, skip emitting a wrapper
57+
if isinstance(result, dict) and result.get("_stdout_emitted"):
58+
return exit_code
59+
60+
# 4. Capture EXACT output passed to DSC
61+
out_json = json.dumps(result, ensure_ascii=False)
62+
63+
print(out_json)
64+
return exit_code

0 commit comments

Comments
 (0)