code-node-graph 0.3.0__tar.gz → 0.5.0__tar.gz

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 (188) hide show
  1. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/CHANGELOG.md +44 -1
  2. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/PKG-INFO +2 -1
  3. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/README.md +1 -0
  4. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/__init__.py +1 -1
  5. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/graph.py +151 -8
  6. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/model.py +45 -0
  7. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/reader.py +20 -3
  8. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/cli.py +9 -1
  9. code_node_graph-0.5.0/src/code_node/history.py +151 -0
  10. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/pages.py +6 -2
  11. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/build.py +5 -3
  12. code_node_graph-0.5.0/src/code_node/report/changes.py +42 -0
  13. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/graph.py +336 -53
  14. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/style.py +40 -4
  15. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/walk.py +20 -5
  16. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/serve.py +2 -2
  17. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/tests/test_code_node.py +140 -6
  18. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/.gitignore +0 -0
  19. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/LICENSE +0 -0
  20. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/examples/books-api/README.md +0 -0
  21. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/examples/cart-service/README.md +0 -0
  22. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/examples/orders-service/README.md +0 -0
  23. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/pyproject.toml +0 -0
  24. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/__main__.py +0 -0
  25. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/__init__.py +0 -0
  26. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/aws_model.py +0 -0
  27. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/findings.py +0 -0
  28. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/mapping.py +0 -0
  29. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/syntax.py +0 -0
  30. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/analysis/terms.py +0 -0
  31. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/deploys/__init__.py +0 -0
  32. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/deploys/sam.py +0 -0
  33. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/deploys/serverless.py +0 -0
  34. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/kit/__init__.py +0 -0
  35. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/kit/blocks.py +0 -0
  36. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/kit/fmt.py +0 -0
  37. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/kit/highlight.py +0 -0
  38. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/kit/render.py +0 -0
  39. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/kit/text.py +0 -0
  40. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/libraries/__init__.py +0 -0
  41. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/libraries/llm.py +0 -0
  42. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/libraries/mongo.py +0 -0
  43. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/libraries/redis.py +0 -0
  44. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/libraries/sql.py +0 -0
  45. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/platforms/__init__.py +0 -0
  46. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/platforms/aws_lambda.py +0 -0
  47. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/platforms/web.py +0 -0
  48. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/project.py +0 -0
  49. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/__init__.py +0 -0
  50. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/brands.py +0 -0
  51. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/editor.py +0 -0
  52. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/README.md +0 -0
  53. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/anthropic-icon.svg +0 -0
  54. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-amplify.svg +0 -0
  55. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-api-gateway.svg +0 -0
  56. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-appflow.svg +0 -0
  57. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-appsync.svg +0 -0
  58. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-athena.svg +0 -0
  59. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-backup.svg +0 -0
  60. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-batch.svg +0 -0
  61. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-certificate-manager.svg +0 -0
  62. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudformation.svg +0 -0
  63. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudfront.svg +0 -0
  64. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudtrail.svg +0 -0
  65. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudwatch.svg +0 -0
  66. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codebuild.svg +0 -0
  67. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codecommit.svg +0 -0
  68. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codedeploy.svg +0 -0
  69. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codepipeline.svg +0 -0
  70. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cognito.svg +0 -0
  71. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-config.svg +0 -0
  72. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-documentdb.svg +0 -0
  73. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-dynamodb.svg +0 -0
  74. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-ec2.svg +0 -0
  75. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-ecs.svg +0 -0
  76. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-eks.svg +0 -0
  77. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-elasticache.svg +0 -0
  78. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-elb.svg +0 -0
  79. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-eventbridge.svg +0 -0
  80. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-glacier.svg +0 -0
  81. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-glue.svg +0 -0
  82. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-iam.svg +0 -0
  83. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-keyspaces.svg +0 -0
  84. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-kinesis.svg +0 -0
  85. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-kms.svg +0 -0
  86. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-lake-formation.svg +0 -0
  87. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-lambda.svg +0 -0
  88. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-lightsail.svg +0 -0
  89. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-mq.svg +0 -0
  90. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-msk.svg +0 -0
  91. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-neptune.svg +0 -0
  92. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-open-search.svg +0 -0
  93. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-quicksight.svg +0 -0
  94. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-rds.svg +0 -0
  95. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-redshift.svg +0 -0
  96. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-route53.svg +0 -0
  97. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-s3.svg +0 -0
  98. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-secrets-manager.svg +0 -0
  99. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-ses.svg +0 -0
  100. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-shield.svg +0 -0
  101. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-sns.svg +0 -0
  102. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-sqs.svg +0 -0
  103. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-step-functions.svg +0 -0
  104. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-systems-manager.svg +0 -0
  105. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-timestream.svg +0 -0
  106. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-waf.svg +0 -0
  107. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-xray.svg +0 -0
  108. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws.svg +0 -0
  109. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/azure-icon.svg +0 -0
  110. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/django-icon.svg +0 -0
  111. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/elasticsearch.svg +0 -0
  112. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/fastapi-icon.svg +0 -0
  113. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/firebase-icon.svg +0 -0
  114. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/flask.svg +0 -0
  115. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/google-cloud.svg +0 -0
  116. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/kafka-icon.svg +0 -0
  117. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/mariadb-icon.svg +0 -0
  118. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/mongodb-icon.svg +0 -0
  119. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/mysql-icon.svg +0 -0
  120. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/openai-icon.svg +0 -0
  121. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/postgresql.svg +0 -0
  122. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/python.svg +0 -0
  123. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/rabbitmq-icon.svg +0 -0
  124. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/redis.svg +0 -0
  125. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/serverless.svg +0 -0
  126. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/slack-icon.svg +0 -0
  127. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-api-gateway.svg +0 -0
  128. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-appflow.svg +0 -0
  129. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-appsync.svg +0 -0
  130. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-athena.svg +0 -0
  131. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-backup.svg +0 -0
  132. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-batch.svg +0 -0
  133. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-certificate-manager.svg +0 -0
  134. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudformation.svg +0 -0
  135. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudfront.svg +0 -0
  136. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudtrail.svg +0 -0
  137. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudwatch.svg +0 -0
  138. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codebuild.svg +0 -0
  139. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codecommit.svg +0 -0
  140. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codedeploy.svg +0 -0
  141. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codepipeline.svg +0 -0
  142. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cognito.svg +0 -0
  143. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-config.svg +0 -0
  144. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-documentdb.svg +0 -0
  145. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-dynamodb.svg +0 -0
  146. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-ec2.svg +0 -0
  147. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-ecs.svg +0 -0
  148. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-eks.svg +0 -0
  149. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-elasticache.svg +0 -0
  150. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-elb.svg +0 -0
  151. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-eventbridge.svg +0 -0
  152. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-glacier.svg +0 -0
  153. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-glue.svg +0 -0
  154. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-iam.svg +0 -0
  155. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-keyspaces.svg +0 -0
  156. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-kinesis.svg +0 -0
  157. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-kms.svg +0 -0
  158. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-lake-formation.svg +0 -0
  159. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-lambda.svg +0 -0
  160. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-mq.svg +0 -0
  161. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-msk.svg +0 -0
  162. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-neptune.svg +0 -0
  163. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-open-search.svg +0 -0
  164. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-quicksight.svg +0 -0
  165. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-rds.svg +0 -0
  166. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-redshift.svg +0 -0
  167. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-route53.svg +0 -0
  168. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-s3.svg +0 -0
  169. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-secrets-manager.svg +0 -0
  170. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-ses.svg +0 -0
  171. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-shield.svg +0 -0
  172. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-sns.svg +0 -0
  173. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-sqs.svg +0 -0
  174. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-step-functions.svg +0 -0
  175. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-systems-manager.svg +0 -0
  176. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-timestream.svg +0 -0
  177. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-waf.svg +0 -0
  178. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-xray.svg +0 -0
  179. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/snowflake-icon.svg +0 -0
  180. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/sqlite.svg +0 -0
  181. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/stripe.svg +0 -0
  182. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/supabase-icon.svg +0 -0
  183. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/logos/twilio-icon.svg +0 -0
  184. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/outline.py +0 -0
  185. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/src/code_node/report/tabs.py +0 -0
  186. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/tests/snapshots/books-api.txt +0 -0
  187. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/tests/snapshots/cart-service.txt +0 -0
  188. {code_node_graph-0.3.0 → code_node_graph-0.5.0}/tests/snapshots/orders-service.txt +0 -0
