graphsense-python 2.11.0__py3-none-any.whl → 2.13.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.
- graphsense/__init__.py +1 -1
- graphsense/api/addresses_api.py +309 -6
- graphsense/api_client.py +1 -1
- graphsense/cli/__init__.py +1 -0
- graphsense/cli/__main__.py +4 -0
- graphsense/cli/bulk_cmd.py +95 -0
- graphsense/cli/context.py +64 -0
- graphsense/cli/convenience.py +444 -0
- graphsense/cli/errors.py +74 -0
- graphsense/cli/gs.py +139 -0
- graphsense/cli/main.py +218 -0
- graphsense/cli/raw.py +259 -0
- graphsense/configuration.py +2 -2
- graphsense/ext/__init__.py +6 -0
- graphsense/ext/bulk.py +51 -0
- graphsense/ext/client.py +520 -0
- graphsense/ext/deprecation.py +81 -0
- graphsense/ext/io.py +229 -0
- graphsense/ext/output.py +245 -0
- graphsense/ext/selectors.py +28 -0
- graphsense/gs_files/__init__.py +69 -0
- graphsense/gs_files/encoder.py +336 -0
- graphsense/gs_files/parser.py +440 -0
- graphsense/gs_files/summary.py +34 -0
- graphsense/gs_files/writer.py +73 -0
- graphsense/models/address.py +3 -1
- graphsense/models/cluster.py +112 -163
- graphsense/models/entity.py +1 -1
- graphsense/models/neighbor_cluster.py +8 -2
- graphsense/models/neighbor_entity.py +9 -3
- {graphsense_python-2.11.0.dist-info → graphsense_python-2.13.0.dist-info}/METADATA +31 -16
- {graphsense_python-2.11.0.dist-info → graphsense_python-2.13.0.dist-info}/RECORD +35 -13
- graphsense_python-2.13.0.dist-info/entry_points.txt +2 -0
- {graphsense_python-2.11.0.dist-info → graphsense_python-2.13.0.dist-info}/WHEEL +0 -0
- {graphsense_python-2.11.0.dist-info → graphsense_python-2.13.0.dist-info}/top_level.txt +0 -0
graphsense/__init__.py
CHANGED
graphsense/api/addresses_api.py
CHANGED
|
@@ -20,6 +20,7 @@ from typing_extensions import Annotated
|
|
|
20
20
|
from graphsense.models.address import Address
|
|
21
21
|
from graphsense.models.address_tags import AddressTags
|
|
22
22
|
from graphsense.models.address_txs import AddressTxs
|
|
23
|
+
from graphsense.models.cluster import Cluster
|
|
23
24
|
from graphsense.models.entity import Entity
|
|
24
25
|
from graphsense.models.links import Links
|
|
25
26
|
from graphsense.models.neighbor_addresses import NeighborAddresses
|
|
@@ -433,6 +434,305 @@ class AddressesApi:
|
|
|
433
434
|
|
|
434
435
|
|
|
435
436
|
|
|
437
|
+
@validate_call_compat
|
|
438
|
+
def get_address_cluster(
|
|
439
|
+
self,
|
|
440
|
+
currency: Annotated[StrictStr, Field(description="The cryptocurrency code (e.g., btc)")],
|
|
441
|
+
address: Annotated[StrictStr, Field(description="The cryptocurrency address")],
|
|
442
|
+
include_actors: Annotated[Optional[StrictBool], Field(description="Whether to include actor information")] = None,
|
|
443
|
+
_request_timeout: Union[
|
|
444
|
+
None,
|
|
445
|
+
Annotated[StrictFloat, Field(gt=0)],
|
|
446
|
+
Tuple[
|
|
447
|
+
Annotated[StrictFloat, Field(gt=0)],
|
|
448
|
+
Annotated[StrictFloat, Field(gt=0)]
|
|
449
|
+
]
|
|
450
|
+
] = None,
|
|
451
|
+
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
|
452
|
+
_content_type: Optional[StrictStr] = None,
|
|
453
|
+
_headers: Optional[Dict[StrictStr, Any]] = None,
|
|
454
|
+
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
|
455
|
+
) -> Cluster:
|
|
456
|
+
"""Get the cluster for an address
|
|
457
|
+
|
|
458
|
+
Returns the address cluster that contains the given address.
|
|
459
|
+
|
|
460
|
+
:param currency: The cryptocurrency code (e.g., btc) (required)
|
|
461
|
+
:type currency: str
|
|
462
|
+
:param address: The cryptocurrency address (required)
|
|
463
|
+
:type address: str
|
|
464
|
+
:param include_actors: Whether to include actor information
|
|
465
|
+
:type include_actors: bool
|
|
466
|
+
:param _request_timeout: timeout setting for this request. If one
|
|
467
|
+
number provided, it will be total request
|
|
468
|
+
timeout. It can also be a pair (tuple) of
|
|
469
|
+
(connection, read) timeouts.
|
|
470
|
+
:type _request_timeout: int, tuple(int, int), optional
|
|
471
|
+
:param _request_auth: set to override the auth_settings for an a single
|
|
472
|
+
request; this effectively ignores the
|
|
473
|
+
authentication in the spec for a single request.
|
|
474
|
+
:type _request_auth: dict, optional
|
|
475
|
+
:param _content_type: force content-type for the request.
|
|
476
|
+
:type _content_type: str, Optional
|
|
477
|
+
:param _headers: set to override the headers for a single
|
|
478
|
+
request; this effectively ignores the headers
|
|
479
|
+
in the spec for a single request.
|
|
480
|
+
:type _headers: dict, optional
|
|
481
|
+
:param _host_index: set to override the host_index for a single
|
|
482
|
+
request; this effectively ignores the host_index
|
|
483
|
+
in the spec for a single request.
|
|
484
|
+
:type _host_index: int, optional
|
|
485
|
+
:return: Returns the result object.
|
|
486
|
+
""" # noqa: E501
|
|
487
|
+
|
|
488
|
+
_param = self._get_address_cluster_serialize(
|
|
489
|
+
currency=currency,
|
|
490
|
+
address=address,
|
|
491
|
+
include_actors=include_actors,
|
|
492
|
+
_request_auth=_request_auth,
|
|
493
|
+
_content_type=_content_type,
|
|
494
|
+
_headers=_headers,
|
|
495
|
+
_host_index=_host_index
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
_response_types_map: Dict[str, Optional[str]] = {
|
|
499
|
+
'200': "Cluster",
|
|
500
|
+
'404': None,
|
|
501
|
+
'422': "HTTPValidationError",
|
|
502
|
+
}
|
|
503
|
+
response_data = self.api_client.call_api(
|
|
504
|
+
*_param,
|
|
505
|
+
_request_timeout=_request_timeout
|
|
506
|
+
)
|
|
507
|
+
response_data.read()
|
|
508
|
+
return self.api_client.response_deserialize(
|
|
509
|
+
response_data=response_data,
|
|
510
|
+
response_types_map=_response_types_map,
|
|
511
|
+
).data
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@validate_call_compat
|
|
515
|
+
def get_address_cluster_with_http_info(
|
|
516
|
+
self,
|
|
517
|
+
currency: Annotated[StrictStr, Field(description="The cryptocurrency code (e.g., btc)")],
|
|
518
|
+
address: Annotated[StrictStr, Field(description="The cryptocurrency address")],
|
|
519
|
+
include_actors: Annotated[Optional[StrictBool], Field(description="Whether to include actor information")] = None,
|
|
520
|
+
_request_timeout: Union[
|
|
521
|
+
None,
|
|
522
|
+
Annotated[StrictFloat, Field(gt=0)],
|
|
523
|
+
Tuple[
|
|
524
|
+
Annotated[StrictFloat, Field(gt=0)],
|
|
525
|
+
Annotated[StrictFloat, Field(gt=0)]
|
|
526
|
+
]
|
|
527
|
+
] = None,
|
|
528
|
+
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
|
529
|
+
_content_type: Optional[StrictStr] = None,
|
|
530
|
+
_headers: Optional[Dict[StrictStr, Any]] = None,
|
|
531
|
+
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
|
532
|
+
) -> ApiResponse[Cluster]:
|
|
533
|
+
"""Get the cluster for an address
|
|
534
|
+
|
|
535
|
+
Returns the address cluster that contains the given address.
|
|
536
|
+
|
|
537
|
+
:param currency: The cryptocurrency code (e.g., btc) (required)
|
|
538
|
+
:type currency: str
|
|
539
|
+
:param address: The cryptocurrency address (required)
|
|
540
|
+
:type address: str
|
|
541
|
+
:param include_actors: Whether to include actor information
|
|
542
|
+
:type include_actors: bool
|
|
543
|
+
:param _request_timeout: timeout setting for this request. If one
|
|
544
|
+
number provided, it will be total request
|
|
545
|
+
timeout. It can also be a pair (tuple) of
|
|
546
|
+
(connection, read) timeouts.
|
|
547
|
+
:type _request_timeout: int, tuple(int, int), optional
|
|
548
|
+
:param _request_auth: set to override the auth_settings for an a single
|
|
549
|
+
request; this effectively ignores the
|
|
550
|
+
authentication in the spec for a single request.
|
|
551
|
+
:type _request_auth: dict, optional
|
|
552
|
+
:param _content_type: force content-type for the request.
|
|
553
|
+
:type _content_type: str, Optional
|
|
554
|
+
:param _headers: set to override the headers for a single
|
|
555
|
+
request; this effectively ignores the headers
|
|
556
|
+
in the spec for a single request.
|
|
557
|
+
:type _headers: dict, optional
|
|
558
|
+
:param _host_index: set to override the host_index for a single
|
|
559
|
+
request; this effectively ignores the host_index
|
|
560
|
+
in the spec for a single request.
|
|
561
|
+
:type _host_index: int, optional
|
|
562
|
+
:return: Returns the result object.
|
|
563
|
+
""" # noqa: E501
|
|
564
|
+
|
|
565
|
+
_param = self._get_address_cluster_serialize(
|
|
566
|
+
currency=currency,
|
|
567
|
+
address=address,
|
|
568
|
+
include_actors=include_actors,
|
|
569
|
+
_request_auth=_request_auth,
|
|
570
|
+
_content_type=_content_type,
|
|
571
|
+
_headers=_headers,
|
|
572
|
+
_host_index=_host_index
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
_response_types_map: Dict[str, Optional[str]] = {
|
|
576
|
+
'200': "Cluster",
|
|
577
|
+
'404': None,
|
|
578
|
+
'422': "HTTPValidationError",
|
|
579
|
+
}
|
|
580
|
+
response_data = self.api_client.call_api(
|
|
581
|
+
*_param,
|
|
582
|
+
_request_timeout=_request_timeout
|
|
583
|
+
)
|
|
584
|
+
response_data.read()
|
|
585
|
+
return self.api_client.response_deserialize(
|
|
586
|
+
response_data=response_data,
|
|
587
|
+
response_types_map=_response_types_map,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@validate_call_compat
|
|
592
|
+
def get_address_cluster_without_preload_content(
|
|
593
|
+
self,
|
|
594
|
+
currency: Annotated[StrictStr, Field(description="The cryptocurrency code (e.g., btc)")],
|
|
595
|
+
address: Annotated[StrictStr, Field(description="The cryptocurrency address")],
|
|
596
|
+
include_actors: Annotated[Optional[StrictBool], Field(description="Whether to include actor information")] = None,
|
|
597
|
+
_request_timeout: Union[
|
|
598
|
+
None,
|
|
599
|
+
Annotated[StrictFloat, Field(gt=0)],
|
|
600
|
+
Tuple[
|
|
601
|
+
Annotated[StrictFloat, Field(gt=0)],
|
|
602
|
+
Annotated[StrictFloat, Field(gt=0)]
|
|
603
|
+
]
|
|
604
|
+
] = None,
|
|
605
|
+
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
|
606
|
+
_content_type: Optional[StrictStr] = None,
|
|
607
|
+
_headers: Optional[Dict[StrictStr, Any]] = None,
|
|
608
|
+
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
|
609
|
+
) -> RESTResponseType:
|
|
610
|
+
"""Get the cluster for an address
|
|
611
|
+
|
|
612
|
+
Returns the address cluster that contains the given address.
|
|
613
|
+
|
|
614
|
+
:param currency: The cryptocurrency code (e.g., btc) (required)
|
|
615
|
+
:type currency: str
|
|
616
|
+
:param address: The cryptocurrency address (required)
|
|
617
|
+
:type address: str
|
|
618
|
+
:param include_actors: Whether to include actor information
|
|
619
|
+
:type include_actors: bool
|
|
620
|
+
:param _request_timeout: timeout setting for this request. If one
|
|
621
|
+
number provided, it will be total request
|
|
622
|
+
timeout. It can also be a pair (tuple) of
|
|
623
|
+
(connection, read) timeouts.
|
|
624
|
+
:type _request_timeout: int, tuple(int, int), optional
|
|
625
|
+
:param _request_auth: set to override the auth_settings for an a single
|
|
626
|
+
request; this effectively ignores the
|
|
627
|
+
authentication in the spec for a single request.
|
|
628
|
+
:type _request_auth: dict, optional
|
|
629
|
+
:param _content_type: force content-type for the request.
|
|
630
|
+
:type _content_type: str, Optional
|
|
631
|
+
:param _headers: set to override the headers for a single
|
|
632
|
+
request; this effectively ignores the headers
|
|
633
|
+
in the spec for a single request.
|
|
634
|
+
:type _headers: dict, optional
|
|
635
|
+
:param _host_index: set to override the host_index for a single
|
|
636
|
+
request; this effectively ignores the host_index
|
|
637
|
+
in the spec for a single request.
|
|
638
|
+
:type _host_index: int, optional
|
|
639
|
+
:return: Returns the result object.
|
|
640
|
+
""" # noqa: E501
|
|
641
|
+
|
|
642
|
+
_param = self._get_address_cluster_serialize(
|
|
643
|
+
currency=currency,
|
|
644
|
+
address=address,
|
|
645
|
+
include_actors=include_actors,
|
|
646
|
+
_request_auth=_request_auth,
|
|
647
|
+
_content_type=_content_type,
|
|
648
|
+
_headers=_headers,
|
|
649
|
+
_host_index=_host_index
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
_response_types_map: Dict[str, Optional[str]] = {
|
|
653
|
+
'200': "Cluster",
|
|
654
|
+
'404': None,
|
|
655
|
+
'422': "HTTPValidationError",
|
|
656
|
+
}
|
|
657
|
+
response_data = self.api_client.call_api(
|
|
658
|
+
*_param,
|
|
659
|
+
_request_timeout=_request_timeout
|
|
660
|
+
)
|
|
661
|
+
return response_data.response
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _get_address_cluster_serialize(
|
|
665
|
+
self,
|
|
666
|
+
currency,
|
|
667
|
+
address,
|
|
668
|
+
include_actors,
|
|
669
|
+
_request_auth,
|
|
670
|
+
_content_type,
|
|
671
|
+
_headers,
|
|
672
|
+
_host_index,
|
|
673
|
+
) -> RequestSerialized:
|
|
674
|
+
|
|
675
|
+
_host = None
|
|
676
|
+
|
|
677
|
+
_collection_formats: Dict[str, str] = {
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
_path_params: Dict[str, str] = {}
|
|
681
|
+
_query_params: List[Tuple[str, str]] = []
|
|
682
|
+
_header_params: Dict[str, Optional[str]] = _headers or {}
|
|
683
|
+
_form_params: List[Tuple[str, str]] = []
|
|
684
|
+
_files: Dict[
|
|
685
|
+
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
|
|
686
|
+
] = {}
|
|
687
|
+
_body_params: Optional[bytes] = None
|
|
688
|
+
|
|
689
|
+
# process the path parameters
|
|
690
|
+
if currency is not None:
|
|
691
|
+
_path_params['currency'] = currency
|
|
692
|
+
if address is not None:
|
|
693
|
+
_path_params['address'] = address
|
|
694
|
+
# process the query parameters
|
|
695
|
+
if include_actors is not None:
|
|
696
|
+
|
|
697
|
+
_query_params.append(('include_actors', include_actors))
|
|
698
|
+
|
|
699
|
+
# process the header parameters
|
|
700
|
+
# process the form parameters
|
|
701
|
+
# process the body parameter
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# set the HTTP header `Accept`
|
|
705
|
+
if 'Accept' not in _header_params:
|
|
706
|
+
_header_params['Accept'] = self.api_client.select_header_accept(
|
|
707
|
+
[
|
|
708
|
+
'application/json'
|
|
709
|
+
]
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# authentication setting
|
|
714
|
+
_auth_settings: List[str] = [
|
|
715
|
+
'api_key'
|
|
716
|
+
]
|
|
717
|
+
|
|
718
|
+
return self.api_client.param_serialize(
|
|
719
|
+
method='GET',
|
|
720
|
+
resource_path='/{currency}/addresses/{address}/cluster',
|
|
721
|
+
path_params=_path_params,
|
|
722
|
+
query_params=_query_params,
|
|
723
|
+
header_params=_header_params,
|
|
724
|
+
body=_body_params,
|
|
725
|
+
post_params=_form_params,
|
|
726
|
+
files=_files,
|
|
727
|
+
auth_settings=_auth_settings,
|
|
728
|
+
collection_formats=_collection_formats,
|
|
729
|
+
_host=_host,
|
|
730
|
+
_request_auth=_request_auth
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
|
|
436
736
|
@validate_call_compat
|
|
437
737
|
def get_address_entity(
|
|
438
738
|
self,
|
|
@@ -452,9 +752,9 @@ class AddressesApi:
|
|
|
452
752
|
_headers: Optional[Dict[StrictStr, Any]] = None,
|
|
453
753
|
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
|
454
754
|
) -> Entity:
|
|
455
|
-
"""Get the entity for an address
|
|
755
|
+
"""(Deprecated) Get the entity for an address
|
|
456
756
|
|
|
457
|
-
Returns the
|
|
757
|
+
Deprecated alias for `GET /{currency}/addresses/{address}/cluster`. Returns the address cluster that contains the given address.
|
|
458
758
|
|
|
459
759
|
:param currency: The cryptocurrency code (e.g., btc) (required)
|
|
460
760
|
:type currency: str
|
|
@@ -483,6 +783,7 @@ class AddressesApi:
|
|
|
483
783
|
:type _host_index: int, optional
|
|
484
784
|
:return: Returns the result object.
|
|
485
785
|
""" # noqa: E501
|
|
786
|
+
warnings.warn("GET /{currency}/addresses/{address}/entity is deprecated.", DeprecationWarning)
|
|
486
787
|
|
|
487
788
|
_param = self._get_address_entity_serialize(
|
|
488
789
|
currency=currency,
|
|
@@ -529,9 +830,9 @@ class AddressesApi:
|
|
|
529
830
|
_headers: Optional[Dict[StrictStr, Any]] = None,
|
|
530
831
|
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
|
531
832
|
) -> ApiResponse[Entity]:
|
|
532
|
-
"""Get the entity for an address
|
|
833
|
+
"""(Deprecated) Get the entity for an address
|
|
533
834
|
|
|
534
|
-
Returns the
|
|
835
|
+
Deprecated alias for `GET /{currency}/addresses/{address}/cluster`. Returns the address cluster that contains the given address.
|
|
535
836
|
|
|
536
837
|
:param currency: The cryptocurrency code (e.g., btc) (required)
|
|
537
838
|
:type currency: str
|
|
@@ -560,6 +861,7 @@ class AddressesApi:
|
|
|
560
861
|
:type _host_index: int, optional
|
|
561
862
|
:return: Returns the result object.
|
|
562
863
|
""" # noqa: E501
|
|
864
|
+
warnings.warn("GET /{currency}/addresses/{address}/entity is deprecated.", DeprecationWarning)
|
|
563
865
|
|
|
564
866
|
_param = self._get_address_entity_serialize(
|
|
565
867
|
currency=currency,
|
|
@@ -606,9 +908,9 @@ class AddressesApi:
|
|
|
606
908
|
_headers: Optional[Dict[StrictStr, Any]] = None,
|
|
607
909
|
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
|
608
910
|
) -> RESTResponseType:
|
|
609
|
-
"""Get the entity for an address
|
|
911
|
+
"""(Deprecated) Get the entity for an address
|
|
610
912
|
|
|
611
|
-
Returns the
|
|
913
|
+
Deprecated alias for `GET /{currency}/addresses/{address}/cluster`. Returns the address cluster that contains the given address.
|
|
612
914
|
|
|
613
915
|
:param currency: The cryptocurrency code (e.g., btc) (required)
|
|
614
916
|
:type currency: str
|
|
@@ -637,6 +939,7 @@ class AddressesApi:
|
|
|
637
939
|
:type _host_index: int, optional
|
|
638
940
|
:return: Returns the result object.
|
|
639
941
|
""" # noqa: E501
|
|
942
|
+
warnings.warn("GET /{currency}/addresses/{address}/entity is deprecated.", DeprecationWarning)
|
|
640
943
|
|
|
641
944
|
_param = self._get_address_entity_serialize(
|
|
642
945
|
currency=currency,
|
graphsense/api_client.py
CHANGED
|
@@ -95,7 +95,7 @@ class ApiClient:
|
|
|
95
95
|
self.default_headers[header_name] = header_value
|
|
96
96
|
self.cookie = cookie
|
|
97
97
|
# Set default User-Agent.
|
|
98
|
-
self.user_agent = 'OpenAPI-Generator/2.
|
|
98
|
+
self.user_agent = 'OpenAPI-Generator/2.13.0/python'
|
|
99
99
|
self.client_side_validation = configuration.client_side_validation
|
|
100
100
|
|
|
101
101
|
def __enter__(self):
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""graphsense CLI — requires the [cli] extra (`pip install graphsense-python[cli]`)."""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""`graphsense bulk <operation>` — direct access to /bulk.json / /bulk.csv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import rich_click as click
|
|
8
|
+
|
|
9
|
+
from graphsense.cli.context import CliContext
|
|
10
|
+
from graphsense.ext import io as io_mod
|
|
11
|
+
from graphsense.ext import output as out_mod
|
|
12
|
+
|
|
13
|
+
pass_ctx = click.make_pass_decorator(CliContext)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command(name="bulk")
|
|
17
|
+
@click.argument("operation")
|
|
18
|
+
@click.argument("currency")
|
|
19
|
+
@click.argument("keys", nargs=-1)
|
|
20
|
+
@click.option(
|
|
21
|
+
"--key-field",
|
|
22
|
+
default="address",
|
|
23
|
+
help="Field name the bulk operation expects (address, tx_hash, cluster, ...).",
|
|
24
|
+
)
|
|
25
|
+
@click.option("--num-pages", type=int, default=1)
|
|
26
|
+
@pass_ctx
|
|
27
|
+
def bulk_command(
|
|
28
|
+
ctx: CliContext,
|
|
29
|
+
operation: str,
|
|
30
|
+
currency: str,
|
|
31
|
+
keys: tuple[str, ...],
|
|
32
|
+
key_field: str,
|
|
33
|
+
num_pages: int,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Call /bulk.json/<operation> or /bulk.csv/<operation> for a list of keys.
|
|
36
|
+
|
|
37
|
+
Keys come from positionals, --input, or stdin (JSON/CSV/lines, with
|
|
38
|
+
--address-jq / --address-col).
|
|
39
|
+
Output format follows --format (default json).
|
|
40
|
+
"""
|
|
41
|
+
ids = _collect_keys(ctx, keys)
|
|
42
|
+
if not ids:
|
|
43
|
+
raise click.UsageError("no keys provided for bulk")
|
|
44
|
+
|
|
45
|
+
fmt = (ctx.format or "json").lower()
|
|
46
|
+
gs = ctx.gs()
|
|
47
|
+
result = gs.bulk(
|
|
48
|
+
operation,
|
|
49
|
+
ids,
|
|
50
|
+
currency=currency,
|
|
51
|
+
format="csv" if fmt == "csv" else "json",
|
|
52
|
+
num_pages=num_pages,
|
|
53
|
+
key_field=key_field,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if fmt == "csv":
|
|
57
|
+
_write_raw(ctx, result)
|
|
58
|
+
else:
|
|
59
|
+
out_mod.write(
|
|
60
|
+
result,
|
|
61
|
+
output=ctx.output,
|
|
62
|
+
directory=ctx.directory,
|
|
63
|
+
format=ctx.format,
|
|
64
|
+
color=ctx.color,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _collect_keys(ctx: CliContext, positional: tuple[str, ...]) -> list[str]:
|
|
69
|
+
if positional:
|
|
70
|
+
return list(positional)
|
|
71
|
+
text = ctx.read_input_text()
|
|
72
|
+
if text is None:
|
|
73
|
+
return []
|
|
74
|
+
return io_mod.parse_input(
|
|
75
|
+
text,
|
|
76
|
+
input_format=ctx.input_format,
|
|
77
|
+
jq=ctx.address_jq,
|
|
78
|
+
col=ctx.address_col,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_raw(ctx: CliContext, payload) -> None:
|
|
83
|
+
"""For CSV bulk output, the server already returns flat rows; pass through."""
|
|
84
|
+
text: Optional[str]
|
|
85
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
86
|
+
text = payload.decode("utf-8")
|
|
87
|
+
elif isinstance(payload, str):
|
|
88
|
+
text = payload
|
|
89
|
+
else:
|
|
90
|
+
# Some generated clients deserialize the CSV as {"value": "...raw..."}
|
|
91
|
+
text = str(payload)
|
|
92
|
+
with out_mod.open_out(ctx.output) as fh:
|
|
93
|
+
fh.write(text)
|
|
94
|
+
if not text.endswith("\n"):
|
|
95
|
+
fh.write("\n")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared Click context for all `gs` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import rich_click as click
|
|
11
|
+
|
|
12
|
+
from graphsense.ext.client import GraphSense
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CliContext:
|
|
17
|
+
api_key: Optional[str] = None
|
|
18
|
+
host: Optional[str] = None
|
|
19
|
+
format: Optional[str] = None
|
|
20
|
+
output: Optional[str] = None
|
|
21
|
+
directory: Optional[str] = None
|
|
22
|
+
input: Optional[str] = None
|
|
23
|
+
input_format: str = "auto"
|
|
24
|
+
# Primary id selectors. "address" in the flag name is conventional —
|
|
25
|
+
# for lookup-tx / lookup-cluster these extract tx hashes / cluster ids.
|
|
26
|
+
address_jq: Optional[str] = None
|
|
27
|
+
address_col: Optional[str] = None
|
|
28
|
+
# Per-row network selectors — see docs/cli/inputs.md.
|
|
29
|
+
# "network" is the preferred term in new code; the generated API still
|
|
30
|
+
# uses "currency" for backward compatibility (see CLAUDE.md).
|
|
31
|
+
network_jq: Optional[str] = None
|
|
32
|
+
network_col: Optional[str] = None
|
|
33
|
+
bulk: Optional[bool] = None
|
|
34
|
+
bulk_threshold: int = 10
|
|
35
|
+
color: str = "auto"
|
|
36
|
+
quiet: bool = False
|
|
37
|
+
verbose: int = 0
|
|
38
|
+
_gs: Optional[GraphSense] = field(default=None, repr=False)
|
|
39
|
+
|
|
40
|
+
def gs(self) -> GraphSense:
|
|
41
|
+
if self._gs is None:
|
|
42
|
+
self._gs = GraphSense(
|
|
43
|
+
api_key=self.api_key,
|
|
44
|
+
host=self.host,
|
|
45
|
+
quiet_deprecation=self.quiet,
|
|
46
|
+
show_deprecated=os.environ.get(
|
|
47
|
+
"GRAPHSENSE_CLIENT_SHOW_DEPRECATED_ENDPOINTS"
|
|
48
|
+
)
|
|
49
|
+
== "1",
|
|
50
|
+
# Resolve click's current stderr stream on each write so
|
|
51
|
+
# `CliRunner(mix_stderr=False)` on click 8.1 captures it.
|
|
52
|
+
deprecation_stream=lambda: click.get_text_stream("stderr"),
|
|
53
|
+
)
|
|
54
|
+
return self._gs
|
|
55
|
+
|
|
56
|
+
def read_input_text(self) -> Optional[str]:
|
|
57
|
+
"""Read the input blob from --input or, if stdin is piped, from stdin."""
|
|
58
|
+
if self.input:
|
|
59
|
+
with open(self.input, "r", encoding="utf-8") as fh:
|
|
60
|
+
return fh.read()
|
|
61
|
+
if not sys.stdin.isatty():
|
|
62
|
+
data = sys.stdin.read()
|
|
63
|
+
return data if data else None
|
|
64
|
+
return None
|