@@ -4,6 +4,47 @@ Every change a user would notice, newest first ([Keep a Changelog](https://keepa
4
4
  follow [Semantic Versioning](https://semver.org/); before 1.0, a minor version may change what a command does.
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - The graph moves like a canvas: drag it to pan in any direction, hold Ctrl (⌘ on a Mac) and scroll, or pinch a
12
+ trackpad, to zoom around the pointer, and `+`, `-` and `0` when it has focus. It stays in a box as wide as the page and
13
+ about the window's height, however far it's zoomed, so the scroll bar under a tall graph is no longer the only way across;
14
+ **Fit** puts all of it in that box, and − and + buttons in its corner say how big it's drawn. This needs JavaScript;
15
+ without it the graph scrolls as before.
16
+
17
+ - Each function says what git says about it, when the folder is in git: who changed it last, how long ago and in
18
+ which commit (a link to the commit on GitHub or GitLab), how many commits and people its lines come from, and how
19
+ many of its lines changed since the last commit. It's in a box's hover card and code panel and in the step that
20
+ goes into it. It's read with `git blame`, once per file; `--no-git` leaves it out.
21
+
22
+ ### Changed
23
+
24
+ - A box's code opens in a panel over the graph, rather than pushing the graph aside on a wide screen.
25
+
26
+ ## [0.4.0] - 2026-10-20
27
+
28
+ ### Fixed
29
+
30
+ - Modules beside the entry point's file are read as its own code when it imports them by their own names, as a
31
+ Lambda function's folder does (`from helpers import x` in `src/app/handler.py`). They were taken for installed
32
+ libraries, so their functions, AWS calls and environment variables were missing, even when the folder is also a
33
+ package from the project's root.
34
+
35
+ ### Added
36
+
37
+ - A box's "+12 calls not drawn" line opens them: click it and the graph starts from that function, with every call it
38
+ makes, a path above it back up to the whole graph, and the browser's Back button to return. **All** in the graph's
39
+ bar draws every function at once, for up to 150. This needs JavaScript; without it the page works as before.
40
+ - A box's code panel closes when you click anywhere outside it, or press Escape, as well as with its ×. Clicking
41
+ another box still opens that one's code.
42
+
43
+ ### Changed
44
+
45
+ - The graph says why functions are left out: too many to draw at once, or too deep, instead of always "too deep or
46
+ too many".
47
+
7
48
  ## [0.3.0] - 2026-10-20
8
49
 
9
50
  ### Changed
@@ -171,7 +212,9 @@ follow [Semantic Versioning](https://semver.org/); before 1.0, a minor version m
171
212
  - `--platform NAME` runs a platform's checks whatever the entry point is; `--lambda` is short for
172
213
  `--platform lambda`. `EntryPoint.is_lambda` and `PageOptions.is_lambda` are now `platform` (`"lambda"` or `""`).
173
214
 
174
- [Unreleased]: https://github.com/utkarsh5026/code-node/compare/v0.3.0...HEAD
215
+ [Unreleased]: https://github.com/utkarsh5026/code-node/compare/v0.5.0...HEAD
216
+ [0.5.0]: https://github.com/utkarsh5026/code-node/compare/v0.4.0...v0.5.0
217
+ [0.4.0]: https://github.com/utkarsh5026/code-node/compare/v0.3.0...v0.4.0
175
218
  [0.3.0]: https://github.com/utkarsh5026/code-node/compare/v0.2.0...v0.3.0
176
219
  [0.2.0]: https://github.com/utkarsh5026/code-node/compare/v0.1.0...v0.2.0
177
220
  [0.1.0]: https://github.com/utkarsh5026/code-node/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: code-node-graph
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: See how Python code works without running it: a graph of who calls whom from an entry point, and what each path reaches.
5
5
  Project-URL: Homepage, https://github.com/utkarsh5026/code-node
6
6
  Project-URL: Documentation, https://utkarsh5026.github.io/code-node/
@@ -117,6 +117,7 @@ string is flagged.
117
117
  | `-o`, `--out`, `--no-open` | Where to save (default `~/.cache/code-node/`), and whether to open it |
118
118
  | `--platform NAME`, `--lambda` | Run a platform's checks on any entry point |
119
119
  | `--editor vscode\|cursor\|none` | Editor the line links open (default `vscode`, WSL-aware) |
120
+ | `--no-git` | Leave out what git says about each function (who changed it last, when, in which commit) |
120
121
 
121
122
  </details>
122
123
 
@@ -87,6 +87,7 @@ string is flagged.
87
87
  | `-o`, `--out`, `--no-open` | Where to save (default `~/.cache/code-node/`), and whether to open it |
88
88
  | `--platform NAME`, `--lambda` | Run a platform's checks on any entry point |
89
89
  | `--editor vscode\|cursor\|none` | Editor the line links open (default `vscode`, WSL-aware) |
90
+ | `--no-git` | Leave out what git says about each function (who changed it last, when, in which commit) |
90
91
 
91
92
  </details>
92
93
 
@@ -13,7 +13,7 @@ from .libraries import Library, register_library
13
13
  from .platforms import Platform, get_platform, register_platform
14
14
  from .project import EntryPoint, Project, entry_points, read_project
15
15
 
16
- __version__ = "0.3.0"
16
+ __version__ = "0.5.0"
17
17
 
18
18
  __all__ = [
19
19
  "CodeGraph",
@@ -8,14 +8,14 @@ from __future__ import annotations
8
8
  import heapq
9
9
  import re
10
10
  from collections import defaultdict
11
- from dataclasses import dataclass, field
11
+ from dataclasses import dataclass, field, replace
12
12
 
13
13
  from ..kit.fmt import _plural
14
14
  from ..kit.text import _clip
15
15
 
16
16
  from .syntax import _a
17
17
  from .aws_model import _service_name
18
- from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, LibraryCall
18
+ from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, History, LibraryCall
19
19
  from .findings import _listed, _short_name
20
20
  from ..platforms import get_platform
21
21
 
@@ -55,6 +55,8 @@ class GraphNode:
55
55
  when: str = "" # a function called only under an if, everywhere it's called: 'check(order)'
56
56
  icon: str = "" # a service's icon, when its plugin names one: 'database', 'queue', 'spark'; or the entry
57
57
  # point's logo, when its platform names one: 'fastapi-icon'
58
+ name: str = "" # a function's box: the function, as module.name ('orders.repo.save')
59
+ history: History | None = None # a function's box: what git says about its lines
58
60
 
59
61
 
60
62
  @dataclass
@@ -85,6 +87,7 @@ class CodeGraph:
85
87
  nodes: list[GraphNode] = field(default_factory=list)
86
88
  edges: list[GraphEdge] = field(default_factory=list)
87
89
  hidden: int = 0 # functions left out to keep the graph readable (too deep, or too many)
90
+ too_deep: int = 0 # ...of them, the ones deeper than it draws
88
91
 
89
92
  def node(self, node_id: str) -> GraphNode | None:
90
93
  return next((n for n in self.nodes if n.id == node_id), None)
@@ -256,11 +259,13 @@ def _pick(
256
259
  order: dict[str, int],
257
260
  room: int,
258
261
  max_depth: int,
262
+ first: bool = False,
259
263
  ) -> list[str]:
260
264
  """
261
265
  The functions the graph draws besides the entry point, at most `room` of them and max_depth calls deep: grown out
262
266
  from the entry point (and what runs at import), each time taking the one the drawn boxes call that leads to the
263
- most code, so the boxes are the ones that do the work and each is joined to one above it.
267
+ most code, so the boxes are the ones that do the work and each is joined to one above it. first takes every
268
+ function the entry point calls before the rest.
264
269
  """
265
270
 
266
271
  def depth(name: str) -> int:
@@ -283,6 +288,14 @@ def _pick(
283
288
  if name not in level:
284
289
  offer(name)
285
290
  picked: list[str] = []
291
+ if first:
292
+ for child, _ in children.get(entry, []):
293
+ if child in by_name and child != entry and child not in picked and depth(child) <= max_depth:
294
+ picked.append(child)
295
+ for name in list(picked):
296
+ for child, _ in children.get(name, []):
297
+ if child not in picked:
298
+ offer(child)
286
299
  while queue and len(picked) < room:
287
300
  name = heapq.heappop(queue)[3]
288
301
  if name in picked:
@@ -294,7 +307,46 @@ def _pick(
294
307
  return picked
295
308
 
296
309
 
297
- def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_depth: int = _GRAPH_DEPTH) -> CodeGraph:
310
+ def _focused(cmap: CodeMap, focus: str) -> CodeMap:
311
+ """
312
+ The map cut down to one of its functions and what it calls, however deep, as if the code started there: its
313
+ calls in from elsewhere, what runs at import and the entry point's input left out.
314
+ """
315
+ children: dict[str, list[str]] = defaultdict(list)
316
+ for f in cmap.functions:
317
+ for where in f.called_from:
318
+ children[where.rpartition(" line ")[0]].append(f.name)
319
+ scope, todo = {focus}, [focus]
320
+ while todo:
321
+ for child in children.get(todo.pop(), []):
322
+ if child not in scope:
323
+ scope.add(child)
324
+ todo.append(child)
325
+ root = next(f for f in cmap.functions if f.name == focus)
326
+ functions = []
327
+ for f in cmap.functions:
328
+ if f.name in scope:
329
+ kept = [] if f.name == focus else [w for w in f.called_from if w.rpartition(" line ")[0] in scope]
330
+ functions.append(replace(f, called_from=kept, when={w: t for w, t in f.when.items() if w in kept}))
331
+ return replace(
332
+ cmap,
333
+ entry=focus,
334
+ handler_file=root.file,
335
+ entry_line=root.line,
336
+ startup=[],
337
+ event=[],
338
+ functions=functions,
339
+ aws=[c for c in cmap.aws if c.function in scope],
340
+ library_calls=[c for c in cmap.library_calls if c.function in scope],
341
+ env=[e for e in cmap.env if e.function in scope],
342
+ spots=[s for s in cmap.spots if s.function in scope and not s.startup],
343
+ routes=[r for r in cmap.routes if r[2] in scope],
344
+ )
345
+
346
+
347
+ def code_graph(
348
+ cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_depth: int = _GRAPH_DEPTH, focus: str = ""
349
+ ) -> CodeGraph:
298
350
  """
299
351
  How the code fits together, as boxes and arrows, from a CodeMap: on the left what comes in (the event, with the
300
352
  fields it reads, and the environment variables), then the handler, what runs at start-up, and its own functions
@@ -304,10 +356,16 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
304
356
  they do is drawn from the nearest box above them that is.
305
357
  The functions drawn are grown out from the entry point, each time the one that leads to the most code (and to AWS,
306
358
  the web and the environment), so the boxes are the ones that do the work, not the nearest helpers.
359
+ focus draws the graph from one of its functions instead (module.name): it and what it calls, every call it makes
360
+ drawn even past max_functions, and nothing from what comes in or what runs at import.
307
361
  """
308
362
  graph = CodeGraph()
309
363
  if cmap.note or cmap.entry is None:
310
364
  return graph
365
+ if focus:
366
+ if not any(f.name == focus for f in cmap.functions):
367
+ return graph
368
+ cmap = _focused(cmap, focus)
311
369
  platform = get_platform(cmap.platform)
312
370
  entry = cmap.entry
313
371
  by_name = {f.name: f for f in cmap.functions}
@@ -342,11 +400,15 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
342
400
  todo.append(child)
343
401
  order = {f.name: i for i, f in enumerate(cmap.functions)}
344
402
  weight = _weights(cmap, children)
345
- picked = _pick(cmap, entry, start_key, children, level, weight, order, max_functions - entry_is_function, max_depth)
403
+ room = max_functions - entry_is_function
404
+ if focus: # a function opened on its own shows every call it makes
405
+ room = max(room, len({child for child, _ in children.get(entry, [])}))
406
+ picked = _pick(cmap, entry, start_key, children, level, weight, order, room, max_depth, first=bool(focus))
346
407
  shown = ([by_name[entry]] if entry_is_function else []) + sorted(
347
408
  (by_name[name] for name in picked), key=lambda f: (level.get(f.name, f.depth + 1), order[f.name])
348
409
  )
349
410
  graph.hidden = len(cmap.functions) - len(shown)
411
+ graph.too_deep = sum(1 for f in cmap.functions if level.get(f.name, f.depth + 1) > max_depth)
350
412
  ids = {f.name: f"f{i}" for i, f in enumerate(shown)}
351
413
  drawn_level = {entry: 0, start_key: 0} # calls from the entry point through the boxes drawn: each box's column
352
414
  todo = [entry, start_key]
@@ -408,7 +470,9 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
408
470
  reasons[call.function].append(f"{call.label} isn't allowed: {denied[call.permission]} (line {call.line})")
409
471
  for function, why in platform.marks(cmap).items():
410
472
  reasons[function] += why
411
- handler_reasons = [m.split(":")[0] for level_, m in platform.entry_findings(cmap) if level_ == "warn"]
473
+ handler_reasons = (
474
+ [] if focus else [m.split(":")[0] for level_, m in platform.entry_findings(cmap) if level_ == "warn"]
475
+ )
412
476
  unset = platform.unset_env(cmap)
413
477
 
414
478
  # the lines in each function worth pointing at: where it calls, reads or sends what the graph draws
@@ -455,7 +519,7 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
455
519
  when=_when(f, [where for where in f.called_from if where.rpartition(" line ")[0] == caller]),
456
520
  )
457
521
  )
458
- has_input = bool(cmap.event) or platform.draws_input
522
+ has_input = not focus and (bool(cmap.event) or platform.draws_input)
459
523
  if has_input:
460
524
  edges.insert(0, GraphEdge("event", entry_id, "event", "its input"))
461
525
 
@@ -769,7 +833,9 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
769
833
  ],
770
834
  more=more,
771
835
  when=_when(f, f.called_from),
772
- icon=platform.entry_icon(cmap) if is_entry else "",
836
+ icon=platform.entry_icon(cmap) if is_entry and not focus else "",
837
+ name=f.name,
838
+ history=f.history,
773
839
  )
774
840
  )
775
841
  out_column = 1 + max([n.column for n in graph.nodes if n.kind in ("handler", "function", "start")] + [1])
@@ -778,3 +844,80 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
778
844
  graph.nodes += reach
779
845
  graph.edges = edges
780
846
  return graph
847
+
848
+
849
+ _GRAPH_VIEWS = 120 # functions opened on their own, at most: past that a box's calls left out stay in the steps
850
+
851
+ _GRAPH_EVERYTHING = 150 # functions, at most, for a graph of all of them besides the one drawn
852
+
853
+
854
+ @dataclass
855
+ class GraphView:
856
+ """
857
+ The graph from one of the functions a graph leaves calls out of: the function and what it calls (`graph`), and the
858
+ calls from the entry point down to it (`path`, module.name each, the entry point first and the function last).
859
+ """
860
+
861
+ name: str
862
+ graph: CodeGraph
863
+ path: list[str] = field(default_factory=list)
864
+
865
+
866
+ def _call_path(cmap: CodeMap, name: str) -> list[str]:
867
+ """
868
+ The fewest calls from the entry point to a function: [entry, ..., name]. [name] when the entry point isn't above it.
869
+ """
870
+ known = {f.name for f in cmap.functions} | {cmap.entry or ""}
871
+ parents: dict[str, list[str]] = defaultdict(list)
872
+ for f in cmap.functions:
873
+ for where in f.called_from:
874
+ caller = where.rpartition(" line ")[0]
875
+ if caller in known:
876
+ parents[f.name].append(caller)
877
+ came: dict[str, str] = {name: ""}
878
+ todo = [name]
879
+ while todo:
880
+ key = todo.pop(0)
881
+ if key == cmap.entry:
882
+ path = [key]
883
+ while came[path[-1]]:
884
+ path.append(came[path[-1]])
885
+ return path
886
+ for parent in parents.get(key, []):
887
+ if parent not in came:
888
+ came[parent] = key
889
+ todo.append(parent)
890
+ return [name]
891
+
892
+
893
+ def graph_views(
894
+ cmap: CodeMap,
895
+ graph: CodeGraph,
896
+ *,
897
+ max_functions: int = _GRAPH_FUNCTIONS,
898
+ max_depth: int = _GRAPH_DEPTH,
899
+ most: int = _GRAPH_VIEWS,
900
+ ) -> list[GraphView]:
901
+ """
902
+ A graph from each function whose calls `graph` leaves out, and from each such function in those, so that from any
903
+ box with calls not drawn, its own graph has them: at most `most`, nearest the entry point first.
904
+ """
905
+ views: dict[str, GraphView] = {}
906
+ todo = [graph]
907
+ while todo and len(views) < most:
908
+ for n in todo.pop(0).nodes:
909
+ if not (n.more and n.name) or n.name in views or len(views) >= most:
910
+ continue
911
+ opened = code_graph(cmap, max_functions=max_functions, max_depth=max_depth, focus=n.name)
912
+ views[n.name] = GraphView(n.name, opened, _call_path(cmap, n.name))
913
+ todo.append(opened)
914
+ return list(views.values())
915
+
916
+
917
+ def everything_graph(cmap: CodeMap, graph: CodeGraph, most: int = _GRAPH_EVERYTHING) -> CodeGraph | None:
918
+ """
919
+ Every function in one graph, when `graph` leaves some out and there are at most `most` of them; else None.
920
+ """
921
+ if not graph.hidden or len(cmap.functions) > most:
922
+ return None
923
+ return code_graph(cmap, max_functions=len(cmap.functions) + 1, max_depth=len(cmap.functions) + 1)
@@ -173,6 +173,50 @@ class EventField:
173
173
  return _event_label(self.path)
174
174
 
175
175
 
176
+ @dataclass
177
+ class Commit:
178
+ """
179
+ A commit, as git blame tells it.
180
+ """
181
+
182
+ sha: str
183
+ author: str
184
+ time: int # when it was made, in seconds since 1970
185
+ subject: str # its message's first line
186
+ url: str = "" # its page on the repository's host, when that's GitHub or GitLab
187
+
188
+ @property
189
+ def short(self) -> str:
190
+ return self.sha[:7]
191
+
192
+
193
+ @dataclass
194
+ class History:
195
+ """
196
+ What git says about a function's lines as they are now: the commit that last changed one of them, how many commits
197
+ and people they come from, and how many have changed since the last commit. Read by history.py; None on a
198
+ function when the folder isn't in git.
199
+ """
200
+
201
+ last: Commit | None # the newest commit among its lines; None when none of them is committed yet
202
+ commits: int # the commits its lines come from
203
+ authors: list[str] # who wrote them, most lines first
204
+ first: int # when the oldest of them was made (about when the function was written), in seconds since 1970
205
+ uncommitted: int # its lines changed since the last commit
206
+ now: int # when this was read, for 'three days ago'
207
+
208
+ def ago(self, time: int) -> str:
209
+ """
210
+ 'today', '3 days ago', '2 months ago', '1 year ago'.
211
+ """
212
+ days = max(0, self.now - time) // 86_400
213
+ for size, unit in ((365, "year"), (30, "month"), (7, "week"), (1, "day")):
214
+ if days >= size:
215
+ count = days // size
216
+ return f"{count} {unit}{'' if count == 1 else 's'} ago"
217
+ return "today"
218
+
219
+
176
220
  @dataclass
177
221
  class CodeFunction:
178
222
  """
@@ -190,6 +234,7 @@ class CodeFunction:
190
234
  # nearest around the call, else the nearest `if x: return` above it ('args.serve', not every check before it)
191
235
  depth: int = 0 # calls from the entry point (the entry point is 0)
192
236
  is_async: bool = False # async def: a call that blocks in it holds up everything else on its event loop
237
+ history: History | None = None # what git says about its lines, when the folder is in git (history.py)
193
238
 
194
239
 
195
240
  @dataclass
@@ -203,6 +203,22 @@ class _CodeReader:
203
203
  module = module[: -len(".__init__")] if module.endswith(".__init__") else module
204
204
  if module and module != "__init__":
205
205
  self.sources[module] = path
206
+ here = (cmap.handler_file or "").rpartition("/")[0]
207
+ here = here + "/" if here else ""
208
+ if here.startswith(root) and here != root:
209
+ # the entry point's own folder is on sys.path too, as a script's is and a Lambda function's code is:
210
+ # `from helpers import x` beside it is its own code, though the folder is also a package from the root
211
+ beside: set[str] = set()
212
+ for path in texts:
213
+ if not path.startswith(here) or not path.endswith(".py") or "site-packages" in path:
214
+ continue
215
+ module = path[len(here) : -3].replace("/", ".")
216
+ module = module[: -len(".__init__")] if module.endswith(".__init__") else module
217
+ if module and module != "__init__" and "-" not in module and module not in self.sources:
218
+ self.sources[module] = path
219
+ beside.add(module.split(".")[0])
220
+ self.tops |= beside
221
+ self.libraries = libraries - beside
206
222
  self.modules: dict[str, _Module] = {}
207
223
  self.defs: dict[str, tuple[str, Any, str | None]] = {} # key -> (module, node, class key)
208
224
  self.classes: dict[str, dict[str, Any]] = {}
@@ -311,8 +327,9 @@ class _CodeReader:
311
327
  cmap.event = list(self.event_reads.values())
312
328
  cmap.routes = [(method, path, key) for method, path, key, _ in self.routes]
313
329
  loaded = [m for m in self.modules.values() if m.tree is not None]
314
- cmap.other_files = sum(
315
- 1 for name, path in self.sources.items() if name not in self.modules and not _JUNK_RE.search(path)
330
+ read = {path for name, path in self.sources.items() if name in self.modules} # a file has two names, at most
331
+ cmap.other_files = len(
332
+ {path for path in self.sources.values() if path not in read and not _JUNK_RE.search(path)}
316
333
  )
317
334
  for m in loaded:
318
335
  for node in m.tree.body:
@@ -324,7 +341,7 @@ class _CodeReader:
324
341
  if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and prefix + f.name not in self.reached
325
342
  )
326
343
  self._secrets(m)
327
- for name, path in self.sources.items():
344
+ for path, name in {path: name for name, path in reversed(self.sources.items())}.items():
328
345
  for match in _AWS_KEY_RE.finditer(self.texts[path]):
329
346
  line = self.texts[path].count("\n", 0, match.start()) + 1
330
347
  self._spot("secret", path, line, name, "", "an AWS access key", ok=False)
@@ -99,6 +99,9 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
99
99
  default="vscode",
100
100
  help="the editor the page's line links open (default vscode; none for no links)",
101
101
  )
102
+ parser.add_argument(
103
+ "--no-git", action="store_true", help="leave out what git says about each function (who changed it, when)"
104
+ )
102
105
  return parser.parse_args(argv)
103
106
 
104
107
 
@@ -286,7 +289,12 @@ def main(argv: list[str] | None = None) -> int:
286
289
  if entry is None:
287
290
  return 1
288
291
  options = PageOptions(
289
- draw_all=args.all, functions=args.functions, depth=args.depth, platform=args.platform, editor=args.editor
292
+ draw_all=args.all,
293
+ functions=args.functions,
294
+ depth=args.depth,
295
+ platform=args.platform,
296
+ editor=args.editor,
297
+ git=not args.no_git,
290
298
  )
291
299
  if args.mermaid:
292
300
  print(make_mermaid(project.root, entry, options))
@@ -0,0 +1,151 @@
1
+ """
2
+ What git says about the functions a map shows: `git blame` on the lines of each one, once per file, for the commit
3
+ that last changed it, how many commits and people its lines come from, and the lines changed since the last commit.
4
+
5
+ git is run on the folder, never the code in it, and only to read: blame with no text conversion and no file system
6
+ monitor. A folder whose own git config names a filter command (one git would run on the files) isn't read at all.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import time
15
+ from collections import Counter
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from pathlib import Path
18
+
19
+ from .analysis.model import CodeFunction, Commit, History
20
+
21
+ _TIMEOUT = 20 # seconds a git command may take before its file goes without history
22
+
23
+ _WORKERS = 8 # files blamed at once
24
+
25
+ _UNCOMMITTED = "0" * 40 # the commit blame gives lines changed since the last one
26
+
27
+ _HOSTS = {"github.com": "{}/commit/{}", "gitlab.com": "{}/-/commit/{}"} # a commit's page, by host
28
+
29
+ _SAFE = ["-c", "core.fsmonitor=false", "--no-pager"] # no program of the folder's config runs
30
+
31
+
32
+ def _git(root: Path, *args: str) -> str | None:
33
+ """
34
+ What a git command prints, run in root; None when git isn't there or fails (not a repository, a file it doesn't
35
+ track, a folder it doesn't trust).
36
+ """
37
+ try:
38
+ done = subprocess.run(
39
+ ["git", *_SAFE, "-C", str(root), *args],
40
+ capture_output=True,
41
+ text=True,
42
+ encoding="utf-8",
43
+ errors="replace",
44
+ timeout=_TIMEOUT,
45
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"},
46
+ )
47
+ except (OSError, subprocess.SubprocessError):
48
+ return None
49
+ return done.stdout if done.returncode == 0 else None
50
+
51
+
52
+ def _runs_filters(root: Path) -> bool:
53
+ """
54
+ Whether the folder's own git config (not the user's) names a filter command, which blame would run on its files.
55
+ """
56
+ found = _git(root, "config", "--show-scope", "--get-regexp", r"^filter\..*\.(clean|process)$")
57
+ return any(line.split("\t", 1)[0] in ("local", "worktree") for line in (found or "").splitlines())
58
+
59
+
60
+ def _commit_page(remote: str) -> str:
61
+ """
62
+ The repository's web address with a {} for the sha, from its origin: 'https://github.com/o/r/commit/{}'.
63
+ """
64
+ found = re.fullmatch(r"(?:https?://(?:[^@/]+@)?|ssh://(?:[^@/]+@)?|[^@/]+@)([^/:]+)[:/](.+?)(?:\.git)?/?", remote)
65
+ if not found or found.group(1) not in _HOSTS:
66
+ return ""
67
+ return _HOSTS[found.group(1)].format(f"https://{found.group(1)}/{found.group(2)}", "{}")
68
+
69
+
70
+ def _blame(root: Path, file: str, start: int, end: int) -> dict[int, tuple[str, dict[str, str]]]:
71
+ """
72
+ Each line from start to end of the file as it is now: line -> (the commit it comes from, that commit's headers).
73
+ """
74
+ out = _git(root, "blame", "--porcelain", "--no-textconv", "-L", f"{start},{end}", "--", file)
75
+ lines: dict[int, tuple[str, dict[str, str]]] = {}
76
+ if not out:
77
+ return lines
78
+ headers: dict[str, dict[str, str]] = {}
79
+ sha = ""
80
+ for row in out.splitlines():
81
+ if row.startswith("\t"):
82
+ continue
83
+ parts = row.split(" ")
84
+ if len(parts) >= 3 and len(parts[0]) == 40 and parts[1].isdigit() and parts[2].isdigit():
85
+ sha = parts[0]
86
+ headers.setdefault(sha, {})
87
+ lines[int(parts[2])] = (sha, headers[sha])
88
+ elif sha:
89
+ key, _, value = row.partition(" ")
90
+ headers[sha].setdefault(key, value)
91
+ return lines
92
+
93
+
94
+ def _history(blamed: dict[int, tuple[str, dict[str, str]]], start: int, end: int, page: str, now: int) -> History:
95
+ """
96
+ A function's history from its lines' blame.
97
+ """
98
+ commits: dict[str, Commit] = {}
99
+ by_author: Counter[str] = Counter()
100
+ uncommitted = 0
101
+ for k in range(start, end + 1):
102
+ if k not in blamed:
103
+ continue
104
+ sha, said = blamed[k]
105
+ if sha == _UNCOMMITTED:
106
+ uncommitted += 1
107
+ continue
108
+ if sha not in commits:
109
+ commits[sha] = Commit(
110
+ sha,
111
+ said.get("author", "?"),
112
+ int(said.get("author-time", "0") or 0),
113
+ said.get("summary", ""),
114
+ page.format(sha) if page else "",
115
+ )
116
+ by_author[commits[sha].author] += 1
117
+ last = max(commits.values(), key=lambda c: c.time, default=None)
118
+ return History(
119
+ last=last,
120
+ commits=len(commits),
121
+ authors=[name for name, _ in by_author.most_common()],
122
+ first=min((c.time for c in commits.values()), default=0),
123
+ uncommitted=uncommitted,
124
+ now=now,
125
+ )
126
+
127
+
128
+ def read_history(root: Path, functions: list[CodeFunction]) -> int:
129
+ """
130
+ Fills in each function's history from git, blaming each file once over the lines its functions take up. How many
131
+ functions it found history for: 0 when the folder isn't in git, git isn't installed, or the folder's config would
132
+ have git run a program of its own.
133
+ """
134
+ spans: dict[str, tuple[int, int]] = {}
135
+ for f in functions:
136
+ if f.file and f.line and f.lines:
137
+ start, end = spans.get(f.file, (f.line, f.line + f.lines - 1))
138
+ spans[f.file] = (min(start, f.line), max(end, f.line + f.lines - 1))
139
+ if not spans or _git(root, "rev-parse", "--is-inside-work-tree") is None or _runs_filters(root):
140
+ return 0
141
+ page = _commit_page((_git(root, "remote", "get-url", "origin") or "").strip())
142
+ now = int(time.time())
143
+ with ThreadPoolExecutor(max_workers=min(_WORKERS, len(spans))) as pool:
144
+ blamed = dict(zip(spans, pool.map(lambda file: _blame(root, file, *spans[file]), spans)))
145
+ found = 0
146
+ for f in functions:
147
+ lines = blamed.get(f.file)
148
+ if lines:
149
+ f.history = _history(lines, f.line, f.line + f.lines - 1, page, now)
150
+ found += 1
151
+ return found
@@ -6,13 +6,14 @@ once, and the server makes it again on every change.
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, replace
10
10
  from pathlib import Path
11
11
 
12
12
  from .analysis.graph import code_graph
13
13
  from .analysis.mapping import entry_file, map_code
14
14
  from .analysis.model import CodeMap
15
15
  from .deploys import find_deployment
16
+ from .history import read_history
16
17
  from .project import EntryPoint, Project, read_project
17
18
  from .report.build import page_blocks
18
19
  from .report.editor import editor_link
@@ -38,6 +39,7 @@ class PageOptions:
38
39
  depth: int = GRAPH_DEPTH
39
40
  platform: str = "" # --platform: its checks run whatever the entry point is ('' leaves it to the entry point)
40
41
  editor: str = "vscode" # --editor: the editor its line links open ('vscode', 'cursor'; 'none' for no links)
42
+ git: bool = True # what git says about each function (--no-git leaves it out)
41
43
 
42
44
  @property
43
45
  def limits(self) -> tuple[int, int]:
@@ -63,6 +65,8 @@ def map_entry(root: Path, entry: EntryPoint, options: PageOptions) -> tuple[Code
63
65
  platform=options.platform or entry.platform,
64
66
  deployment=deployment,
65
67
  )
68
+ if options.git:
69
+ read_history(project.root, cmap.functions)
66
70
  return cmap, project
67
71
 
68
72
 
@@ -87,6 +91,6 @@ def make_mermaid(root: Path, entry: EntryPoint, options: PageOptions) -> str:
87
91
  """
88
92
  The graph as Mermaid.
89
93
  """
90
- cmap, _ = map_entry(root, entry, options)
94
+ cmap, _ = map_entry(root, entry, replace(options, git=False)) # Mermaid has nowhere to say it
91
95
  max_functions, max_depth = options.limits
92
96
  return code_graph(cmap, max_functions=max_functions, max_depth=max_depth).mermaid()